From 5e81b17e2862c806c0a21b7552b55ea4452f1176 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Delattre?= Date: Sun, 18 Feb 2018 16:57:16 +0100 Subject: [PATCH] Add user token validation --- backend/auth/auth.go | 59 +++++++++++++++--- backend/auth/middleware.go | 23 +++---- backend/main.go | 2 +- backend/routes.go | 3 + backend/tokens/tokens.go | 96 +++++++++++++++++++++++++++++ backend/users/handlers.go | 94 +++++++++++++++++++++------- backend/users/users.go | 63 ------------------- migrations/0006_user_token.down.sql | 1 + migrations/0006_user_token.up.sql | 12 ++++ 9 files changed, 248 insertions(+), 105 deletions(-) create mode 100644 backend/tokens/tokens.go create mode 100644 migrations/0006_user_token.down.sql create mode 100644 migrations/0006_user_token.up.sql diff --git a/backend/auth/auth.go b/backend/auth/auth.go index 0fefd82..6b3c331 100644 --- a/backend/auth/auth.go +++ b/backend/auth/auth.go @@ -4,8 +4,11 @@ import ( "fmt" "net/http" "strings" + "time" jwt "github.com/dgrijalva/jwt-go" + "github.com/jmoiron/sqlx" + "gitlab.quimbo.fr/odwrtw/canape/backend/tokens" "golang.org/x/crypto/bcrypt" ) @@ -17,6 +20,8 @@ var ( ErrInvalidSecret = fmt.Errorf("Invalid secret") // ErrInvalidToken returned when the jwt token is invalid ErrInvalidToken = fmt.Errorf("Invalid token") + // ErrUnauthenticatedUser returned when a user is not authenticated + ErrUnauthenticatedUser = fmt.Errorf("Unauthenticated user") ) // UserBackend interface for user backend @@ -35,6 +40,7 @@ type User interface { // Authorizer handle sesssion type Authorizer struct { + db *sqlx.DB Params } @@ -48,8 +54,9 @@ type Params struct { // New Authorizer pepper is like a salt but not stored in database, // cost is the bcrypt cost for hashing the password -func New(params Params) *Authorizer { +func New(db *sqlx.DB, params Params) *Authorizer { return &Authorizer{ + db: db, Params: params, } } @@ -64,7 +71,7 @@ func (a *Authorizer) GenHash(password string) (string, error) { } // Login cheks password and creates a jwt token -func (a *Authorizer) Login(rw http.ResponseWriter, req *http.Request, username, password string) (User, error) { +func (a *Authorizer) Login(username, password string) (*tokens.Token, error) { u, err := a.Backend.GetUser(username) if err != nil { return nil, err @@ -76,7 +83,34 @@ func (a *Authorizer) Login(rw http.ResponseWriter, req *http.Request, username, return nil, ErrInvalidPassword } - return u, nil + // Create a jwt token + jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + // Not before + "nbf": time.Now().Unix(), + // Issued at + "iat": time.Now().Unix(), + // Private claims + "username": u.GetName(), + "isAdmin": u.IsAdmin(), + "isActivated": u.IsActivated(), + }) + + // Sign the token + ss, err := jwtToken.SignedString([]byte(a.Secret)) + if err != nil { + return nil, err + } + + t := &tokens.Token{ + Token: ss, + Username: u.GetName(), + } + + if err := t.Add(a.db); err != nil { + return nil, err + } + + return t, nil } // CurrentUser returns the logged in username from session and verifies the token @@ -92,14 +126,14 @@ func (a *Authorizer) CurrentUser(rw http.ResponseWriter, req *http.Request) (Use if tokenStr == "" { tokenCookie, err := req.Cookie("token") if err != nil || tokenCookie == nil { - return nil, nil + return nil, ErrUnauthenticatedUser } tokenStr = tokenCookie.Value } // No user logged if tokenStr == "" { - return nil, nil + return nil, ErrUnauthenticatedUser } // Keyfunc to decode the token @@ -111,13 +145,13 @@ func (a *Authorizer) CurrentUser(rw http.ResponseWriter, req *http.Request) (Use Username string `json:"username"` jwt.StandardClaims } - token, err := jwt.ParseWithClaims(tokenStr, &tokenClaims, keyfunc) + jwtToken, err := jwt.ParseWithClaims(tokenStr, &tokenClaims, keyfunc) if err != nil { return nil, err } // Check the token validity - if !token.Valid { + if !jwtToken.Valid { return nil, ErrInvalidToken } @@ -127,5 +161,16 @@ func (a *Authorizer) CurrentUser(rw http.ResponseWriter, req *http.Request) (Use return nil, err } + // Check the token in database + token, err := tokens.GetUserToken(a.db, u.GetName(), tokenStr) + if err != nil { + return nil, ErrInvalidToken + } + + token.UserAgent = req.UserAgent() + if err := token.Update(a.db); err != nil { + return nil, err + } + return u, nil } diff --git a/backend/auth/middleware.go b/backend/auth/middleware.go index 1ae088f..c4c9f35 100644 --- a/backend/auth/middleware.go +++ b/backend/auth/middleware.go @@ -25,21 +25,22 @@ func NewMiddleware(authorizer *Authorizer, log *logrus.Entry) *Middleware { func (m *Middleware) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { user, err := m.authorizer.CurrentUser(w, r) - if err != nil { + switch err { + case nil: + m.log.Debugf("setting user %s in the context", user.GetName()) + ctxKey := authContextKey("auth.user") + ctx := context.WithValue(r.Context(), ctxKey, user) + r = r.WithContext(ctx) + case ErrUnauthenticatedUser: + m.log.Debugf("unauthenticated user") + case ErrInvalidToken: + m.log.Debugf("user has an invalid token") + default: + m.log.Error(err) http.Error(w, err.Error(), http.StatusUnauthorized) return } - if user != nil { - m.log.Debugf("setting user %s in the context", user.GetName()) - } else { - m.log.Debugf("got a nil user in the context") - } - - ctxKey := authContextKey("auth.user") - ctx := context.WithValue(r.Context(), ctxKey, user) - r = r.WithContext(ctx) - next(w, r) } diff --git a/backend/main.go b/backend/main.go index 9e1ab1c..19b0416 100644 --- a/backend/main.go +++ b/backend/main.go @@ -51,7 +51,7 @@ func main() { Cost: cf.Authorizer.Cost, Secret: cf.Authorizer.Secret, } - authorizer := auth.New(authParams) + authorizer := auth.New(db, authParams) // Create web environment needed by the app env := web.NewEnv(web.EnvParams{ diff --git a/backend/routes.go b/backend/routes.go index e972443..303a210 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -16,6 +16,9 @@ func setupRoutes(env *web.Env) { env.Handle("/users/signup", users.SignupPOSTHandler).Methods("POST") env.Handle("/users/details", users.DetailsHandler).WithRole(users.UserRole).Methods("GET") env.Handle("/users/edit", users.EditHandler).WithRole(users.UserRole).Methods("POST") + env.Handle("/users/tokens", users.GetTokensHandler).WithRole(users.UserRole).Methods("GET") + env.Handle("/users/tokens/{token}", users.EditTokenHandler).WithRole(users.UserRole).Methods("POST") + env.Handle("/users/tokens/{token}", users.DeleteTokenHandler).WithRole(users.UserRole).Methods("DELETE") // Movies routes env.Handle("/movies/polochon", movies.PolochonMoviesHandler).WithRole(users.UserRole).Methods("GET") diff --git a/backend/tokens/tokens.go b/backend/tokens/tokens.go new file mode 100644 index 0000000..d25f24a --- /dev/null +++ b/backend/tokens/tokens.go @@ -0,0 +1,96 @@ +package tokens + +import ( + "database/sql" + "errors" + "fmt" + + "github.com/jmoiron/sqlx" + "gitlab.quimbo.fr/odwrtw/canape/backend/sqly" +) + +const ( + addTokenQuery = `INSERT INTO tokens (token, username) VALUES ($1, $2);` + getTokenQuery = `SELECT * FROM tokens WHERE token=$1;` + getUserTokenQuery = `SELECT * FROM tokens WHERE username=$1 and token=$2;` + getUserTokensQuery = `SELECT * FROM tokens WHERE username=$1;` + deleteTokenQuery = `DELETE FROM tokens WHERE username=$1 AND token=$2;` + updateTokenQuery = `UPDATE tokens SET description=:description, user_agent=:user_agent WHERE token=:token RETURNING *;` +) + +// Custom errors +var ( + ErrTokenNotFound = errors.New("tokens: token not found") +) + +// Token represents a token +type Token struct { + sqly.BaseModel + Username string `db:"username" json:"username"` + Token string `db:"token" json:"token"` + Description string `db:"description" json:"description"` + UserAgent string `db:"user_agent" json:"user_agent"` +} + +// Add a token to the database +func (t *Token) Add(db *sqlx.DB) error { + _, err := db.Queryx(addTokenQuery, t.Token, t.Username) + if err != nil { + return err + } + return nil +} + +// GetUserToken returns the token linked to a user +func GetUserToken(db *sqlx.DB, username, token string) (*Token, error) { + t := &Token{} + err := db.QueryRowx(getUserTokenQuery, username, token).StructScan(t) + if err != nil { + if err == sql.ErrNoRows { + return nil, ErrTokenNotFound + } + return nil, err + } + return t, nil +} + +// DeleteToken deletes a token +func DeleteToken(db *sqlx.DB, username, token string) error { + r, err := db.Exec(deleteTokenQuery, username, token) + if err != nil { + return err + } + + count, err := r.RowsAffected() + if err != nil { + return err + } + + if count != 1 { + return fmt.Errorf("Unexpected number of row deleted: %d", count) + } + + return nil +} + +// GetUserTokens returns all tokens owned by the user +func GetUserTokens(db *sqlx.DB, username string) ([]*Token, error) { + tokens := []*Token{} + err := db.Select(&tokens, getUserTokensQuery, username) + if err != nil { + return nil, err + } + return tokens, nil +} + +// Update updates a token +func (t *Token) Update(db *sqlx.DB) error { + rows, err := db.NamedQuery(updateTokenQuery, t) + if err != nil { + return err + } + for rows.Next() { + rows.StructScan(t) + } + return nil +} diff --git a/backend/users/handlers.go b/backend/users/handlers.go index 2815ca2..f57b4c7 100644 --- a/backend/users/handlers.go +++ b/backend/users/handlers.go @@ -4,12 +4,11 @@ import ( "encoding/json" "fmt" "net/http" - "time" - - jwt "github.com/dgrijalva/jwt-go" + "github.com/gorilla/mux" "gitlab.quimbo.fr/odwrtw/canape/backend/auth" "gitlab.quimbo.fr/odwrtw/canape/backend/config" + "gitlab.quimbo.fr/odwrtw/canape/backend/tokens" "gitlab.quimbo.fr/odwrtw/canape/backend/web" ) @@ -65,37 +64,21 @@ func LoginPOSTHandler(e *web.Env, w http.ResponseWriter, r *http.Request) error return err } - user, err := e.Auth.Login(w, r, data.Username, data.Password) + token, err := e.Auth.Login(data.Username, data.Password) if err != nil { if err == auth.ErrInvalidPassword || err == ErrUnknownUser { return e.RenderError(w, fmt.Errorf("Error invalid user or password")) } return err } - e.Log.Debugf("logged %s", user.GetName()) + e.Log.Debugf("logged %s", token.Username) - // Create a jwt token - token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ - // Not before - "nbf": time.Now().Unix(), - // Issued at - "iat": time.Now().Unix(), - // Private claims - "username": user.GetName(), - "isAdmin": user.IsAdmin(), - "isActivated": user.IsActivated(), - }) - - // Sign the token - ss, err := token.SignedString([]byte(e.Auth.Secret)) - if err != nil { - return err - } + // TODO: add token expiration / keep me login stuff var out = struct { Token string `json:"token"` }{ - Token: ss, + Token: token.Token, } return e.RenderJSON(w, out) @@ -167,3 +150,68 @@ func EditHandler(e *web.Env, w http.ResponseWriter, r *http.Request) error { return e.RenderOK(w, "user updated") } + +// GetTokensHandler lists the tokens of a user +func GetTokensHandler(e *web.Env, w http.ResponseWriter, r *http.Request) error { + v := auth.GetCurrentUser(r, e.Log) + user, ok := v.(*User) + if !ok { + return fmt.Errorf("invalid user type") + } + + tokens, err := tokens.GetUserTokens(e.Database, user.Name) + if err != nil { + return err + } + + return e.RenderJSON(w, tokens) +} + +// DeleteTokenHandler helps delete a token +func DeleteTokenHandler(e *web.Env, w http.ResponseWriter, r *http.Request) error { + vars := mux.Vars(r) + token := vars["token"] + + v := auth.GetCurrentUser(r, e.Log) + user, ok := v.(*User) + if !ok { + return fmt.Errorf("invalid user type") + } + + if err := tokens.DeleteToken(e.Database, user.Name, token); err != nil { + return err + } + + return e.RenderOK(w, "token deleted") +} + +// EditTokenHandler helps delete a token +func EditTokenHandler(e *web.Env, w http.ResponseWriter, r *http.Request) error { + vars := mux.Vars(r) + token := vars["token"] + + v := auth.GetCurrentUser(r, e.Log) + user, ok := v.(*User) + if !ok { + return fmt.Errorf("invalid user type") + } + + t, err := tokens.GetUserToken(e.Database, user.Name, token) + if err != nil { + return err + } + + data := struct { + Description string `json:"description"` + }{} + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + return err + } + + t.Description = data.Description + if err := t.Update(e.Database); err != nil { + return err + } + + return e.RenderJSON(w, t) +} diff --git a/backend/users/users.go b/backend/users/users.go index 00ba547..4c817e8 100644 --- a/backend/users/users.go +++ b/backend/users/users.go @@ -11,7 +11,6 @@ import ( "github.com/odwrtw/papi" "gitlab.quimbo.fr/odwrtw/canape/backend/config" - "gitlab.quimbo.fr/odwrtw/canape/backend/random" "gitlab.quimbo.fr/odwrtw/canape/backend/sqly" ) @@ -22,11 +21,6 @@ const ( updateUserQuery = `UPDATE users SET name=:name, hash=:hash, admin=:admin, activated=:activated, rawconfig=:rawconfig WHERE id=:id RETURNING *;` deleteUseQuery = `DELETE FROM users WHERE id=:id;` - addTokenQuery = `INSERT INTO tokens (value, user_id) VALUES ($1, $2) RETURNING id;` - getTokensQuery = `SELECT id, value FROM tokens WHERE user_id=$1;` - checkTokenQuery = `SELECT count(*) FROM tokens WHERE user_id=$1 AND value=$2;` - deleteTokenQuery = `DELETE FROM tokens WHERE user_id=$1 AND value=$2;` - getAllUsersQuery = `SELECT * FROM users order by created_at;` ) @@ -119,12 +113,6 @@ func (u *User) NewPapiClient() (*papi.Client, error) { return client, nil } -// Token represents a token -type Token struct { - sqly.BaseModel - Value string -} - // Get returns user with specified name func Get(q sqlx.Queryer, name string) (*User, error) { u := &User{} @@ -193,57 +181,6 @@ func (u *User) Delete(ex *sqlx.DB) error { return nil } -// GetTokens returns all tokens owned by the user -func (u *User) GetTokens(ex *sqlx.DB) ([]*Token, error) { - tokens := []*Token{} - err := ex.Select(&tokens, getTokensQuery, u.ID) - if err != nil { - return nil, err - } - return tokens, nil -} - -// NewToken generates a new token for the user -func (u *User) NewToken(ex *sqlx.DB) (*Token, error) { - t := &Token{ - Value: random.String(50), - } - - var id string - err := ex.QueryRowx(addTokenQuery, t.Value, u.ID).Scan(&id) - if err != nil { - return nil, err - } - t.ID = id - return t, nil -} - -// CheckToken checks if specified value exists in token's values for the user -func (u *User) CheckToken(ex *sqlx.DB, value string) (bool, error) { - var count int - err := ex.QueryRowx(checkTokenQuery, u.ID, value).Scan(&count) - if err != nil { - return false, err - } - if count != 1 { - return false, nil - } - return true, nil -} - -// DeleteToken delete token by value -func (u *User) DeleteToken(ex *sqlx.DB, value string) error { - r, err := ex.Exec(deleteTokenQuery, u.ID, value) - if err != nil { - return err - } - count, _ := r.RowsAffected() - if count != 1 { - return fmt.Errorf("Unexpected number of row deleted: %d", count) - } - return nil -} - // GetHash implements auth.User interface func (u *User) GetHash() string { return u.Hash diff --git a/migrations/0006_user_token.down.sql b/migrations/0006_user_token.down.sql new file mode 100644 index 0000000..7da0f43 --- /dev/null +++ b/migrations/0006_user_token.down.sql @@ -0,0 +1 @@ +DROP TABLE tokens; diff --git a/migrations/0006_user_token.up.sql b/migrations/0006_user_token.up.sql new file mode 100644 index 0000000..e26c276 --- /dev/null +++ b/migrations/0006_user_token.up.sql @@ -0,0 +1,12 @@ +CREATE TABLE tokens ( + token text PRIMARY KEY NOT NULL UNIQUE, + username text NOT NULL REFERENCES users(name), + description text NOT NULL DEFAULT '-', + user_agent text NOT NULL DEFAULT '-', + ip inet NOT NULL, + last_used timestamp with time zone NOT NULL DEFAULT current_timestamp, + LIKE base INCLUDING DEFAULTS +); + +CREATE INDEX ON tokens (username); +CREATE TRIGGER update_tokens_updated_at BEFORE UPDATE ON tokens FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column();