Add user token validation

This commit is contained in:
Grégoire Delattre 2018-02-18 16:57:16 +01:00
parent 3375481b21
commit 5e81b17e28
9 changed files with 248 additions and 105 deletions

View File

@ -4,8 +4,11 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
"time"
jwt "github.com/dgrijalva/jwt-go" jwt "github.com/dgrijalva/jwt-go"
"github.com/jmoiron/sqlx"
"gitlab.quimbo.fr/odwrtw/canape/backend/tokens"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@ -17,6 +20,8 @@ var (
ErrInvalidSecret = fmt.Errorf("Invalid secret") ErrInvalidSecret = fmt.Errorf("Invalid secret")
// ErrInvalidToken returned when the jwt token is invalid // ErrInvalidToken returned when the jwt token is invalid
ErrInvalidToken = fmt.Errorf("Invalid token") ErrInvalidToken = fmt.Errorf("Invalid token")
// ErrUnauthenticatedUser returned when a user is not authenticated
ErrUnauthenticatedUser = fmt.Errorf("Unauthenticated user")
) )
// UserBackend interface for user backend // UserBackend interface for user backend
@ -35,6 +40,7 @@ type User interface {
// Authorizer handle sesssion // Authorizer handle sesssion
type Authorizer struct { type Authorizer struct {
db *sqlx.DB
Params Params
} }
@ -48,8 +54,9 @@ type Params struct {
// New Authorizer pepper is like a salt but not stored in database, // New Authorizer pepper is like a salt but not stored in database,
// cost is the bcrypt cost for hashing the password // cost is the bcrypt cost for hashing the password
func New(params Params) *Authorizer { func New(db *sqlx.DB, params Params) *Authorizer {
return &Authorizer{ return &Authorizer{
db: db,
Params: params, Params: params,
} }
} }
@ -64,7 +71,7 @@ func (a *Authorizer) GenHash(password string) (string, error) {
} }
// Login cheks password and creates a jwt token // 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) u, err := a.Backend.GetUser(username)
if err != nil { if err != nil {
return nil, err return nil, err
@ -76,7 +83,34 @@ func (a *Authorizer) Login(rw http.ResponseWriter, req *http.Request, username,
return nil, ErrInvalidPassword 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 // 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 == "" { if tokenStr == "" {
tokenCookie, err := req.Cookie("token") tokenCookie, err := req.Cookie("token")
if err != nil || tokenCookie == nil { if err != nil || tokenCookie == nil {
return nil, nil return nil, ErrUnauthenticatedUser
} }
tokenStr = tokenCookie.Value tokenStr = tokenCookie.Value
} }
// No user logged // No user logged
if tokenStr == "" { if tokenStr == "" {
return nil, nil return nil, ErrUnauthenticatedUser
} }
// Keyfunc to decode the token // Keyfunc to decode the token
@ -111,13 +145,13 @@ func (a *Authorizer) CurrentUser(rw http.ResponseWriter, req *http.Request) (Use
Username string `json:"username"` Username string `json:"username"`
jwt.StandardClaims jwt.StandardClaims
} }
token, err := jwt.ParseWithClaims(tokenStr, &tokenClaims, keyfunc) jwtToken, err := jwt.ParseWithClaims(tokenStr, &tokenClaims, keyfunc)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Check the token validity // Check the token validity
if !token.Valid { if !jwtToken.Valid {
return nil, ErrInvalidToken return nil, ErrInvalidToken
} }
@ -127,5 +161,16 @@ func (a *Authorizer) CurrentUser(rw http.ResponseWriter, req *http.Request) (Use
return nil, err 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 return u, nil
} }

View File

@ -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) { func (m *Middleware) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
user, err := m.authorizer.CurrentUser(w, r) 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) http.Error(w, err.Error(), http.StatusUnauthorized)
return 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) next(w, r)
} }

View File

@ -51,7 +51,7 @@ func main() {
Cost: cf.Authorizer.Cost, Cost: cf.Authorizer.Cost,
Secret: cf.Authorizer.Secret, Secret: cf.Authorizer.Secret,
} }
authorizer := auth.New(authParams) authorizer := auth.New(db, authParams)
// Create web environment needed by the app // Create web environment needed by the app
env := web.NewEnv(web.EnvParams{ env := web.NewEnv(web.EnvParams{

View File

@ -16,6 +16,9 @@ func setupRoutes(env *web.Env) {
env.Handle("/users/signup", users.SignupPOSTHandler).Methods("POST") env.Handle("/users/signup", users.SignupPOSTHandler).Methods("POST")
env.Handle("/users/details", users.DetailsHandler).WithRole(users.UserRole).Methods("GET") 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/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 // Movies routes
env.Handle("/movies/polochon", movies.PolochonMoviesHandler).WithRole(users.UserRole).Methods("GET") env.Handle("/movies/polochon", movies.PolochonMoviesHandler).WithRole(users.UserRole).Methods("GET")

96
backend/tokens/tokens.go Normal file
View File

@ -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
}

View File

@ -4,12 +4,11 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "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/auth"
"gitlab.quimbo.fr/odwrtw/canape/backend/config" "gitlab.quimbo.fr/odwrtw/canape/backend/config"
"gitlab.quimbo.fr/odwrtw/canape/backend/tokens"
"gitlab.quimbo.fr/odwrtw/canape/backend/web" "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 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 != nil {
if err == auth.ErrInvalidPassword || err == ErrUnknownUser { if err == auth.ErrInvalidPassword || err == ErrUnknownUser {
return e.RenderError(w, fmt.Errorf("Error invalid user or password")) return e.RenderError(w, fmt.Errorf("Error invalid user or password"))
} }
return err return err
} }
e.Log.Debugf("logged %s", user.GetName()) e.Log.Debugf("logged %s", token.Username)
// Create a jwt token // TODO: add token expiration / keep me login stuff
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
}
var out = struct { var out = struct {
Token string `json:"token"` Token string `json:"token"`
}{ }{
Token: ss, Token: token.Token,
} }
return e.RenderJSON(w, out) 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") 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)
}

View File

@ -11,7 +11,6 @@ import (
"github.com/odwrtw/papi" "github.com/odwrtw/papi"
"gitlab.quimbo.fr/odwrtw/canape/backend/config" "gitlab.quimbo.fr/odwrtw/canape/backend/config"
"gitlab.quimbo.fr/odwrtw/canape/backend/random"
"gitlab.quimbo.fr/odwrtw/canape/backend/sqly" "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 *;` 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;` 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;` getAllUsersQuery = `SELECT * FROM users order by created_at;`
) )
@ -119,12 +113,6 @@ func (u *User) NewPapiClient() (*papi.Client, error) {
return client, nil return client, nil
} }
// Token represents a token
type Token struct {
sqly.BaseModel
Value string
}
// Get returns user with specified name // Get returns user with specified name
func Get(q sqlx.Queryer, name string) (*User, error) { func Get(q sqlx.Queryer, name string) (*User, error) {
u := &User{} u := &User{}
@ -193,57 +181,6 @@ func (u *User) Delete(ex *sqlx.DB) error {
return nil 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 // GetHash implements auth.User interface
func (u *User) GetHash() string { func (u *User) GetHash() string {
return u.Hash return u.Hash

View File

@ -0,0 +1 @@
DROP TABLE tokens;

View File

@ -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();