diff --git a/Gopkg.lock b/Gopkg.lock index e89aea89..843750af 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -48,12 +48,6 @@ revision = "e4805606f5138247510183050abe642344275ebd" version = "v1.14.10" -[[projects]] - name = "github.com/awslabs/aws-sdk-go" - packages = ["service/iam"] - revision = "aff39e8473db578a1cec9ac2f829a56813c1d631" - version = "v1.14.14" - [[projects]] name = "github.com/boltdb/bolt" packages = ["."] @@ -378,6 +372,6 @@ [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "53de8c655e9993ee367c812b02ef45bf086b8675785ab42995a7906eacdeff09" + inputs-digest = "e7d94b57dbe3a2a6ac0a68b37a67f85bb57e7ea37da404c08407808f032f60ef" solver-name = "gps-cdcl" solver-version = 1 diff --git a/Makefile b/Makefile index 7c798cd2..f0b1158f 100644 --- a/Makefile +++ b/Makefile @@ -48,7 +48,7 @@ clean: # Run static analysis .PHONY: lint lint: - PATH=$(PATH):./bin bash -c './bin/gometalinter --vendor --deadline=60s ./...' + PATH=$(PATH):./bin bash -c './bin/gometalinter --vendor --deadline=120s ./...' (cd ./daemon/web; npm run lint) # Run test suite without Docker ops diff --git a/cmd/provision.go b/cmd/provision.go index e5b1cd38..f1162000 100644 --- a/cmd/provision.go +++ b/cmd/provision.go @@ -141,16 +141,15 @@ var cmdProvisionECS = &cobra.Command{ if !found { log.Fatal("vps setup did not complete properly") } - gitURL, err := local.GetRepoRemote("origin") - if err != nil { - log.Fatal(err) - } // Bootstrap remote fmt.Printf("Initializing Inertia daemon at %s...\n", inertia.RemoteVPS.IP) - err = inertia.BootstrapRemote(common.ExtractRepository(common.GetSSHRemoteURL(gitURL))) + err = inertia.BootstrapRemote(config.Project) if err != nil { log.Fatal(err) } + + // Save udpated config + config.Write(path) }, } diff --git a/daemon/inertiad/auth/auth_test.go b/daemon/inertiad/auth/auth_test.go deleted file mode 100644 index 03944155..00000000 --- a/daemon/inertiad/auth/auth_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package auth - -import ( - "os" - "path" - "testing" - - jwt "github.com/dgrijalva/jwt-go" - "github.com/stretchr/testify/assert" -) - -var ( - testPrivateKey = []byte("very_sekrit_key") - testToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.AqFWnFeY9B8jj7-l3z0a9iaZdwIca7xhUF3fuaJjU90" - testInertiaKeyPath = path.Join(os.Getenv("GOPATH"), "/src/github.com/ubclaunchpad/inertia/test/keys/id_rsa") -) - -// Helper function that implements jwt.keyFunc -func getFakeAPIKey(tok *jwt.Token) (interface{}, error) { - return testPrivateKey, nil -} - -func TestGetAPIPrivateKey(t *testing.T) { - key, err := getAPIPrivateKeyFromPath(nil, testInertiaKeyPath) - assert.Nil(t, err) - assert.Contains(t, string(key.([]byte)), "user: git, name: ssh-public-keys") -} - -func TestGetGithubKey(t *testing.T) { - pemFile, err := os.Open(testInertiaKeyPath) - assert.Nil(t, err) - _, err = GetGithubKey(pemFile) - assert.Nil(t, err) -} - -func TestGenerateToken(t *testing.T) { - token, err := GenerateToken(testPrivateKey) - assert.Nil(t, err, "generateToken must not fail") - assert.Equal(t, token, testToken) - - otherToken, err := GenerateToken([]byte("another_sekrit_key")) - assert.Nil(t, err) - assert.NotEqual(t, token, otherToken) -} diff --git a/daemon/inertiad/auth/permissions.go b/daemon/inertiad/auth/permissions.go index d313ea5f..2c1f497e 100644 --- a/daemon/inertiad/auth/permissions.go +++ b/daemon/inertiad/auth/permissions.go @@ -9,6 +9,11 @@ import ( jwt "github.com/dgrijalva/jwt-go" "github.com/ubclaunchpad/inertia/common" + "github.com/ubclaunchpad/inertia/daemon/inertiad/crypto" +) + +const ( + errMalformedHeaderMsg = "malformed authorization error" ) // PermissionsHandler handles users, permissions, and sessions on top @@ -18,7 +23,6 @@ type PermissionsHandler struct { users *userManager sessions *sessionManager mux *http.ServeMux - keyLookup func(*jwt.Token) (interface{}, error) userPaths []string adminPaths []string } @@ -37,7 +41,11 @@ func NewPermissionsHandler( } // Set up session manager - sessionManager := newSessionManager(hostDomain, timeout) + lookup := crypto.GetAPIPrivateKey + if len(keyLookup) > 0 { + lookup = keyLookup[0] + } + sessionManager := newSessionManager(hostDomain, timeout, lookup) // Set up permissions handler mux := http.NewServeMux() @@ -47,13 +55,8 @@ func NewPermissionsHandler( sessions: sessionManager, mux: mux, } - handler.keyLookup = GetAPIPrivateKey - if len(keyLookup) > 0 { - handler.keyLookup = keyLookup[0] - } - // The following endpoints are for user administration and session - // administration + // The following endpoints are for user administration and session administration userHandler := http.NewServeMux() userHandler.HandleFunc("/login", handler.loginHandler) userHandler.HandleFunc("/logout", handler.logoutHandler) @@ -84,17 +87,17 @@ func (h *PermissionsHandler) Close() error { // nolint: gocyclo func (h *PermissionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - // http.StripPrefix removes the leading slash, but in the interest - // of maintaining similar behaviour to stdlib handler functions, - // we manually add a leading "/" here instead of having users not add - // a leading "/" on the path if it dosn't already exist. + // http.StripPrefix removes the leading slash, but in the interest of + // maintaining similar behaviour to stdlib handler functions, we manually + // add a leading "/" here instead of having users not add a leading "/" on + // the path if it dosn't already exist. path := r.URL.Path if !strings.HasPrefix(path, "/") { path = "/" + path r.URL.Path = path } - // Check if this is restricted + // Check if this path is restricted adminRestricted := false for _, prefix := range h.adminPaths { if strings.HasPrefix(path, prefix) { @@ -108,62 +111,37 @@ func (h *PermissionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } - // Serve if path is public + // Serve directly if path is public if !userRestricted && !adminRestricted { h.mux.ServeHTTP(w, r) return } - // Check token in header - if no tokens, check cookie - bearerString := r.Header.Get("Authorization") - splitToken := strings.Split(bearerString, "Bearer ") - if len(splitToken) == 2 { - tokenString := splitToken[1] - - // Parse takes the token string and a function for looking up the key. - token, err := jwt.Parse(tokenString, h.keyLookup) - if err != nil { - http.Error(w, err.Error(), http.StatusForbidden) - return - } - - // Verify the claims (none for now) and token. - if _, ok := token.Claims.(jwt.MapClaims); !ok || !token.Valid { - http.Error(w, tokenInvalidErrorMsg, http.StatusForbidden) - return - } - - // @todo: manage admin-restricted endpoints - - // Serve the requested endpoint to token holders - h.mux.ServeHTTP(w, r) - return - } - - // Check if session is valid - s, err := h.sessions.GetSession(w, r) + // Check if token is valid + claims, err := h.sessions.GetSession(r) if err != nil { - if err == errSessionNotFound || err == errCookieNotFound { - http.Error(w, err.Error(), http.StatusForbidden) + if err == errSessionNotFound { + http.Error(w, err.Error(), http.StatusUnauthorized) } else { - http.Error(w, err.Error(), http.StatusInternalServerError) + http.Error(w, err.Error(), http.StatusForbidden) } return } // Check if user has sufficient permissions for path if adminRestricted { - admin, err := h.users.IsAdmin(s.Username) + admin, err := h.users.IsAdmin(claims.User) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) + return } if !admin { http.Error(w, "Admin privileges required", http.StatusForbidden) + return } - return } - // Serve the requested page if permissions were granted + // Serve the requested endpoint to token holders h.mux.ServeHTTP(w, r) } @@ -296,7 +274,7 @@ func (h *PermissionsHandler) loginHandler(w http.ResponseWriter, r *http.Request } // Log in user if password is correct - correct, err := h.users.IsCorrectCredentials(userReq.Username, userReq.Password) + props, correct, err := h.users.IsCorrectCredentials(userReq.Username, userReq.Password) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -305,18 +283,19 @@ func (h *PermissionsHandler) loginHandler(w http.ResponseWriter, r *http.Request http.Error(w, "Login failed", http.StatusForbidden) return } - err = h.sessions.BeginSession(userReq.Username, w, r) + _, token, err := h.sessions.BeginSession(userReq.Username, props.Admin) if err != nil { http.Error(w, "Login failed: "+err.Error(), http.StatusForbidden) return } + // Write back w.WriteHeader(http.StatusOK) - fmt.Fprintf(w, "[SUCCESS %d] User %s logged in\n", http.StatusOK, userReq.Username) + w.Write([]byte(token)) } func (h *PermissionsHandler) logoutHandler(w http.ResponseWriter, r *http.Request) { - err := h.sessions.EndSession(w, r) + err := h.sessions.EndSession(r) if err != nil { http.Error(w, "Logout failed: "+err.Error(), http.StatusInternalServerError) return diff --git a/daemon/inertiad/auth/permissions_test.go b/daemon/inertiad/auth/permissions_test.go index 6785a70a..54733a9a 100644 --- a/daemon/inertiad/auth/permissions_test.go +++ b/daemon/inertiad/auth/permissions_test.go @@ -4,15 +4,16 @@ import ( "bytes" "encoding/json" "fmt" + "io/ioutil" "net/http" "net/http/httptest" "os" "path" "testing" - "github.com/ubclaunchpad/inertia/common" - "github.com/stretchr/testify/assert" + "github.com/ubclaunchpad/inertia/common" + "github.com/ubclaunchpad/inertia/daemon/inertiad/crypto" ) func getTestPermissionsHandler(dir string) (*PermissionsHandler, error) { @@ -23,7 +24,7 @@ func getTestPermissionsHandler(dir string) (*PermissionsHandler, error) { return NewPermissionsHandler( path.Join(dir, "users.db"), "127.0.0.1", 3000, - getFakeAPIKey, + crypto.GetFakeAPIKey, ) } @@ -66,12 +67,19 @@ func TestServeHTTPWithUserReject(t *testing.T) { w.WriteHeader(http.StatusOK) })) + // Without token req, err := http.NewRequest("POST", ts.URL+"/test", nil) assert.Nil(t, err) resp, err := http.DefaultClient.Do(req) assert.Nil(t, err) defer resp.Body.Close() + assert.Equal(t, http.StatusForbidden, resp.StatusCode) + // With malformed token + req.Header.Set("Authorization", "Bearer badtoken") + resp, err = http.DefaultClient.Do(req) + assert.Nil(t, err) + defer resp.Body.Close() assert.Equal(t, http.StatusForbidden, resp.StatusCode) } @@ -94,7 +102,7 @@ func TestServeHTTPWithUserLoginAndLogout(t *testing.T) { err = ph.users.AddUser("bobheadxi", "wowgreat", false) assert.Nil(t, err) - // Login in as user, use cookiejar to catch cookie + // Login in as user user := &common.UserRequest{Username: "bobheadxi", Password: "wowgreat"} body, err := json.Marshal(user) assert.Nil(t, err) @@ -105,15 +113,15 @@ func TestServeHTTPWithUserLoginAndLogout(t *testing.T) { defer loginResp.Body.Close() assert.Equal(t, http.StatusOK, loginResp.StatusCode) - // Check for cookies - assert.True(t, len(loginResp.Cookies()) > 0) - cookie := loginResp.Cookies()[0] - assert.Equal(t, "ubclaunchpad-inertia", cookie.Name) + // Get token + tokenBytes, err := ioutil.ReadAll(loginResp.Body) + assert.Nil(t, err) + token := string(tokenBytes) // Attempt to validate req, err = http.NewRequest("POST", ts.URL+"/user/validate", nil) assert.Nil(t, err) - req.AddCookie(cookie) + req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) assert.Nil(t, err) defer resp.Body.Close() @@ -122,17 +130,20 @@ func TestServeHTTPWithUserLoginAndLogout(t *testing.T) { // Log out req, err = http.NewRequest("POST", ts.URL+"/user/logout", nil) assert.Nil(t, err) - req.AddCookie(cookie) + req.Header.Set("Authorization", "Bearer "+token) logoutResp, err := http.DefaultClient.Do(req) assert.Nil(t, err) defer logoutResp.Body.Close() assert.Equal(t, http.StatusOK, logoutResp.StatusCode) - // Check for cookies - assert.True(t, len(logoutResp.Cookies()) > 0) - cookie = logoutResp.Cookies()[0] - assert.Equal(t, "ubclaunchpad-inertia", cookie.Name) - assert.Equal(t, 0, cookie.MaxAge) + // Attempt to validate again + req, err = http.NewRequest("POST", ts.URL+"/user/validate", nil) + assert.Nil(t, err) + req.Header.Set("Authorization", "Bearer "+token) + resp, err = http.DefaultClient.Do(req) + assert.Nil(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) } func TestServeHTTPWithUserLoginAndAccept(t *testing.T) { @@ -154,7 +165,7 @@ func TestServeHTTPWithUserLoginAndAccept(t *testing.T) { err = ph.users.AddUser("bobheadxi", "wowgreat", false) assert.Nil(t, err) - // Login in as user, use cookiejar to catch cookie + // Login in as user user := &common.UserRequest{Username: "bobheadxi", Password: "wowgreat"} body, err := json.Marshal(user) assert.Nil(t, err) @@ -165,15 +176,15 @@ func TestServeHTTPWithUserLoginAndAccept(t *testing.T) { defer loginResp.Body.Close() assert.Equal(t, http.StatusOK, loginResp.StatusCode) - // Check for cookies - assert.True(t, len(loginResp.Cookies()) > 0) - cookie := loginResp.Cookies()[0] - assert.Equal(t, "ubclaunchpad-inertia", cookie.Name) + // Get token + tokenBytes, err := ioutil.ReadAll(loginResp.Body) + assert.Nil(t, err) + token := string(tokenBytes) // Attempt to access restricted endpoint with cookie req, err = http.NewRequest("POST", ts.URL+"/test", nil) assert.Nil(t, err) - req.AddCookie(cookie) + req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) assert.Nil(t, err) defer resp.Body.Close() @@ -200,7 +211,7 @@ func TestServeHTTPDenyNonAdmin(t *testing.T) { err = ph.users.AddUser("bobheadxi", "wowgreat", false) assert.Nil(t, err) - // Login in as user, use cookiejar to catch cookie + // Login in as user user := &common.UserRequest{Username: "bobheadxi", Password: "wowgreat"} body, err := json.Marshal(user) assert.Nil(t, err) @@ -211,15 +222,15 @@ func TestServeHTTPDenyNonAdmin(t *testing.T) { defer loginResp.Body.Close() assert.Equal(t, http.StatusOK, loginResp.StatusCode) - // Check for cookies - assert.True(t, len(loginResp.Cookies()) > 0) - cookie := loginResp.Cookies()[0] - assert.Equal(t, "ubclaunchpad-inertia", cookie.Name) + // Get token + tokenBytes, err := ioutil.ReadAll(loginResp.Body) + assert.Nil(t, err) + token := string(tokenBytes) // Attempt to access restricted endpoint with cookie req, err = http.NewRequest("POST", ts.URL+"/test", nil) assert.Nil(t, err) - req.AddCookie(cookie) + req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) assert.Nil(t, err) defer resp.Body.Close() @@ -246,7 +257,7 @@ func TestServeHTTPAllowAdmin(t *testing.T) { err = ph.users.AddUser("bobheadxi", "wowgreat", true) assert.Nil(t, err) - // Login in as user, use cookiejar to catch cookie + // Login in as user user := &common.UserRequest{Username: "bobheadxi", Password: "wowgreat"} body, err := json.Marshal(user) assert.Nil(t, err) @@ -257,15 +268,15 @@ func TestServeHTTPAllowAdmin(t *testing.T) { defer loginResp.Body.Close() assert.Equal(t, http.StatusOK, loginResp.StatusCode) - // Check for cookies - assert.True(t, len(loginResp.Cookies()) > 0) - cookie := loginResp.Cookies()[0] - assert.Equal(t, "ubclaunchpad-inertia", cookie.Name) + // Get token + tokenBytes, err := ioutil.ReadAll(loginResp.Body) + assert.Nil(t, err) + token := string(tokenBytes) // Attempt to access restricted endpoint with cookie req, err = http.NewRequest("POST", ts.URL+"/test", nil) assert.Nil(t, err) - req.AddCookie(cookie) + req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) assert.Nil(t, err) defer resp.Body.Close() @@ -285,9 +296,9 @@ func TestUserControlHandlers(t *testing.T) { defer ph.Close() ts.Config.Handler = ph - // Test handler uses the getFakeAPIToken keylookup, which - // will match with the testToken - bearerTokenString := fmt.Sprintf("Bearer %s", testToken) + // Test handler uses the getFakeAPIToken keylookup, which will match with + // the testToken + bearerTokenString := fmt.Sprintf("Bearer %s", crypto.TestMasterToken) // Add a new user body, err := json.Marshal(&common.UserRequest{ diff --git a/daemon/inertiad/auth/sessions.go b/daemon/inertiad/auth/sessions.go index 889782bd..4bd071b2 100644 --- a/daemon/inertiad/auth/sessions.go +++ b/daemon/inertiad/auth/sessions.go @@ -2,43 +2,46 @@ package auth import ( "errors" + "fmt" "net/http" - "net/url" + "strings" "sync" "time" + jwt "github.com/dgrijalva/jwt-go" "github.com/ubclaunchpad/inertia/common" + "github.com/ubclaunchpad/inertia/daemon/inertiad/crypto" ) -// session are properties associated with session, -// used for database entries -type session struct { - Username string `json:"username"` - Expires time.Time `json:"created"` -} - type sessionManager struct { - cookieName string - cookieDomain string - cookieTimeout time.Duration - internal map[string]*session + // sessionTimeout is the amount of time created Tokens are given to expire + sessionTimeout time.Duration + // internal is sessionManager's session store - it is protected by an RWMutex + internal map[string]*crypto.TokenClaims sync.RWMutex + + // keyLookup implements jwt.Keyfunc and retrieves the key used to sign and + // validate JWT tokens + keyLookup func(*jwt.Token) (interface{}, error) + + // endSessionCleanup ends the goroutine that continually cleans up expired + // essions from memory endSessionCleanup chan bool } -func newSessionManager(domain string, timeout int) *sessionManager { +func newSessionManager(domain string, timeout int, + keyLookup func(*jwt.Token) (interface{}, error)) *sessionManager { manager := &sessionManager{ - cookieName: "ubclaunchpad-inertia", - cookieDomain: domain, - cookieTimeout: time.Duration(timeout) * time.Minute, - internal: make(map[string]*session), + sessionTimeout: time.Duration(timeout) * time.Minute, + internal: make(map[string]*crypto.TokenClaims), + keyLookup: keyLookup, endSessionCleanup: make(chan bool), } // Set up session cleanup goroutine - ticker := time.NewTicker(manager.cookieTimeout) + ticker := time.NewTicker(manager.sessionTimeout) go func() { for { select { @@ -47,8 +50,8 @@ func newSessionManager(domain string, timeout int) *sessionManager { return case <-ticker.C: manager.Lock() - for id, session := range manager.internal { - if !manager.isValidSession(session) { + for id, c := range manager.internal { + if c.Valid() != nil { delete(manager.internal, id) } } @@ -64,117 +67,101 @@ func (s *sessionManager) Close() { s.endSessionCleanup <- true s.Lock() - s.internal = make(map[string]*session) + s.internal = make(map[string]*crypto.TokenClaims) s.Unlock() } -// SessionBegin starts a new session with user by setting a cookie -// and adding session to memory -func (s *sessionManager) BeginSession(username string, w http.ResponseWriter, r *http.Request) error { - expiration := time.Now().Add(s.cookieTimeout) +// SessionBegin starts a new session with user by generating a token and adding +// session to memory +func (s *sessionManager) BeginSession(username string, admin bool) (*crypto.TokenClaims, string, error) { + expiration := time.Now().Add(s.sessionTimeout) id, err := common.GenerateRandomString() if err != nil { - return errors.New("Failed to begin session for " + username + ": " + err.Error()) + return nil, "", fmt.Errorf("Faield to begin sesison for %s: %s", username, err.Error()) } - // Add session to map - s.Lock() - s.internal[id] = &session{ - Username: username, - Expires: expiration, + claims := &crypto.TokenClaims{ + SessionID: id, User: username, Admin: admin, Expiry: expiration, } - s.Unlock() - // Add cookie with session ID - http.SetCookie(w, &http.Cookie{ - Name: s.cookieName, - Value: url.QueryEscape(id), - Domain: s.cookieDomain, - Path: "/", - HttpOnly: true, - Expires: expiration, - }) - return nil -} - -// SessionEnd ends a session and sets cookie to expire -func (s *sessionManager) EndSession(w http.ResponseWriter, r *http.Request) error { - cookie, err := r.Cookie(s.cookieName) + // Sign a token for user + keyBytes, err := s.keyLookup(nil) if err != nil { - return errors.New("Invalid cookie: " + err.Error()) + return nil, "", err } - if cookie.Value == "" { - return errors.New("Invalid cookie") - } - id, err := url.QueryUnescape(cookie.Value) + token, err := claims.GenerateToken(keyBytes.([]byte)) if err != nil { - return errors.New("Invalid cookie: " + err.Error()) + return nil, "", err } - // Delete session from map + // Add session to map s.Lock() - delete(s.internal, id) + s.internal[id] = claims s.Unlock() + return claims, token, nil +} - // Set cookie to expire immediately - http.SetCookie(w, &http.Cookie{ - Name: s.cookieName, - Value: "", - Domain: s.cookieDomain, - Path: "/", - HttpOnly: true, - Expires: time.Unix(0, 0), - }) +// SessionEnd ends a session by invalidating the token +func (s *sessionManager) EndSession(r *http.Request) error { + claims, err := s.GetSession(r) + if err != nil { + return err + } + + // Delete session from map + s.deleteSession(claims.SessionID) return nil } // GetSession verifies if given request is from a valid session and returns it -func (s *sessionManager) GetSession(w http.ResponseWriter, r *http.Request) (*session, error) { - cookie, err := r.Cookie(s.cookieName) - if err != nil || cookie.Value == "" { - return nil, errCookieNotFound +func (s *sessionManager) GetSession(r *http.Request) (*crypto.TokenClaims, error) { + // Check token in header - if no tokens, check cookie + bearerString := r.Header.Get("Authorization") + splitToken := strings.Split(bearerString, "Bearer ") + if len(splitToken) != 2 { + return nil, errors.New(errMalformedHeaderMsg) } - id, err := url.QueryUnescape(cookie.Value) + + // Validate token and get claims + claims, err := crypto.ValidateToken(splitToken[1], s.keyLookup) if err != nil { return nil, err } - s.RLock() - session, found := s.internal[id] - if !found { - s.RUnlock() - s.EndSession(w, r) - return nil, errSessionNotFound + // Master tokens aren't session-tracked. TODO: reassess security of this + if claims.IsMaster() { + return claims, nil } - if !s.isValidSession(session) { + + s.RLock() + _, found := s.internal[claims.SessionID] + if !found || claims.Valid() != nil { s.RUnlock() - s.EndSession(w, r) + s.deleteSession(claims.SessionID) return nil, errSessionNotFound } s.RUnlock() - - return session, nil + return claims, nil } // endAllUserSessions removes all active sessions with given user func (s *sessionManager) EndAllUserSessions(username string) { - s.Lock() - for id, session := range s.internal { - if session.Username == username { - delete(s.internal, id) + for id, claim := range s.internal { + if claim.User == username { + s.deleteSession(id) } } - s.Unlock() } // EndAllSessions removes all active sessions func (s *sessionManager) EndAllSessions() { s.Lock() - s.internal = make(map[string]*session) + s.internal = make(map[string]*crypto.TokenClaims) s.Unlock() } -// isValidSession checks if session is expired -func (s *sessionManager) isValidSession(session *session) bool { - return session.Expires.After(time.Now()) +func (s *sessionManager) deleteSession(sessionID string) { + s.Lock() + delete(s.internal, sessionID) + s.Unlock() } diff --git a/daemon/inertiad/auth/sessions_test.go b/daemon/inertiad/auth/sessions_test.go new file mode 100644 index 00000000..a7c2d48c --- /dev/null +++ b/daemon/inertiad/auth/sessions_test.go @@ -0,0 +1,32 @@ +package auth + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/ubclaunchpad/inertia/daemon/inertiad/crypto" +) + +func Test_sessionManager_EndAllSessions(t *testing.T) { + // make two sessions because map is pointer + sessions := map[string]*crypto.TokenClaims{ + "1234": &crypto.TokenClaims{User: "bob"}, + } + manager := &sessionManager{internal: map[string]*crypto.TokenClaims{ + "1234": &crypto.TokenClaims{User: "bob"}, + }} + manager.EndAllSessions() + assert.False(t, assert.ObjectsAreEqualValues(sessions, manager.internal)) +} + +func Test_sessionManager_EndAllUserSessions(t *testing.T) { + // make two sessions because map is pointer + sessions := map[string]*crypto.TokenClaims{ + "1234": &crypto.TokenClaims{User: "bob"}, + } + manager := &sessionManager{internal: map[string]*crypto.TokenClaims{ + "1234": &crypto.TokenClaims{User: "bob"}, + }} + manager.EndAllUserSessions("bob") + assert.False(t, assert.ObjectsAreEqualValues(sessions, manager.internal)) +} diff --git a/daemon/inertiad/auth/users.go b/daemon/inertiad/auth/users.go index 8e7f51f5..8f4a636a 100644 --- a/daemon/inertiad/auth/users.go +++ b/daemon/inertiad/auth/users.go @@ -10,7 +10,6 @@ import ( var ( errSessionNotFound = errors.New("Session not found") - errCookieNotFound = errors.New("Cookie not found") errUserNotFound = errors.New("User not found") ) @@ -28,8 +27,8 @@ type userProps struct { // userManager administers sessions and user accounts type userManager struct { - // db is a boltdb database, which is an embedded - // key/value database where each "bucket" is a collection + // db is a boltdb database, which is an embedded key/value database where + // each "bucket" is a collection db *bolt.DB usersBucket []byte } @@ -45,9 +44,21 @@ func newUserManager(dbPath string) (*userManager, error) { return nil, err } err = db.Update(func(tx *bolt.Tx) error { - _, err = tx.CreateBucketIfNotExists(manager.usersBucket) - return err + users, err := tx.CreateBucketIfNotExists(manager.usersBucket) + if err != nil { + return err + } + // Add a master user - the password to this guy/gal will just be the + // GitHub key. It's not really meant for use. + bytes, err := json.Marshal(&userProps{Admin: true}) + if err != nil { + return err + } + return users.Put([]byte("master"), bytes) }) + if err != nil { + return nil, err + } manager.db = db return manager, nil @@ -134,18 +145,21 @@ func (m *userManager) HasUser(username string) error { // IsCorrectCredentials checks if username and password has a match // in the database -func (m *userManager) IsCorrectCredentials(username, password string) (bool, error) { - correct := false - userbytes := []byte(username) - var userErr error +func (m *userManager) IsCorrectCredentials(username, password string) (*userProps, bool, error) { + var ( + userbytes = []byte(username) + userProps = &userProps{} + userErr error + correct bool + ) + transactionErr := m.db.Update(func(tx *bolt.Tx) error { users := tx.Bucket(m.usersBucket) propsBytes := users.Get(userbytes) if propsBytes == nil { return errUserNotFound } - props := &userProps{} - err := json.Unmarshal(propsBytes, props) + err := json.Unmarshal(propsBytes, userProps) if err != nil { return errors.New("Corrupt user properties: " + err.Error()) } @@ -156,28 +170,28 @@ func (m *userManager) IsCorrectCredentials(username, password string) (bool, err return err } - correct = crypto.CorrectPassword(props.HashedPassword, password) + correct = crypto.CorrectPassword(userProps.HashedPassword, password) if !correct { // Track number of login attempts and don't add // user back to the database if past limit - props.LoginAttempts++ - if props.LoginAttempts <= loginAttemptsLimit { - bytes, err := json.Marshal(props) + userProps.LoginAttempts++ + if userProps.LoginAttempts <= loginAttemptsLimit { + bytes, err := json.Marshal(userProps) if err != nil { return err } return users.Put(userbytes, bytes) } - // Rollback will occur if transaction returns and - // error, so store in variable + // Rollback will occur if transaction returns and error, so store + // in variable. TODO: don't delete? userErr = errors.New("Too many login attempts - user deleted") return nil } // Reset attempts to 0 if login successful - props.LoginAttempts = 0 - bytes, err := json.Marshal(props) + userProps.LoginAttempts = 0 + bytes, err := json.Marshal(userProps) if err != nil { return err } @@ -185,13 +199,14 @@ func (m *userManager) IsCorrectCredentials(username, password string) (bool, err }) if userErr != nil { - return correct, userErr + return userProps, correct, userErr } - return correct, transactionErr + return userProps, correct, transactionErr } // IsAdmin checks if given user is has administrator priviledges func (m *userManager) IsAdmin(username string) (bool, error) { + // Check if user is admin in database admin := false err := m.db.View(func(tx *bolt.Tx) error { users := tx.Bucket(m.usersBucket) diff --git a/daemon/inertiad/auth/users_test.go b/daemon/inertiad/auth/users_test.go index fb844aed..03a37bdc 100644 --- a/daemon/inertiad/auth/users_test.go +++ b/daemon/inertiad/auth/users_test.go @@ -26,11 +26,11 @@ func TestAddUserAndIsCorrectCredentials(t *testing.T) { err = manager.AddUser("bobheadxi", "best_person_ever", true) assert.Nil(t, err) - correct, err := manager.IsCorrectCredentials("bobheadxi", "not_quite_best") + _, correct, err := manager.IsCorrectCredentials("bobheadxi", "not_quite_best") assert.Nil(t, err) assert.False(t, correct) - correct, err = manager.IsCorrectCredentials("bobheadxi", "best_person_ever") + _, correct, err = manager.IsCorrectCredentials("bobheadxi", "best_person_ever") assert.Nil(t, err) assert.True(t, correct) } @@ -49,7 +49,7 @@ func TestAllUserManagementOperations(t *testing.T) { assert.Nil(t, err) users := manager.UserList() - assert.Equal(t, len(users), 2) + assert.Equal(t, len(users), 3) // There is a master user in here too err = manager.HasUser("bobheadxi") assert.Nil(t, err) @@ -112,12 +112,12 @@ func TestTooManyLogins(t *testing.T) { assert.Nil(t, err) for i := 0; i < loginAttemptsLimit; i++ { - correct, err := manager.IsCorrectCredentials("bobheadxi", "not_quite_best") + _, correct, err := manager.IsCorrectCredentials("bobheadxi", "not_quite_best") assert.Nil(t, err) assert.False(t, correct) } - correct, err := manager.IsCorrectCredentials("bobheadxi", "not_quite_best") + _, correct, err := manager.IsCorrectCredentials("bobheadxi", "not_quite_best") assert.False(t, correct) assert.NotNil(t, err) assert.Contains(t, err.Error(), "login attempts") diff --git a/daemon/inertiad/cmd.go b/daemon/inertiad/cmd.go index 1038b8c8..ebdf7636 100644 --- a/daemon/inertiad/cmd.go +++ b/daemon/inertiad/cmd.go @@ -4,9 +4,8 @@ import ( "fmt" "os" - "github.com/ubclaunchpad/inertia/daemon/inertiad/auth" - "github.com/spf13/cobra" + "github.com/ubclaunchpad/inertia/daemon/inertiad/crypto" ) // Version is the current build of Inertia @@ -39,12 +38,12 @@ var tokenCmd = &cobra.Command{ Long: `Produce an API token to use with the daemon, Created using an RSA private key.`, Run: func(cmd *cobra.Command, args []string) { - keyBytes, err := auth.GetAPIPrivateKey(nil) + keyBytes, err := crypto.GetAPIPrivateKey(nil) if err != nil { panic(err) } - token, err := auth.GenerateToken(keyBytes.([]byte)) + token, err := crypto.GenerateMasterToken(keyBytes.([]byte)) if err != nil { panic(err) } diff --git a/daemon/inertiad/crypto/authtest.go b/daemon/inertiad/crypto/authtest.go new file mode 100644 index 00000000..0f0c29e2 --- /dev/null +++ b/daemon/inertiad/crypto/authtest.go @@ -0,0 +1,28 @@ +package crypto + +import ( + "os" + "path" + + jwt "github.com/dgrijalva/jwt-go" +) + +// This file contains test assets + +var ( + // TestPrivateKey is an example key for testing purposes + TestPrivateKey = []byte("very_sekrit_key") + + // TestMasterToken is an example token for testing purposes. This is + // generated by TestGenerateMasterToken, and is a master token. + TestMasterToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzZXNzaW9uX2lkIjoiIiwidXNlciI6Im1hc3RlciIsImFkbWluIjp0cnVlLCJleHBpcnkiOiIwMDAxLTAxLTAxVDAwOjAwOjAwWiJ9.tX9mIJOQ-S-_nJL6kjK8hAe5x5z6SpmDewDh5kUHOlk" + + // TestInertiaKeyPath the path to Inertia's test RSA key + TestInertiaKeyPath = path.Join(os.Getenv("GOPATH"), "/src/github.com/ubclaunchpad/inertia/test/keys/id_rsa") +) + +// GetFakeAPIKey is a helper function that implements jwt.keyFunc and returns +// the test private key +func GetFakeAPIKey(tok *jwt.Token) (interface{}, error) { + return TestPrivateKey, nil +} diff --git a/daemon/inertiad/auth/auth.go b/daemon/inertiad/crypto/keys.go similarity index 78% rename from daemon/inertiad/auth/auth.go rename to daemon/inertiad/crypto/keys.go index 1e9c4616..1391008b 100644 --- a/daemon/inertiad/auth/auth.go +++ b/daemon/inertiad/crypto/keys.go @@ -1,4 +1,4 @@ -package auth +package crypto import ( "io" @@ -14,10 +14,6 @@ var ( DaemonGithubKeyLocation = os.Getenv("INERTIA_GH_KEY_PATH") //"/app/host/.ssh/id_rsa_inertia_deploy" ) -const ( - tokenInvalidErrorMsg = "Token invalid" -) - // GetAPIPrivateKey returns the private RSA key to authenticate HTTP // requests sent to the daemon. For now, we simply use the GitHub // deploy key. Retrieves from default DaemonGithubKeyLocation. @@ -46,10 +42,3 @@ func GetGithubKey(pemFile io.Reader) (ssh.AuthMethod, error) { } return ssh.NewPublicKeys("git", bytes, "") } - -// GenerateToken creates a JSON Web Token (JWT) for a client to use when -// sending HTTP requests to the daemon server. -func GenerateToken(key []byte) (string, error) { - // No claims for now. - return jwt.New(jwt.SigningMethodHS256).SignedString(key) -} diff --git a/daemon/inertiad/crypto/keys_test.go b/daemon/inertiad/crypto/keys_test.go new file mode 100644 index 00000000..bd7eebad --- /dev/null +++ b/daemon/inertiad/crypto/keys_test.go @@ -0,0 +1,21 @@ +package crypto + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetAPIPrivateKey(t *testing.T) { + key, err := getAPIPrivateKeyFromPath(nil, TestInertiaKeyPath) + assert.Nil(t, err) + assert.Contains(t, string(key.([]byte)), "user: git, name: ssh-public-keys") +} + +func TestGetGithubKey(t *testing.T) { + pemFile, err := os.Open(TestInertiaKeyPath) + assert.Nil(t, err) + _, err = GetGithubKey(pemFile) + assert.Nil(t, err) +} diff --git a/daemon/inertiad/crypto/token.go b/daemon/inertiad/crypto/token.go new file mode 100644 index 00000000..f2ff112e --- /dev/null +++ b/daemon/inertiad/crypto/token.go @@ -0,0 +1,81 @@ +package crypto + +import ( + "errors" + "time" + + jwt "github.com/dgrijalva/jwt-go" +) + +const ( + // TokenInvalidErrorMsg says that the token is invalid + TokenInvalidErrorMsg = "token invalid" + + // TokenExpiredErrorMsg says that the token is expired + TokenExpiredErrorMsg = "token expired" +) + +// TokenClaims represents a JWT token's claims +type TokenClaims struct { + SessionID string `json:"session_id"` + User string `json:"user"` + Admin bool `json:"admin"` + Expiry time.Time `json:"expiry"` +} + +// Valid checks if token is authentic +func (t *TokenClaims) Valid() error { + if t.IsMaster() { + return nil + } + + if !t.Expiry.After(time.Now()) { + return errors.New(TokenExpiredErrorMsg) + } + return nil +} + +// IsMaster returns true if this is a mster key +func (t *TokenClaims) IsMaster() bool { + return (t.User == "master" && t.Expiry == time.Time{}) +} + +// GenerateToken creates a JWT token from this claim, signed with given key +func (t *TokenClaims) GenerateToken(key []byte) (string, error) { + return jwt. + NewWithClaims(jwt.SigningMethodHS256, t). + SignedString(key) +} + +// ValidateToken ensures token is valid and returns its metadata +func ValidateToken(tokenString string, lookup jwt.Keyfunc) (*TokenClaims, error) { + // Parse takes the token string and a function for looking up the key. + token, err := jwt.ParseWithClaims(tokenString, &TokenClaims{}, lookup) + if err != nil { + return nil, err + } + + // Verify signing algorithm and token + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok || !token.Valid { + return nil, errors.New(TokenInvalidErrorMsg) + } + + // Verify the claims and token. + if claim, ok := token.Claims.(*TokenClaims); ok { + return claim, nil + } + return nil, errors.New(TokenInvalidErrorMsg) +} + +// GenerateMasterToken creates a "master" JSON Web Token (JWT) for a client to use +// when sending HTTP requests to the daemon server. +func GenerateMasterToken(key []byte) (string, error) { + return jwt. + NewWithClaims(jwt.SigningMethodHS256, &TokenClaims{ + User: "master", + Admin: true, + // For the time being, never allow this token to expire, so don't + // set an expiry. + }). + SignedString(key) +} diff --git a/daemon/inertiad/crypto/token_test.go b/daemon/inertiad/crypto/token_test.go new file mode 100644 index 00000000..6a7eefd5 --- /dev/null +++ b/daemon/inertiad/crypto/token_test.go @@ -0,0 +1,69 @@ +package crypto + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestGenerateMasterToken(t *testing.T) { + token, err := GenerateMasterToken(TestPrivateKey) + assert.Nil(t, err) + assert.Equal(t, TestMasterToken, token) + + otherToken, err := GenerateMasterToken([]byte("another_sekrit_key")) + assert.Nil(t, err) + assert.NotEqual(t, token, otherToken) + + // Verify validity + readClaims, err := ValidateToken(token, GetFakeAPIKey) + assert.Nil(t, err) + assert.Nil(t, readClaims.Valid()) +} + +func TestTokenClaims_Valid(t *testing.T) { + type fields struct { + SessionID string + User string + Admin bool + Expiry time.Time + } + tests := []struct { + name string + fields fields + wantErr bool + }{ + // master key + {"success", fields{"1234", "master", true, time.Time{}}, false}, + // expiry in future (+1) + {"success", fields{"1234", "bob", true, time.Now().AddDate(0, 1, 0)}, false}, + // expiry in past (-1) + {"fail", fields{"1234", "bob", true, time.Now().AddDate(0, -1, 0)}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + claims := &TokenClaims{ + SessionID: tt.fields.SessionID, + User: tt.fields.User, + Admin: tt.fields.Admin, + Expiry: tt.fields.Expiry, + } + if err := claims.Valid(); (err != nil) != tt.wantErr { + t.Errorf("TokenClaims.Valid() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestTokenClaims_GenerateToken(t *testing.T) { + expires := time.Now().AddDate(0, 1, 0) + claims := &TokenClaims{"1234", "robert", true, expires} + token, err := claims.GenerateToken(TestPrivateKey) + assert.Nil(t, err) + + // Try decoding token + readClaims, err := ValidateToken(token, GetFakeAPIKey) + assert.Nil(t, err) + assert.Equal(t, claims.User, readClaims.User) +} diff --git a/daemon/inertiad/git/error.go b/daemon/inertiad/git/error.go index 78b22a34..e5ec2326 100644 --- a/daemon/inertiad/git/error.go +++ b/daemon/inertiad/git/error.go @@ -4,12 +4,12 @@ import ( "errors" "io/ioutil" - "github.com/ubclaunchpad/inertia/daemon/inertiad/auth" + "github.com/ubclaunchpad/inertia/daemon/inertiad/crypto" ) // AuthFailedErr attaches the daemon key in the error message func AuthFailedErr(path ...string) error { - keyLoc := auth.DaemonGithubKeyLocation + keyLoc := crypto.DaemonGithubKeyLocation if len(path) > 0 { keyLoc = path[0] } diff --git a/daemon/inertiad/project/deployment.go b/daemon/inertiad/project/deployment.go index f8918d5d..af4ac7d6 100644 --- a/daemon/inertiad/project/deployment.go +++ b/daemon/inertiad/project/deployment.go @@ -10,9 +10,9 @@ import ( docker "github.com/docker/docker/client" "github.com/ubclaunchpad/inertia/common" - "github.com/ubclaunchpad/inertia/daemon/inertiad/auth" "github.com/ubclaunchpad/inertia/daemon/inertiad/build" "github.com/ubclaunchpad/inertia/daemon/inertiad/containers" + "github.com/ubclaunchpad/inertia/daemon/inertiad/crypto" "github.com/ubclaunchpad/inertia/daemon/inertiad/git" gogit "gopkg.in/src-d/go-git.v4" "gopkg.in/src-d/go-git.v4/plumbing/transport/ssh" @@ -79,7 +79,7 @@ func NewDeployment(builder Builder, cfg DeploymentConfig, out io.Writer) (*Deplo if err != nil { return nil, err } - authMethod, err := auth.GetGithubKey(pemFile) + authMethod, err := crypto.GetGithubKey(pemFile) if err != nil { return nil, err } diff --git a/daemon/inertiad/up.go b/daemon/inertiad/up.go index 6eb54f36..8e7a4262 100644 --- a/daemon/inertiad/up.go +++ b/daemon/inertiad/up.go @@ -9,8 +9,8 @@ import ( docker "github.com/docker/docker/client" "github.com/ubclaunchpad/inertia/common" - "github.com/ubclaunchpad/inertia/daemon/inertiad/auth" "github.com/ubclaunchpad/inertia/daemon/inertiad/build" + "github.com/ubclaunchpad/inertia/daemon/inertiad/crypto" "github.com/ubclaunchpad/inertia/daemon/inertiad/log" "github.com/ubclaunchpad/inertia/daemon/inertiad/project" ) @@ -52,7 +52,7 @@ func upHandler(w http.ResponseWriter, r *http.Request) { BuildFilePath: upReq.BuildFilePath, RemoteURL: gitOpts.RemoteURL, Branch: gitOpts.Branch, - PemFilePath: auth.DaemonGithubKeyLocation, + PemFilePath: crypto.DaemonGithubKeyLocation, DatabasePath: path.Join(conf.DataDirectory, "project.db"), }, logger) if err != nil {