From f35ee20a37505e08b1d65f2b02ccc91694ccf819 Mon Sep 17 00:00:00 2001 From: Robert Lin Date: Thu, 28 Jun 2018 19:32:22 -0700 Subject: [PATCH 01/19] Refactor auth/auth.go in package crypto --- daemon/inertiad/auth/auth_test.go | 44 ------------------------ daemon/inertiad/auth/permissions.go | 7 +++- daemon/inertiad/auth/permissions_test.go | 12 +++++-- daemon/inertiad/{auth => crypto}/auth.go | 6 +--- daemon/inertiad/crypto/auth_test.go | 31 +++++++++++++++++ daemon/inertiad/crypto/authtest.go | 19 ++++++++++ 6 files changed, 66 insertions(+), 53 deletions(-) delete mode 100644 daemon/inertiad/auth/auth_test.go rename daemon/inertiad/{auth => crypto}/auth.go (95%) create mode 100644 daemon/inertiad/crypto/auth_test.go create mode 100644 daemon/inertiad/crypto/authtest.go 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..22340384 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 ( + tokenInvalidErrorMsg = "Token invalid" ) // PermissionsHandler handles users, permissions, and sessions on top @@ -47,7 +52,7 @@ func NewPermissionsHandler( sessions: sessionManager, mux: mux, } - handler.keyLookup = GetAPIPrivateKey + handler.keyLookup = crypto.GetAPIPrivateKey if len(keyLookup) > 0 { handler.keyLookup = keyLookup[0] } diff --git a/daemon/inertiad/auth/permissions_test.go b/daemon/inertiad/auth/permissions_test.go index 6785a70a..d3a3696e 100644 --- a/daemon/inertiad/auth/permissions_test.go +++ b/daemon/inertiad/auth/permissions_test.go @@ -10,11 +10,17 @@ import ( "path" "testing" - "github.com/ubclaunchpad/inertia/common" - + jwt "github.com/dgrijalva/jwt-go" "github.com/stretchr/testify/assert" + "github.com/ubclaunchpad/inertia/common" + "github.com/ubclaunchpad/inertia/daemon/inertiad/crypto" ) +// Helper function that implements jwt.keyFunc +func getFakeAPIKey(tok *jwt.Token) (interface{}, error) { + return crypto.TestPrivateKey, nil +} + func getTestPermissionsHandler(dir string) (*PermissionsHandler, error) { err := os.Mkdir(dir, os.ModePerm) if err != nil { @@ -287,7 +293,7 @@ func TestUserControlHandlers(t *testing.T) { // Test handler uses the getFakeAPIToken keylookup, which // will match with the testToken - bearerTokenString := fmt.Sprintf("Bearer %s", testToken) + bearerTokenString := fmt.Sprintf("Bearer %s", crypto.TestToken) // Add a new user body, err := json.Marshal(&common.UserRequest{ diff --git a/daemon/inertiad/auth/auth.go b/daemon/inertiad/crypto/auth.go similarity index 95% rename from daemon/inertiad/auth/auth.go rename to daemon/inertiad/crypto/auth.go index 1e9c4616..4c8f025a 100644 --- a/daemon/inertiad/auth/auth.go +++ b/daemon/inertiad/crypto/auth.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. diff --git a/daemon/inertiad/crypto/auth_test.go b/daemon/inertiad/crypto/auth_test.go new file mode 100644 index 00000000..397a7480 --- /dev/null +++ b/daemon/inertiad/crypto/auth_test.go @@ -0,0 +1,31 @@ +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) +} + +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/crypto/authtest.go b/daemon/inertiad/crypto/authtest.go new file mode 100644 index 00000000..1c4a9487 --- /dev/null +++ b/daemon/inertiad/crypto/authtest.go @@ -0,0 +1,19 @@ +package crypto + +import ( + "os" + "path" +) + +// This file contains test assets + +var ( + // TestPrivateKey is an example key for testing purposes + TestPrivateKey = []byte("very_sekrit_key") + + // TestToken is an example token for testing purposes + TestToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.AqFWnFeY9B8jj7-l3z0a9iaZdwIca7xhUF3fuaJjU90" + + // TestInertiaKeyPath the path to Inertia's test RSA key + TestInertiaKeyPath = path.Join(os.Getenv("GOPATH"), "/src/github.com/ubclaunchpad/inertia/test/keys/id_rsa") +) From 8b86351f1916c48a91c8c938534b4fba09144d61 Mon Sep 17 00:00:00 2001 From: Robert Lin Date: Sat, 30 Jun 2018 02:27:56 -0700 Subject: [PATCH 02/19] Revamp permissions and session control to use JWT tokens Added some temporary workarounds for client tokens not having claims at the moment. --- daemon/inertiad/auth/permissions.go | 80 ++++------- daemon/inertiad/auth/permissions_test.go | 83 ++++++------ daemon/inertiad/auth/sessions.go | 165 +++++++++++------------ daemon/inertiad/auth/users.go | 50 ++++--- daemon/inertiad/auth/users_test.go | 8 +- daemon/inertiad/cmd.go | 7 +- daemon/inertiad/crypto/auth.go | 7 - daemon/inertiad/crypto/auth_test.go | 10 -- daemon/inertiad/crypto/authtest.go | 8 ++ daemon/inertiad/crypto/token.go | 69 ++++++++++ daemon/inertiad/crypto/token_test.go | 62 +++++++++ 11 files changed, 325 insertions(+), 224 deletions(-) create mode 100644 daemon/inertiad/crypto/token.go create mode 100644 daemon/inertiad/crypto/token_test.go diff --git a/daemon/inertiad/auth/permissions.go b/daemon/inertiad/auth/permissions.go index 22340384..2c1f497e 100644 --- a/daemon/inertiad/auth/permissions.go +++ b/daemon/inertiad/auth/permissions.go @@ -13,7 +13,7 @@ import ( ) const ( - tokenInvalidErrorMsg = "Token invalid" + errMalformedHeaderMsg = "malformed authorization error" ) // PermissionsHandler handles users, permissions, and sessions on top @@ -23,7 +23,6 @@ type PermissionsHandler struct { users *userManager sessions *sessionManager mux *http.ServeMux - keyLookup func(*jwt.Token) (interface{}, error) userPaths []string adminPaths []string } @@ -42,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() @@ -52,13 +55,8 @@ func NewPermissionsHandler( sessions: sessionManager, mux: mux, } - handler.keyLookup = crypto.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) @@ -89,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) { @@ -113,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) } @@ -301,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 @@ -310,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 d3a3696e..26b8a85b 100644 --- a/daemon/inertiad/auth/permissions_test.go +++ b/daemon/inertiad/auth/permissions_test.go @@ -4,23 +4,18 @@ import ( "bytes" "encoding/json" "fmt" + "io/ioutil" "net/http" "net/http/httptest" "os" "path" "testing" - jwt "github.com/dgrijalva/jwt-go" "github.com/stretchr/testify/assert" "github.com/ubclaunchpad/inertia/common" "github.com/ubclaunchpad/inertia/daemon/inertiad/crypto" ) -// Helper function that implements jwt.keyFunc -func getFakeAPIKey(tok *jwt.Token) (interface{}, error) { - return crypto.TestPrivateKey, nil -} - func getTestPermissionsHandler(dir string) (*PermissionsHandler, error) { err := os.Mkdir(dir, os.ModePerm) if err != nil { @@ -29,7 +24,7 @@ func getTestPermissionsHandler(dir string) (*PermissionsHandler, error) { return NewPermissionsHandler( path.Join(dir, "users.db"), "127.0.0.1", 3000, - getFakeAPIKey, + crypto.GetFakeAPIKey, ) } @@ -72,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) } @@ -100,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) @@ -111,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() @@ -128,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) { @@ -160,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) @@ -171,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() @@ -206,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) @@ -217,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() @@ -252,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) @@ -263,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() @@ -291,8 +296,8 @@ func TestUserControlHandlers(t *testing.T) { defer ph.Close() ts.Config.Handler = ph - // Test handler uses the getFakeAPIToken keylookup, which - // will match with the testToken + // Test handler uses the getFakeAPIToken keylookup, which will match with + // the testToken bearerTokenString := fmt.Sprintf("Bearer %s", crypto.TestToken) // Add a new user diff --git a/daemon/inertiad/auth/sessions.go b/daemon/inertiad/auth/sessions.go index 889782bd..9d7ef923 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,103 @@ 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 + // TODO: this is a workaround for client tokens having no claims and no + // sessions + if claims.User == "" && claims.SessionID == "" { + claims.User = "sudo" + 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/users.go b/daemon/inertiad/auth/users.go index 8e7f51f5..105670f6 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 } @@ -48,6 +47,9 @@ func newUserManager(dbPath string) (*userManager, error) { _, err = tx.CreateBucketIfNotExists(manager.usersBucket) return err }) + if err != nil { + return nil, err + } manager.db = db return manager, nil @@ -134,18 +136,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 +161,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 +190,20 @@ 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) { + // TODO: this is a workaround for client tokens having no claims and no + // sessions + if username == "sudo" { + return true, nil + } + + // 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..e399784d 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) } @@ -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..08e705fc 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.GenerateToken(keyBytes.([]byte)) if err != nil { panic(err) } diff --git a/daemon/inertiad/crypto/auth.go b/daemon/inertiad/crypto/auth.go index 4c8f025a..1391008b 100644 --- a/daemon/inertiad/crypto/auth.go +++ b/daemon/inertiad/crypto/auth.go @@ -42,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/auth_test.go b/daemon/inertiad/crypto/auth_test.go index 397a7480..bd7eebad 100644 --- a/daemon/inertiad/crypto/auth_test.go +++ b/daemon/inertiad/crypto/auth_test.go @@ -19,13 +19,3 @@ func TestGetGithubKey(t *testing.T) { _, 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/crypto/authtest.go b/daemon/inertiad/crypto/authtest.go index 1c4a9487..db360a39 100644 --- a/daemon/inertiad/crypto/authtest.go +++ b/daemon/inertiad/crypto/authtest.go @@ -3,6 +3,8 @@ package crypto import ( "os" "path" + + jwt "github.com/dgrijalva/jwt-go" ) // This file contains test assets @@ -17,3 +19,9 @@ var ( // 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/crypto/token.go b/daemon/inertiad/crypto/token.go new file mode 100644 index 00000000..73106681 --- /dev/null +++ b/daemon/inertiad/crypto/token.go @@ -0,0 +1,69 @@ +package crypto + +import ( + "errors" + "time" + + jwt "github.com/dgrijalva/jwt-go" +) + +const ( + TokenInvalidErrorMsg = "token invalid" + 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 { + // TODO: This is a workaround for client tokens, which currently do not + // have claims. Need to move those onto this system. + if t.SessionID == "" && t.User == "" { + return nil + } + + if !t.Expiry.After(time.Now()) { + return errors.New(TokenExpiredErrorMsg) + } + return nil +} + +// 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) +} + +// 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/token_test.go b/daemon/inertiad/crypto/token_test.go new file mode 100644 index 00000000..024eb751 --- /dev/null +++ b/daemon/inertiad/crypto/token_test.go @@ -0,0 +1,62 @@ +package crypto + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +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) +} + +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 + }{ + // 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 TestTokenCliams_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.True(t, assert.ObjectsAreEqualValues(claims, readClaims)) +} From b5dbd31ce27a82a203d3ba398d7531bc7a5d5988 Mon Sep 17 00:00:00 2001 From: Robert Lin Date: Sat, 30 Jun 2018 02:36:44 -0700 Subject: [PATCH 03/19] Fix references to migrated auth functions --- daemon/inertiad/git/error.go | 4 ++-- daemon/inertiad/project/deployment.go | 4 ++-- daemon/inertiad/up.go | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) 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 { From 5b3702a3b82420d3c3973047d5b3a55b8679296e Mon Sep 17 00:00:00 2001 From: Robert Lin Date: Sat, 30 Jun 2018 10:22:46 -0700 Subject: [PATCH 04/19] Increase gometalinter timeout --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 31c3e96321b4ffb782550a9d9783279b2b2cdcf2 Mon Sep 17 00:00:00 2001 From: Robert Lin Date: Sat, 30 Jun 2018 10:56:30 -0700 Subject: [PATCH 05/19] Update dependencies --- Gopkg.lock | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) 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 From e5c1916e18755a9c4f07f8d76180c1057c62614f Mon Sep 17 00:00:00 2001 From: Robert Lin Date: Sat, 30 Jun 2018 10:57:12 -0700 Subject: [PATCH 06/19] Generate master token for CLI use --- daemon/inertiad/auth/permissions_test.go | 2 +- daemon/inertiad/auth/sessions.go | 6 ++-- daemon/inertiad/auth/users.go | 19 +++++++------ daemon/inertiad/cmd.go | 2 +- daemon/inertiad/crypto/authtest.go | 5 ++-- daemon/inertiad/crypto/{auth.go => keys.go} | 0 .../crypto/{auth_test.go => keys_test.go} | 0 daemon/inertiad/crypto/token.go | 28 +++++++++++++------ daemon/inertiad/crypto/token_test.go | 19 +++++++++---- 9 files changed, 51 insertions(+), 30 deletions(-) rename daemon/inertiad/crypto/{auth.go => keys.go} (100%) rename daemon/inertiad/crypto/{auth_test.go => keys_test.go} (100%) diff --git a/daemon/inertiad/auth/permissions_test.go b/daemon/inertiad/auth/permissions_test.go index 26b8a85b..54733a9a 100644 --- a/daemon/inertiad/auth/permissions_test.go +++ b/daemon/inertiad/auth/permissions_test.go @@ -298,7 +298,7 @@ func TestUserControlHandlers(t *testing.T) { // Test handler uses the getFakeAPIToken keylookup, which will match with // the testToken - bearerTokenString := fmt.Sprintf("Bearer %s", crypto.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 9d7ef923..4bd071b2 100644 --- a/daemon/inertiad/auth/sessions.go +++ b/daemon/inertiad/auth/sessions.go @@ -128,10 +128,8 @@ func (s *sessionManager) GetSession(r *http.Request) (*crypto.TokenClaims, error return nil, err } - // TODO: this is a workaround for client tokens having no claims and no - // sessions - if claims.User == "" && claims.SessionID == "" { - claims.User = "sudo" + // Master tokens aren't session-tracked. TODO: reassess security of this + if claims.IsMaster() { return claims, nil } diff --git a/daemon/inertiad/auth/users.go b/daemon/inertiad/auth/users.go index 105670f6..8f4a636a 100644 --- a/daemon/inertiad/auth/users.go +++ b/daemon/inertiad/auth/users.go @@ -44,8 +44,17 @@ 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 @@ -197,12 +206,6 @@ func (m *userManager) IsCorrectCredentials(username, password string) (*userProp // IsAdmin checks if given user is has administrator priviledges func (m *userManager) IsAdmin(username string) (bool, error) { - // TODO: this is a workaround for client tokens having no claims and no - // sessions - if username == "sudo" { - return true, nil - } - // Check if user is admin in database admin := false err := m.db.View(func(tx *bolt.Tx) error { diff --git a/daemon/inertiad/cmd.go b/daemon/inertiad/cmd.go index 08e705fc..ebdf7636 100644 --- a/daemon/inertiad/cmd.go +++ b/daemon/inertiad/cmd.go @@ -43,7 +43,7 @@ Created using an RSA private key.`, panic(err) } - token, err := crypto.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 index db360a39..0f0c29e2 100644 --- a/daemon/inertiad/crypto/authtest.go +++ b/daemon/inertiad/crypto/authtest.go @@ -13,8 +13,9 @@ var ( // TestPrivateKey is an example key for testing purposes TestPrivateKey = []byte("very_sekrit_key") - // TestToken is an example token for testing purposes - TestToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.AqFWnFeY9B8jj7-l3z0a9iaZdwIca7xhUF3fuaJjU90" + // 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") diff --git a/daemon/inertiad/crypto/auth.go b/daemon/inertiad/crypto/keys.go similarity index 100% rename from daemon/inertiad/crypto/auth.go rename to daemon/inertiad/crypto/keys.go diff --git a/daemon/inertiad/crypto/auth_test.go b/daemon/inertiad/crypto/keys_test.go similarity index 100% rename from daemon/inertiad/crypto/auth_test.go rename to daemon/inertiad/crypto/keys_test.go diff --git a/daemon/inertiad/crypto/token.go b/daemon/inertiad/crypto/token.go index 73106681..f2ff112e 100644 --- a/daemon/inertiad/crypto/token.go +++ b/daemon/inertiad/crypto/token.go @@ -8,7 +8,10 @@ import ( ) const ( + // TokenInvalidErrorMsg says that the token is invalid TokenInvalidErrorMsg = "token invalid" + + // TokenExpiredErrorMsg says that the token is expired TokenExpiredErrorMsg = "token expired" ) @@ -22,9 +25,7 @@ type TokenClaims struct { // Valid checks if token is authentic func (t *TokenClaims) Valid() error { - // TODO: This is a workaround for client tokens, which currently do not - // have claims. Need to move those onto this system. - if t.SessionID == "" && t.User == "" { + if t.IsMaster() { return nil } @@ -34,6 +35,11 @@ func (t *TokenClaims) Valid() error { 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. @@ -61,9 +67,15 @@ func ValidateToken(tokenString string, lookup jwt.Keyfunc) (*TokenClaims, error) return nil, errors.New(TokenInvalidErrorMsg) } -// 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) +// 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 index 024eb751..45325506 100644 --- a/daemon/inertiad/crypto/token_test.go +++ b/daemon/inertiad/crypto/token_test.go @@ -7,14 +7,19 @@ import ( "github.com/stretchr/testify/assert" ) -func TestGenerateToken(t *testing.T) { - token, err := GenerateToken(TestPrivateKey) - assert.Nil(t, err, "generateToken must not fail") - assert.Equal(t, token, TestToken) +func TestGenerateMasterToken(t *testing.T) { + token, err := GenerateMasterToken(TestPrivateKey) + assert.Nil(t, err) + assert.Equal(t, TestMasterToken, token) - otherToken, err := GenerateToken([]byte("another_sekrit_key")) + 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) { @@ -29,6 +34,8 @@ func TestTokenClaims_Valid(t *testing.T) { 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) @@ -49,7 +56,7 @@ func TestTokenClaims_Valid(t *testing.T) { } } -func TestTokenCliams_GenerateToken(t *testing.T) { +func TestTokenClaims_GenerateToken(t *testing.T) { expires := time.Now().AddDate(0, 1, 0) claims := &TokenClaims{"1234", "robert", true, expires} token, err := claims.GenerateToken(TestPrivateKey) From 086cf067cf46382f6e335ed426dafcf7aa0f835a Mon Sep 17 00:00:00 2001 From: Robert Lin Date: Sat, 30 Jun 2018 10:57:17 -0700 Subject: [PATCH 07/19] Add session tests --- daemon/inertiad/auth/sessions_test.go | 32 +++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 daemon/inertiad/auth/sessions_test.go 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)) +} From 6185b72eb8ccb2be359c7ccb63f811fb7f9754b1 Mon Sep 17 00:00:00 2001 From: Robert Lin Date: Sat, 30 Jun 2018 11:15:24 -0700 Subject: [PATCH 08/19] Add extra user to test (there is now a persistent master user) --- daemon/inertiad/auth/users_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/daemon/inertiad/auth/users_test.go b/daemon/inertiad/auth/users_test.go index e399784d..03a37bdc 100644 --- a/daemon/inertiad/auth/users_test.go +++ b/daemon/inertiad/auth/users_test.go @@ -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) From 3756f39e27f884ea10a4110187f12f85c4fbaf54 Mon Sep 17 00:00:00 2001 From: Robert Lin Date: Sat, 30 Jun 2018 11:16:47 -0700 Subject: [PATCH 09/19] Make more lenient token test --- daemon/inertiad/crypto/token_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/daemon/inertiad/crypto/token_test.go b/daemon/inertiad/crypto/token_test.go index 45325506..6a7eefd5 100644 --- a/daemon/inertiad/crypto/token_test.go +++ b/daemon/inertiad/crypto/token_test.go @@ -65,5 +65,5 @@ func TestTokenClaims_GenerateToken(t *testing.T) { // Try decoding token readClaims, err := ValidateToken(token, GetFakeAPIKey) assert.Nil(t, err) - assert.True(t, assert.ObjectsAreEqualValues(claims, readClaims)) + assert.Equal(t, claims.User, readClaims.User) } From 54e10af22df7be5784dbfe82c5ffcf1d2e166bf6 Mon Sep 17 00:00:00 2001 From: Robert Lin Date: Sat, 30 Jun 2018 11:54:04 -0700 Subject: [PATCH 10/19] Save config after bootstrap provision --- cmd/provision.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) 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) }, } From 77105c5ccbd2eb2bd1470bfce78cb91a5b81e049 Mon Sep 17 00:00:00 2001 From: Robert Lin Date: Sat, 30 Jun 2018 12:12:18 -0700 Subject: [PATCH 11/19] Set project directory on up --- daemon/inertiad/up.go | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/daemon/inertiad/up.go b/daemon/inertiad/up.go index 8e7a4262..4e638553 100644 --- a/daemon/inertiad/up.go +++ b/daemon/inertiad/up.go @@ -47,13 +47,14 @@ func upHandler(w http.ResponseWriter, r *http.Request) { if deployment == nil { logger.Println("No deployment detected") d, err := project.NewDeployment(build.NewBuilder(*conf), project.DeploymentConfig{ - ProjectName: upReq.Project, - BuildType: upReq.BuildType, - BuildFilePath: upReq.BuildFilePath, - RemoteURL: gitOpts.RemoteURL, - Branch: gitOpts.Branch, - PemFilePath: crypto.DaemonGithubKeyLocation, - DatabasePath: path.Join(conf.DataDirectory, "project.db"), + ProjectDirectory: conf.ProjectDirectory, + ProjectName: upReq.Project, + BuildType: upReq.BuildType, + BuildFilePath: upReq.BuildFilePath, + RemoteURL: gitOpts.RemoteURL, + Branch: gitOpts.Branch, + PemFilePath: crypto.DaemonGithubKeyLocation, + DatabasePath: path.Join(conf.DataDirectory, "project.db"), }, logger) if err != nil { logger.WriteErr(err.Error(), http.StatusPreconditionFailed) From 7a75d172d92391d45ae9ad6db997b0571266553e Mon Sep 17 00:00:00 2001 From: Robert Lin Date: Sat, 30 Jun 2018 12:17:19 -0700 Subject: [PATCH 12/19] gofmt -s --- daemon/inertiad/auth/sessions_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/daemon/inertiad/auth/sessions_test.go b/daemon/inertiad/auth/sessions_test.go index a7c2d48c..5c422f30 100644 --- a/daemon/inertiad/auth/sessions_test.go +++ b/daemon/inertiad/auth/sessions_test.go @@ -10,10 +10,10 @@ import ( func Test_sessionManager_EndAllSessions(t *testing.T) { // make two sessions because map is pointer sessions := map[string]*crypto.TokenClaims{ - "1234": &crypto.TokenClaims{User: "bob"}, + "1234": {User: "bob"}, } manager := &sessionManager{internal: map[string]*crypto.TokenClaims{ - "1234": &crypto.TokenClaims{User: "bob"}, + "1234": {User: "bob"}, }} manager.EndAllSessions() assert.False(t, assert.ObjectsAreEqualValues(sessions, manager.internal)) @@ -22,10 +22,10 @@ func Test_sessionManager_EndAllSessions(t *testing.T) { func Test_sessionManager_EndAllUserSessions(t *testing.T) { // make two sessions because map is pointer sessions := map[string]*crypto.TokenClaims{ - "1234": &crypto.TokenClaims{User: "bob"}, + "1234": {User: "bob"}, } manager := &sessionManager{internal: map[string]*crypto.TokenClaims{ - "1234": &crypto.TokenClaims{User: "bob"}, + "1234": {User: "bob"}, }} manager.EndAllUserSessions("bob") assert.False(t, assert.ObjectsAreEqualValues(sessions, manager.internal)) From 82c40b685da95cfbe3bca7806273a3a784a404f9 Mon Sep 17 00:00:00 2001 From: Robert Lin Date: Sat, 30 Jun 2018 13:18:17 -0700 Subject: [PATCH 13/19] Correct spelling error --- cmd/provision.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/provision.go b/cmd/provision.go index f1162000..cd67e749 100644 --- a/cmd/provision.go +++ b/cmd/provision.go @@ -149,7 +149,7 @@ var cmdProvisionECS = &cobra.Command{ log.Fatal(err) } - // Save udpated config + // Save updated config config.Write(path) }, } From dfabe9476001aad2fb89a7c5a5d830a8f1c47841 Mon Sep 17 00:00:00 2001 From: Robert Lin Date: Sat, 30 Jun 2018 15:13:35 -0700 Subject: [PATCH 14/19] Update dependency install instructions --- CONTRIBUTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 57d22fa0..0046cac0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -83,8 +83,8 @@ Inertia uses: Make sure all of the above are installed before running: ```bash -$> make # installs dependencies and an Inertia - # build tagged as "test" to gopath +$> make deps # installs dependencies +$> make cli # installs Inertia build tagged as "test" to gopath $> inertia --version # check what version you have installed ``` From b33075b8794ac3bc348cdd813be1537cf9555abd Mon Sep 17 00:00:00 2001 From: Robert Lin Date: Sat, 30 Jun 2018 15:26:04 -0700 Subject: [PATCH 15/19] Remove debug log --- cmd/deploy.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/cmd/deploy.go b/cmd/deploy.go index e0cdc617..1c557057 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -48,8 +48,6 @@ func init() { ) } - fmt.Print(config) - // Make a new command for each remote with all associated // deployment commands. for _, remote := range config.Remotes { From 12271e2d20953c446d8f1117ac0d6bb7bef0ab67 Mon Sep 17 00:00:00 2001 From: Robert Lin Date: Sat, 30 Jun 2018 16:02:56 -0700 Subject: [PATCH 16/19] Prevent release upload from including inertia.go --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 37ee6c1c..f33f88e4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -88,7 +88,7 @@ jobs: api_key: secure: eHe5lL6mVp6XQM5sdUQHWcugROAjZC5Gf81flIjM/4K1ceUQfblIQ3/suXOHBXl+jGro0DTlI/a8S9zKb3NDdlTiXjABQD8KkWNMfEqajUInRwSFE7vSvydzJDuwO00KZOuFKdp2hZwpO2jbODZvHEnYONTClle5pV6bzhp4JaY1P0CjAl6/E67WVjpNQmHNIlzcZ7PRLe7KcjEl5N7DIg/B4R19EiBTjaPsCh8xP/T68cqVVa+4cwUAxN6Xd0ca81T9dkhppRL9tiJRn6iP/x64paIeWV0pF80V2PKTwbI9Ox0mcd7TWkHFD4taV+UwQUeOeN3OR7NHfzyPtWUcCVOp3wxFKvWySPYNeh8x8OrFh7HzIMl+20SQ/rdEsJJ6cfMx+qp+RBJmSj+Btm/ZxfEZgjRTg2jiCeUNnNpBL+46U49TVPTuw7+K97x/cGRF9k0JAfkq75tQTPCoQwmkCqTeJ6KGwFwlDx5+OUCJz3WS0XfQ6k88eYQygmBBdWqoHXUS75vOa96GVN1JSp8tBpSp0lUp+U2QQJkIy9VN9NpMlsbn3r5Ssk/AtEhal1fpT5tncPvzphwSKDH3WtCJ+FJjMajBIlZA2yop1VdsVJzKFBo2DH+yn8qd1tIsp5C+HDPvulCYMFCg7zJk0ksXZVERTGnrnRBSENW+Ut1avJ0= file_glob: true - file: inertia.* + file: inertia.v* go: "1.10" on: repo: ubclaunchpad/inertia From dea6f2554b094d8f881ffb770bbefa68b10a7738 Mon Sep 17 00:00:00 2001 From: Robert Lin Date: Sun, 1 Jul 2018 15:14:25 -0700 Subject: [PATCH 17/19] Remove lint from test-all --- Makefile | 1 - 1 file changed, 1 deletion(-) diff --git a/Makefile b/Makefile index f0b1158f..651f5c59 100644 --- a/Makefile +++ b/Makefile @@ -65,7 +65,6 @@ test-v: # Also attempts to run linter .PHONY: test-all test-all: - make lint make testenv VPS_OS=$(VPS_OS) VPS_VERSION=$(VPS_VERSION) make testdaemon go test ./... -ldflags "-X main.Version=test" --cover From ab144d7d592a4ae30fc7b3cedaf6b18ed7e72602 Mon Sep 17 00:00:00 2001 From: Robert Lin Date: Sun, 1 Jul 2018 15:48:36 -0700 Subject: [PATCH 18/19] Replace stdout prints with FPrint to set writers (#294) --- client/client.go | 62 ++++++++++++++++++++++++------------------- client/client_test.go | 4 +++ cmd/deploy.go | 41 ++++++++++++---------------- cmd/env.go | 6 ++--- cmd/provision.go | 6 ++--- cmd/users.go | 8 +++--- common/util.go | 7 +++++ local/storage.go | 16 ++++++----- local/storage_test.go | 4 +-- provision/ec2.go | 45 +++++++++++++++++++------------ 10 files changed, 111 insertions(+), 88 deletions(-) diff --git a/client/client.go b/client/client.go index 0315e288..5fe61008 100644 --- a/client/client.go +++ b/client/client.go @@ -6,7 +6,6 @@ import ( "encoding/json" "fmt" "io" - "log" "net/http" "net/url" "path" @@ -26,18 +25,27 @@ type Client struct { buildType string buildFilePath string + out io.Writer + sshRunner SSHSession verifySSL bool } // NewClient sets up a client to communicate to the daemon at // the given named remote. -func NewClient(remoteName string, config *cfg.Config) (*Client, bool) { +func NewClient(remoteName string, config *cfg.Config, out ...io.Writer) (*Client, bool) { remote, found := config.GetRemote(remoteName) if !found { return nil, false } + var writer io.Writer + if len(out) > 0 { + writer = out[0] + } else { + writer = common.DevNull{} + } + return &Client{ RemoteVPS: remote, version: config.Version, @@ -45,6 +53,8 @@ func NewClient(remoteName string, config *cfg.Config) (*Client, bool) { buildType: config.BuildType, buildFilePath: config.BuildFilePath, sshRunner: NewSSHRunner(remote), + + out: writer, }, true } @@ -59,15 +69,15 @@ func (c *Client) SetSSLVerification(verify bool) { // public-private key-pair. It outputs configuration information // for the user. func (c *Client) BootstrapRemote(repoName string) error { - println("Setting up remote \"" + c.Name + "\" at " + c.IP) + fmt.Fprintf(c.out, "Setting up remote %s at %s", c.Name, c.IP) - println(">> Step 1/4: Installing docker...") + fmt.Fprint(c.out, ">> Step 1/4: Installing docker...") err := c.installDocker(c.sshRunner) if err != nil { return err } - println("\n>> Step 2/4: Building deploy key...") + fmt.Fprint(c.out, "\n>> Step 2/4: Building deploy key...") if err != nil { return err } @@ -78,7 +88,7 @@ func (c *Client) BootstrapRemote(repoName string) error { // This step needs to run before any other commands that rely on // the daemon image, since the daemon is loaded here. - println("\n>> Step 3/4: Starting daemon...") + fmt.Fprint(c.out, "\n>> Step 3/4: Starting daemon...") if err != nil { return err } @@ -87,34 +97,34 @@ func (c *Client) BootstrapRemote(repoName string) error { return err } - println("\n>> Step 4/4: Fetching daemon API token...") + fmt.Fprint(c.out, "\n>> Step 4/4: Fetching daemon API token...") token, err := c.getDaemonAPIToken(c.sshRunner, c.version) if err != nil { return err } c.Daemon.Token = token - println("\nInertia has been set up and daemon is running on remote!") - println("You may have to wait briefly for Inertia to set up some dependencies.") - fmt.Printf("Use 'inertia %s logs --stream' to check on the daemon's setup progress.\n\n", c.Name) + fmt.Fprint(c.out, "\nInertia has been set up and daemon is running on remote!") + fmt.Fprint(c.out, "You may have to wait briefly for Inertia to set up some dependencies.") + fmt.Fprintf(c.out, "Use 'inertia %s logs --stream' to check on the daemon's setup progress.\n\n", c.Name) - println("=============================\n") + fmt.Fprint(c.out, "=============================\n") // Output deploy key to user. - println(">> GitHub Deploy Key (add to https://www.github.com/" + repoName + "/settings/keys/new): ") - println(pub.String()) + fmt.Fprintf(c.out, ">> GitHub Deploy Key (add to https://www.github.com/%s/settings/keys/new): ", repoName) + fmt.Fprint(c.out, pub.String()) // Output Webhook url to user. - println(">> GitHub WebHook URL (add to https://www.github.com/" + repoName + "/settings/hooks/new): ") - println("WebHook Address: https://" + c.IP + ":" + c.Daemon.Port + "/webhook") - println("WebHook Secret: " + c.Daemon.WebHookSecret) - println(`Note that you will have to disable SSH verification in your webhook + fmt.Fprintf(c.out, ">> GitHub WebHook URL (add to https://www.github.com/%s/settings/hooks/new): ", repoName) + fmt.Fprintf(c.out, "WebHook Address: https://%s:%s/webhook", c.IP, c.Daemon.Port) + fmt.Fprint(c.out, "WebHook Secret: "+c.Daemon.WebHookSecret) + fmt.Fprint(c.out, `Note that you will have to disable SSH verification in your webhook settings - Inertia uses self-signed certificates that GitHub won't -be able to verify.` + "\n") +be able to verify.`+"\n") - println(`Inertia daemon successfully deployed! Add your webhook url and deploy + fmt.Fprint(c.out, `Inertia daemon successfully deployed! Add your webhook url and deploy key to enable continuous deployment.`) - fmt.Printf("Then run 'inertia %s up' to deploy your application.\n", c.Name) + fmt.Fprintf(c.out, "Then run 'inertia %s up' to deploy your application.\n", c.Name) return nil } @@ -140,8 +150,7 @@ func (c *Client) DaemonDown() error { _, stderr, err := c.sshRunner.Run(string(scriptBytes)) if err != nil { - println(stderr.String()) - return err + return fmt.Errorf("daemon shutdown failed: %s: %s", err.Error(), stderr.String()) } return nil @@ -158,8 +167,7 @@ func (c *Client) installDocker(session SSHSession) error { cmdStr := string(installDockerSh) _, stderr, err := session.Run(cmdStr) if err != nil { - println(stderr.String()) - return err + return fmt.Errorf("docker installation: %s: %s", err.Error(), stderr.String()) } return nil @@ -177,8 +185,7 @@ func (c *Client) keyGen(session SSHSession) (*bytes.Buffer, error) { result, stderr, err := session.Run(string(scriptBytes)) if err != nil { - log.Println(stderr.String()) - return nil, err + return nil, fmt.Errorf("key generation failed: %s: %s", err.Error(), stderr.String()) } return result, nil @@ -195,8 +202,7 @@ func (c *Client) getDaemonAPIToken(session SSHSession, daemonVersion string) (st stdout, stderr, err := session.Run(daemonCmdStr) if err != nil { - log.Println(stderr.String()) - return "", err + return "", fmt.Errorf("api token generation failed: %s: %s", err.Error(), stderr.String()) } // There may be a newline, remove it. diff --git a/client/client_test.go b/client/client_test.go index 8c2570e9..fc1930db 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -7,6 +7,7 @@ import ( "io/ioutil" "net/http" "net/http/httptest" + "os" "strings" "testing" "time" @@ -47,6 +48,7 @@ func getMockClient(ts *httptest.Server) *Client { return &Client{ RemoteVPS: mockRemote, + out: os.Stdout, project: "test_project", } } @@ -66,12 +68,14 @@ func getIntegrationClient(mockRunner *mockSSHRunner) *Client { return &Client{ version: "test", RemoteVPS: remote, + out: os.Stdout, sshRunner: mockRunner, } } return &Client{ version: "test", RemoteVPS: remote, + out: os.Stdout, sshRunner: NewSSHRunner(remote), } } diff --git a/cmd/deploy.go b/cmd/deploy.go index 1c557057..8255dd21 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -3,7 +3,6 @@ package cmd import ( "bufio" "encoding/json" - "errors" "fmt" "io/ioutil" "net/http" @@ -138,7 +137,7 @@ var cmdDeploymentUp = &cobra.Command{ to be active on your remote - do this by running 'inertia [REMOTE] init'`, Run: func(cmd *cobra.Command, args []string) { remoteName := strings.Split(cmd.Parent().Use, " ")[0] - deployment, err := local.GetClient(remoteName, configFilePath, cmd) + deployment, _, err := local.GetClient(remoteName, configFilePath, cmd) if err != nil { log.Fatal(err) } @@ -202,7 +201,7 @@ var cmdDeploymentDown = &cobra.Command{ Requires project to be online - do this by running 'inertia [REMOTE] up`, Run: func(cmd *cobra.Command, args []string) { remoteName := strings.Split(cmd.Parent().Use, " ")[0] - deployment, err := local.GetClient(remoteName, configFilePath, cmd) + deployment, _, err := local.GetClient(remoteName, configFilePath, cmd) if err != nil { log.Fatal(err) } @@ -239,7 +238,7 @@ var cmdDeploymentStatus = &cobra.Command{ running 'inertia [REMOTE] up'`, Run: func(cmd *cobra.Command, args []string) { remoteName := strings.Split(cmd.Parent().Use, " ")[0] - deployment, err := local.GetClient(remoteName, configFilePath, cmd) + deployment, _, err := local.GetClient(remoteName, configFilePath, cmd) if err != nil { log.Fatal(err) } @@ -286,7 +285,7 @@ var cmdDeploymentLogs = &cobra.Command{ status' to see what containers are accessible.`, Run: func(cmd *cobra.Command, args []string) { remoteName := strings.Split(cmd.Parent().Use, " ")[0] - deployment, err := local.GetClient(remoteName, configFilePath, cmd) + deployment, _, err := local.GetClient(remoteName, configFilePath, cmd) if err != nil { log.Fatal(err) } @@ -346,7 +345,7 @@ var cmdDeploymentSSH = &cobra.Command{ Long: `Starts up an interact SSH session with your remote.`, Run: func(cmd *cobra.Command, args []string) { remoteName := strings.Split(cmd.Parent().Use, " ")[0] - deployment, err := local.GetClient(remoteName, configFilePath) + deployment, _, err := local.GetClient(remoteName, configFilePath) if err != nil { log.Fatal(err) } @@ -366,7 +365,7 @@ deployment. Provide a relative path to your file.`, Args: cobra.MinimumNArgs(1), Run: func(cmd *cobra.Command, args []string) { remoteName := strings.Split(cmd.Parent().Use, " ")[0] - deployment, err := local.GetClient(remoteName, configFilePath, cmd) + deployment, _, err := local.GetClient(remoteName, configFilePath, cmd) if err != nil { log.Fatal(err) } @@ -422,27 +421,21 @@ request access to the repository via a public key, and will listen for updates to this repository's remote master branch.`, Run: func(cmd *cobra.Command, args []string) { remoteName := strings.Split(cmd.Parent().Use, " ")[0] + cli, write, err := local.GetClient(remoteName, configFilePath, cmd) + if err != nil { + log.Fatal(err) + } - // Bootstrap needs to write to configuration. - config, path, err := local.GetProjectConfigFromDisk(configFilePath) + url, err := local.GetRepoRemote("origin") if err != nil { log.Fatal(err) } - cli, found := client.NewClient(remoteName, config) - if found { - url, err := local.GetRepoRemote("origin") - if err != nil { - log.Fatal(err) - } - repoName := common.ExtractRepository(common.GetSSHRemoteURL(url)) - err = cli.BootstrapRemote(repoName) - if err != nil { - log.Fatal(err) - } - config.Write(path) - } else { - log.Fatal(errors.New("There does not appear to be a remote with this name. Have you modified the Inertia configuration file?")) + repoName := common.ExtractRepository(common.GetSSHRemoteURL(url)) + err = cli.BootstrapRemote(repoName) + if err != nil { + log.Fatal(err) } + write() }, } @@ -456,7 +449,7 @@ remote. Requires Inertia daemon to be active on your remote - do this by running 'inertia [REMOTE] init'`, Run: func(cmd *cobra.Command, args []string) { remoteName := strings.Split(cmd.Parent().Use, " ")[0] - deployment, err := local.GetClient(remoteName, configFilePath, cmd) + deployment, _, err := local.GetClient(remoteName, configFilePath, cmd) if err != nil { log.Fatal(err) } diff --git a/cmd/env.go b/cmd/env.go index ec46bd4d..35829ada 100644 --- a/cmd/env.go +++ b/cmd/env.go @@ -23,7 +23,7 @@ variables are applied to all deployed containers.`, Args: cobra.MinimumNArgs(2), Run: func(cmd *cobra.Command, args []string) { remoteName := strings.Split(cmd.Parent().Parent().Use, " ")[0] - deployment, err := local.GetClient(remoteName, configFilePath, cmd) + deployment, _, err := local.GetClient(remoteName, configFilePath, cmd) if err != nil { log.Fatal(err) } @@ -55,7 +55,7 @@ and persistent environment storage.`, Args: cobra.MinimumNArgs(1), Run: func(cmd *cobra.Command, args []string) { remoteName := strings.Split(cmd.Parent().Parent().Use, " ")[0] - deployment, err := local.GetClient(remoteName, configFilePath, cmd) + deployment, _, err := local.GetClient(remoteName, configFilePath, cmd) if err != nil { log.Fatal(err) } @@ -79,7 +79,7 @@ var cmdDeploymentEnvList = &cobra.Command{ Short: "List currently set and saved environment variables", Run: func(cmd *cobra.Command, args []string) { remoteName := strings.Split(cmd.Parent().Parent().Use, " ")[0] - deployment, err := local.GetClient(remoteName, configFilePath, cmd) + deployment, _, err := local.GetClient(remoteName, configFilePath, cmd) if err != nil { log.Fatal(err) } diff --git a/cmd/provision.go b/cmd/provision.go index cd67e749..576a0005 100644 --- a/cmd/provision.go +++ b/cmd/provision.go @@ -66,9 +66,9 @@ var cmdProvisionECS = &cobra.Command{ if err != nil { log.Fatal(err) } - prov, err = provision.NewEC2Provisioner(id, key) + prov, err = provision.NewEC2Provisioner(id, key, os.Stdout) } else { - prov, err = provision.NewEC2ProvisionerFromEnv() + prov, err = provision.NewEC2ProvisionerFromEnv(os.Stdout) } if err != nil { log.Fatal(err) @@ -137,7 +137,7 @@ var cmdProvisionECS = &cobra.Command{ config.Write(path) // Create inertia client - inertia, found := client.NewClient(args[0], config) + inertia, found := client.NewClient(args[0], config, os.Stdout) if !found { log.Fatal("vps setup did not complete properly") } diff --git a/cmd/users.go b/cmd/users.go index 9145f8e2..81a2e7ba 100644 --- a/cmd/users.go +++ b/cmd/users.go @@ -31,7 +31,7 @@ Use the --admin flag to create an admin user.`, Args: cobra.MinimumNArgs(1), Run: func(cmd *cobra.Command, args []string) { remoteName := strings.Split(cmd.Parent().Parent().Use, " ")[0] - deployment, err := local.GetClient(remoteName, configFilePath, cmd) + deployment, _, err := local.GetClient(remoteName, configFilePath, cmd) if err != nil { log.Fatal(err) } @@ -80,7 +80,7 @@ deployment from the web app.`, Args: cobra.MinimumNArgs(1), Run: func(cmd *cobra.Command, args []string) { remoteName := strings.Split(cmd.Parent().Parent().Use, " ")[0] - deployment, err := local.GetClient(remoteName, configFilePath, cmd) + deployment, _, err := local.GetClient(remoteName, configFilePath, cmd) if err != nil { log.Fatal(err) } @@ -117,7 +117,7 @@ from the web app.`, Args: cobra.MinimumNArgs(1), Run: func(cmd *cobra.Command, args []string) { remoteName := strings.Split(cmd.Parent().Parent().Use, " ")[0] - deployment, err := local.GetClient(remoteName, configFilePath, cmd) + deployment, _, err := local.GetClient(remoteName, configFilePath, cmd) if err != nil { log.Fatal(err) } @@ -151,7 +151,7 @@ var cmdDeploymentListUsers = &cobra.Command{ Long: `List all users with access to Inertia Web on your remote.`, Run: func(cmd *cobra.Command, args []string) { remoteName := strings.Split(cmd.Parent().Parent().Use, " ")[0] - deployment, err := local.GetClient(remoteName, configFilePath, cmd) + deployment, _, err := local.GetClient(remoteName, configFilePath, cmd) if err != nil { log.Fatal(err) } diff --git a/common/util.go b/common/util.go index faa45f3f..34127819 100644 --- a/common/util.go +++ b/common/util.go @@ -10,6 +10,13 @@ import ( "time" ) +// DevNull writes to null, since a nil io.Writer will break shit +type DevNull struct{} + +func (dn DevNull) Write(p []byte) (n int, err error) { + return len(p), nil +} + // GetFullPath returns the absolute path of the config file. func GetFullPath(relPath string) (string, error) { path, err := os.Getwd() diff --git a/local/storage.go b/local/storage.go index a39284ce..2d487900 100644 --- a/local/storage.go +++ b/local/storage.go @@ -88,26 +88,28 @@ func GetProjectConfigFromDisk(relPath string) (*cfg.Config, string, error) { } // GetClient returns a local deployment setup -func GetClient(name, relPath string, cmd ...*cobra.Command) (*client.Client, error) { - config, _, err := GetProjectConfigFromDisk(relPath) +func GetClient(name, relPath string, cmd ...*cobra.Command) (*client.Client, func() error, error) { + config, path, err := GetProjectConfigFromDisk(relPath) if err != nil { - return nil, err + return nil, nil, err } - client, found := client.NewClient(name, config) + client, found := client.NewClient(name, config, os.Stdout) if !found { - return nil, errors.New("Remote not found") + return nil, nil, errors.New("Remote not found") } if len(cmd) == 1 && cmd[0] != nil { verify, err := cmd[0].Flags().GetBool("verify-ssl") if err != nil { - return nil, err + return nil, nil, err } client.SetSSLVerification(verify) } - return client, nil + return client, func() error { + return config.Write(path) + }, nil } // SaveKey writes a key to given path diff --git a/local/storage_test.go b/local/storage_test.go index dff3b16e..ccbc1a85 100644 --- a/local/storage_test.go +++ b/local/storage_test.go @@ -63,11 +63,11 @@ func TestConfigCreateAndWriteAndRead(t *testing.T) { assert.Equal(t, config.Remotes["test2"], readConfig.Remotes["test2"]) // Test client read - client, err := GetClient("test2", "inertia.toml") + client, _, err := GetClient("test2", "inertia.toml") assert.Nil(t, err) assert.Equal(t, "test2", client.Name) assert.Equal(t, "12343:80801", client.GetIPAndPort()) - _, err = GetClient("asdf", "inertia.toml") + _, _, err = GetClient("asdf", "inertia.toml") assert.NotNil(t, err) // Test config remove diff --git a/provision/ec2.go b/provision/ec2.go index d2cb31da..3c188c83 100644 --- a/provision/ec2.go +++ b/provision/ec2.go @@ -2,6 +2,7 @@ package provision import ( "fmt" + "io" "net" "os" "path/filepath" @@ -20,6 +21,7 @@ import ( // EC2Provisioner creates Amazon EC2 instances type EC2Provisioner struct { + out io.Writer user string session *session.Session client *ec2.EC2 @@ -27,16 +29,16 @@ type EC2Provisioner struct { // NewEC2Provisioner creates a client to interact with Amazon EC2 using the // given credentials -func NewEC2Provisioner(id, key string) (*EC2Provisioner, error) { +func NewEC2Provisioner(id, key string, out ...io.Writer) (*EC2Provisioner, error) { prov := &EC2Provisioner{} - return prov, prov.init(credentials.NewStaticCredentials(id, key, "")) + return prov, prov.init(credentials.NewStaticCredentials(id, key, ""), out) } // NewEC2ProvisionerFromEnv creates a client to interact with Amazon EC2 using // credentials from environment -func NewEC2ProvisionerFromEnv() (*EC2Provisioner, error) { +func NewEC2ProvisionerFromEnv(out ...io.Writer) (*EC2Provisioner, error) { prov := &EC2Provisioner{} - return prov, prov.init(credentials.NewEnvCredentials()) + return prov, prov.init(credentials.NewEnvCredentials(), out) } // GetUser returns the user attached to given credentials @@ -164,7 +166,7 @@ func (p *EC2Provisioner) CreateInstance(opts EC2CreateInstanceOptions) (*cfg.Rem } // Loop until intance is running - println("Checking status of requested instance...") + fmt.Fprint(p.out, "Checking status of requested instance...") attempts := 0 var instanceStatus *ec2.DescribeInstancesOutput for { @@ -180,16 +182,16 @@ func (p *EC2Provisioner) CreateInstance(opts EC2CreateInstanceOptions) (*cfg.Rem // A reservation corresponds to a command to start instances time.Sleep(3 * time.Second) continue - } else if *result.Reservations[0].Instances[0].State.Code != 16 { - // Code 16 indicates instance is running - println("Instance status: " + *result.Reservations[0].Instances[0].State.Name) - time.Sleep(3 * time.Second) - continue - } else { + } else if *result.Reservations[0].Instances[0].State.Code == 16 { // Code 16 means we can continue! - println("Instance is running!") + fmt.Fprint(p.out, "Instance is running!") instanceStatus = result break + } else { + // Keep polling + fmt.Fprint(p.out, "Instance status: "+*result.Reservations[0].Instances[0].State.Name) + time.Sleep(3 * time.Second) + continue } } @@ -207,15 +209,18 @@ func (p *EC2Provisioner) CreateInstance(opts EC2CreateInstanceOptions) (*cfg.Rem }, }, }) + if err != nil { + fmt.Fprintf(p.out, "Failed to set tags: %s", err.Error()) + } // Poll for SSH port to open - println("Waiting for port 22 to open...") + fmt.Fprint(p.out, "Waiting for ports to open...") for { time.Sleep(3 * time.Second) - println("Checking port...") + fmt.Fprint(p.out, "Checking ports...") conn, err := net.Dial("tcp", *instanceStatus.Reservations[0].Instances[0].PublicDnsName+":22") if err == nil { - println("Connection established!") + fmt.Fprint(p.out, "Connection established!") conn.Close() break } @@ -224,7 +229,8 @@ func (p *EC2Provisioner) CreateInstance(opts EC2CreateInstanceOptions) (*cfg.Rem // Generate webhook secret webhookSecret, err := common.GenerateRandomString() if err != nil { - println(err.Error()) + fmt.Fprint(p.out, err.Error()) + fmt.Fprint(p.out, "Using default secret 'inertia'") webhookSecret = "interia" } @@ -279,7 +285,12 @@ func (p *EC2Provisioner) exposePorts(securityGroupID string, daemonPort int64, p return err } -func (p *EC2Provisioner) init(creds *credentials.Credentials) error { +func (p *EC2Provisioner) init(creds *credentials.Credentials, out []io.Writer) error { + if len(out) > 0 { + p.out = out[0] + } else { + p.out = common.DevNull{} + } // Set default user p.user = "ec2-user" From 0c4136ed4ccf6ac5ec946accdd3857e3cd7ff57f Mon Sep 17 00:00:00 2001 From: Robert Lin Date: Tue, 3 Jul 2018 23:49:55 -0700 Subject: [PATCH 19/19] Correct invalid references and add more detail to guide (#299) * 'client/bootstrap' is now 'client/scripts' * add note that the docker daemon should be online * add instructions for setting up GOPATH and GOBIN --- CONTRIBUTING.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0046cac0..cd03c42c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,12 +75,20 @@ $> git remote rename origin upstream # Set the official repo as you $> git remote add origin https://github.com/$AMAZING_YOU/inertia.git ``` +You will also want to add `GOPATH` and `GOBIN` to your `PATH` to use any Inertia executables you install. Just add the following to your `.bashrc` or `.bash_profile`: + +```bash +export PATH="$PATH:$HOME/go/bin" +export GOPATH=$HOME/go +export GOBIN=$HOME/go/bin +``` + Inertia uses: - [dep](https://github.com/golang/dep) for managing Golang dependencies - [npm](https://www.npmjs.com) to manage dependencies for Inertia's React web app - [Docker](https://www.docker.com/community-edition) for various application functionalities and integration testing -Make sure all of the above are installed before running: +Make sure all of the above are installed (and that the Docker daemon is online) before running: ```bash $> make deps # installs dependencies @@ -108,7 +116,7 @@ This code should only include the CLI user interface and code used to manage loc The Inertia client package manages all clientside functionality. The client codebase is in `./client/`. -To bootstrap servers, some bash scripting is often involved, but we'd like to avoid shipping bash scripts with our go binary - instead, we use [go-bindata](https://github.com/jteeuwen/go-bindata) to compile shell scripts into our Go executables. If you make changes to the bootstrapping shell scripts in `client/bootstrap/`, convert them to `Assets` by running: +To bootstrap servers, some bash scripting is often involved, but we'd like to avoid shipping bash scripts with our go binary - instead, we use [go-bindata](https://github.com/jteeuwen/go-bindata) to compile shell scripts into our Go executables. If you make changes to the bootstrapping shell scripts in `client/scripts/`, convert them to `Assets` by running: ```bash $> make bootstrap @@ -117,7 +125,7 @@ $> make bootstrap Then use your asset! ```go -shellScriptData, err := Asset("client/bootstrap/myshellscript.sh") +shellScriptData, err := Asset("client/scripts/myshellscript.sh") if err != nil { log.Fatal("No asset with that name") }