This is an automated email from the git hooks/post-receive script. New commit to branch bow-v2-go in repository bow. See https://gitlab.nuiton.org/chorem/bow.git commit e366ae7b41e3e13f6ca541e3d65dddff2dcb7633 Author: Benjamin <poussin@codelutin.com> Date: Wed Apr 8 23:55:10 2020 +0200 ajout de 2 tests pour modele plus tard ajout de l'auth jwt --- doc/implementation.md | 25 ++++++++++++++ go.mod | 1 + go.sum | 2 ++ pkg/http/router.go | 74 ++++++++++++++++++++++++++++++++++++++++ pkg/http/systemResource.go | 2 +- pkg/http/systemResource_test.go | 39 +++++++++++++++++++++ pkg/http/userResource.go | 63 +++++++++++++++++++++++++++++++++- pkg/repository/userRepository.go | 55 ++++++++++++++++++++++------- pkg/utils/const.go | 18 ++++++++++ pkg/utils/jwt.go | 55 +++++++++++++++++++++++++++++ pkg/utils/uuid_test.go | 17 +++++++++ 11 files changed, 337 insertions(+), 14 deletions(-) diff --git a/doc/implementation.md b/doc/implementation.md index 6d7ade8..b8f3fcb 100644 --- a/doc/implementation.md +++ b/doc/implementation.md @@ -1,3 +1,28 @@ +TODO: table d'historique d'authentification + +== Creation d'un compte + +La création d'un compte, ne fait qu'envoyer un mail avec un lien contenant +un jeton jwt crypté ensuite avec "crypto/aes". Si la personne clique réellement +sur le lien cela valide la création du compte. + +Le jeton contient: +- le mail +- le mot de passe + +== Droit dans Postgresql + +L'utilisateur ayant créé la base n'est pas utilisé pour le login applicatif + +Il y a un utilisateur (nobody) qui n'a droit sur aucun objet met qui peut endoser le role +de tous les autres. C'est cet utilisateur qui est utilisé dans l'applicatif. +Si la personne qui arrive n'a pas de token (pas authentifiée) alors elle reste +avec cet utilisateur sans droit. + +Les seuls droits qu'il a est: +- création d'un nouvelle utilisateur +- lecture des mots de passe + == Optimisation Posggresql Les tableaux (ex: text[]) utilise des index gin, il faut donc utiliser seulement diff --git a/go.mod b/go.mod index 68f24cd..3e4a88c 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module gitlab.chorem.org/chorem/bow go 1.14 require ( + github.com/brianvoe/sjwt v0.5.1 github.com/gorilla/mux v1.7.4 github.com/jackc/pgtype v1.3.0 github.com/jackc/pgx/v4 v4.6.0 diff --git a/go.sum b/go.sum index 1e338b6..fd87830 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/brianvoe/sjwt v0.5.1 h1:OKwnUrrVMnP81N9S5+ylgZECUEwW4Uw6W6J0FgcIZfw= +github.com/brianvoe/sjwt v0.5.1/go.mod h1:GsyrNi4zWvWAcsVGNNMULQ8SfDMmJ2ybzAyPjNQJJL8= github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= diff --git a/pkg/http/router.go b/pkg/http/router.go index 5fb2ca5..9ecb3a1 100644 --- a/pkg/http/router.go +++ b/pkg/http/router.go @@ -1,10 +1,17 @@ package http import ( + "context" + "fmt" + "io" "log" "net/http" + "strings" "time" + "gitlab.chorem.org/chorem/bow/pkg/repository" + "gitlab.chorem.org/chorem/bow/pkg/utils" + "github.com/gorilla/mux" ) @@ -13,11 +20,16 @@ Start web server */ func Start(addr string) { router := mux.NewRouter() + router.Use(authentication) s := router.PathPrefix("/api/v1").Subrouter() s.HandleFunc("/system/liveness", isAlive).Methods("GET") s.HandleFunc("/users", createUser).Methods("POST") + s.HandleFunc("/users/auth", createAuth).Methods("POST") + s.HandleFunc("/users/{id}", getUser).Methods("GET") s.HandleFunc("/users/{id}", deleteUser).Methods("DELETE") + s.HandleFunc("/users/{id}/auth", createAuth).Methods("POST") + s.HandleFunc("/users/{id}/auth", deleteAuth).Methods("DELETE") s.HandleFunc("/users/{id}/password", updateUserPassword).Methods("PUT") s.HandleFunc("/users/{id}/token", addUserToken).Methods("POST") s.HandleFunc("/users/{id}/unconfirmedemails", addUserUnconfirmedEmail).Methods("POST") @@ -44,3 +56,65 @@ func Start(addr string) { log.Fatal(srv.ListenAndServe()) } + +func authentication(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + isAppToken := false + + // 1 as query param + query := r.URL.Query() + token := query.Get(utils.TokenName) + + // 2 as bow header + if token == "" { + token = r.Header.Get(utils.TokenHeaderName) + } else { + isAppToken = true + } + + // 3 as Authorization header + if token == "" { + tokenBearer := r.Header.Get("Authorization") + if tokenBearer != "" { + splitted := strings.Split(tokenBearer, " ") //The token normally comes in format `Bearer {token-body}`, we check if the retrieved token matched this requirement + if len(splitted) != 2 { + w.WriteHeader(http.StatusForbidden) + w.Header().Add("Content-Type", "application/json") + io.WriteString(w, `{"message": "Invalid/Malformed auth token header Authorization"}`) + return + } + token = splitted[1] + } + } + + // 4 as cookie + if token == "" { + cookie, err := r.Cookie(utils.TokenName) + if err == nil { + token = cookie.Value + } + } + + userID := utils.JwtVerify(token) + + if userID == "" && isAppToken { + tmp, err := repository.UserIDFromToken(token) + if err != nil { + http.Error(w, fmt.Sprintf("%s", err), 500) + return + } + userID = tmp + } + + if userID == "" { + userID = "nobody" + } + + log.Printf("User is '%s'\n", userID) + + ctx := context.WithValue(r.Context(), utils.UserID, userID) + r = r.WithContext(ctx) + next.ServeHTTP(w, r) + }) + +} diff --git a/pkg/http/systemResource.go b/pkg/http/systemResource.go index c8afefd..482e97f 100644 --- a/pkg/http/systemResource.go +++ b/pkg/http/systemResource.go @@ -8,5 +8,5 @@ import ( func isAlive(w http.ResponseWriter, r *http.Request) { log.Println("http liveness") - json.NewEncoder(w).Encode(map[string]bool{"ok": true}) + json.NewEncoder(w).Encode(map[string]bool{"alive": true}) } diff --git a/pkg/http/systemResource_test.go b/pkg/http/systemResource_test.go new file mode 100644 index 0000000..571a7b1 --- /dev/null +++ b/pkg/http/systemResource_test.go @@ -0,0 +1,39 @@ +package http + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestHealthCheckHandler(t *testing.T) { + // Create a request to pass to our handler. We don't have any query parameters for now, so we'll + // pass 'nil' as the third parameter. + req, err := http.NewRequest("GET", "/api/v1/system/liveness", nil) + if err != nil { + t.Fatal(err) + } + + // We create a ResponseRecorder (which satisfies http.ResponseWriter) to record the response. + rr := httptest.NewRecorder() + handler := http.HandlerFunc(isAlive) + + // Our handlers satisfy http.Handler, so we can call their ServeHTTP method + // directly and pass in our Request and ResponseRecorder. + handler.ServeHTTP(rr, req) + + // Check the status code is what we expect. + if status := rr.Code; status != http.StatusOK { + t.Errorf("handler returned wrong status code: got %v want %v", + status, http.StatusOK) + } + + // Check the response body is what we expect. + expected := `{"alive":true}` + result := strings.TrimSpace(rr.Body.String()) + if result != expected { + t.Errorf("handler returned unexpected body: got '%v' want '%v'", + result, expected) + } +} diff --git a/pkg/http/userResource.go b/pkg/http/userResource.go index 851862e..d8fdb00 100644 --- a/pkg/http/userResource.go +++ b/pkg/http/userResource.go @@ -3,14 +3,75 @@ package http import ( "encoding/json" "fmt" + "io" "log" "net/http" "github.com/gorilla/mux" "gitlab.chorem.org/chorem/bow/pkg/model" "gitlab.chorem.org/chorem/bow/pkg/repository" + "gitlab.chorem.org/chorem/bow/pkg/utils" ) +/* +DeleteAuth remove cookie authentication +*/ +func deleteAuth(w http.ResponseWriter, r *http.Request) { + cookie := http.Cookie{Name: utils.TokenName, Value: "", HttpOnly: true, MaxAge: 0} + http.SetCookie(w, &cookie) +} + +/* +CreateAuth create JWT token and set header, cookie, body +body: {"token": "xxxx"} +*/ +func createAuth(w http.ResponseWriter, r *http.Request) { + id := mux.Vars(r)["id"] + + var data map[string]string + err := json.NewDecoder(r.Body).Decode(&data) + if err != nil { + http.Error(w, fmt.Sprintf("%s", err), 400) + return + } + + if checkLogin(id, data["email"], data["password"]) { + token := utils.JwtGenerate(id) + w.Header().Add(utils.TokenHeaderName, token) + + cookie := http.Cookie{Name: utils.TokenName, Value: token, HttpOnly: true} + http.SetCookie(w, &cookie) + + w.Header().Add("Content-Type", "application/json") + io.WriteString(w, fmt.Sprintf(`{"token": "%s"}`, token)) + } else { + http.Error(w, "Bad id or password", 403) + } +} + +func checkLogin(id string, email string, password string) bool { + if id != "" && repository.CheckUserPasswordForID(id, password) { + return true + } + + _, err := repository.CheckUserPasswordForEmail(email, password) + return err == nil +} + +/* +GetUser return all information on user (info, config, auth) +*/ +func getUser(w http.ResponseWriter, r *http.Request) { + id := mux.Vars(r)["id"] + json, err := repository.UserJSON(id) + if err != nil { + http.Error(w, fmt.Sprintf("%s", err), 500) + } + + w.Header().Add("Content-Type", "application/json") + io.WriteString(w, json) +} + /* createUser body: {"login": "toto", "password": "xxxx"} @@ -47,7 +108,7 @@ func deleteUser(w http.ResponseWriter, r *http.Request) { } /* -createUser +updateUserPassword body: {"password": "xxxx", "oldPassword": "yyyy"} */ func updateUserPassword(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/repository/userRepository.go b/pkg/repository/userRepository.go index 1622125..ce45986 100644 --- a/pkg/repository/userRepository.go +++ b/pkg/repository/userRepository.go @@ -14,11 +14,19 @@ import ( ) /* -UserJSON retourne l'utilisateur au format json +UserJSON return user in json format +all field are send except: +- password +- email confirmation token */ func UserJSON(id string) (string, error) { var pgjson pgtype.JSON - row := db.QueryRow(context.Background(), `WITH __all AS (select * from bowuser where id=$1) SELECT json_agg(__all.*) as j FROM __all`, id) + allFields := "id, creationdate, updatedate, login, tokens, emails, authenticationinfo, autoscreenshot, autofavicon, maxtagincloud, maxresult, actions" + row := db.QueryRow(context.Background(), fmt.Sprintf(` + WITH tmp AS (select %[1]s, (jsonb_array_elements(unconfirmedemails)::jsonb)->'email' as unconfirmedemails from bowuser where id=$1), + __all AS (select %[1]s, array_agg(unconfirmedemails) as unconfirmedemails from tmp group by %[1]s) + SELECT json_agg(__all.*) as j + FROM __all`, allFields), id) err := row.Scan(&pgjson) if err != nil { return "", err @@ -31,9 +39,39 @@ func UserJSON(id string) (string, error) { } /* -UserJSON retourne l'id de l'utilisateur (string) si le mot de passe est le bon et que l'utilisateur est retrouve +UserIdFromToken get user id by application token */ -func checkPassword(loginOrEmail string, password string) (string, error) { +func UserIDFromToken(token string) (string, error) { + + tokenJSON := fmt.Sprintf(`{"token": "%s"}`, token) + var id string + row := db.QueryRow(context.Background(), ` + select id from bowuser b + where exists (select * from jsonb_array_elements(tokens) as x + where x @> $1);`, tokenJSON) + err := row.Scan(&id) + + return id, err +} + +/* +CheckUserPasswordForID check password for id +*/ +func CheckUserPasswordForID(id string, password string) bool { + var hash string + row := db.QueryRow(context.Background(), `select password from bowuser where id=$1`, id) + err := row.Scan(&hash) + if err != nil { + return false + } + + return utils.CheckPassword(password, hash) +} + +/* +CheckUserPasswordForEmail retourne l'id de l'utilisateur (string) si le mot de passe est le bon et que l'utilisateur est retrouve +*/ +func CheckUserPasswordForEmail(loginOrEmail string, password string) (string, error) { var uuid pgtype.UUID var hash string row := db.QueryRow(context.Background(), `select id, password from bowuser where login=$1 or emails @> $2::text[]`, loginOrEmail, fmt.Sprintf(`{"%s"}`, loginOrEmail)) @@ -102,14 +140,7 @@ func DeleteUser(id string) error { UpdateUserPassword update user password, if old password match, or if force is true */ func UpdateUserPassword(id string, password string, oldPassword string, force bool) error { - var hash string - row := db.QueryRow(context.Background(), `select password from bowuser where id=$1`, id) - err := row.Scan(&hash) - if err != nil { - return err - } - - if force || utils.CheckPassword(oldPassword, hash) { + if force || CheckUserPasswordForID(id, oldPassword) { hash, err := utils.HashPassword(password) diff --git a/pkg/utils/const.go b/pkg/utils/const.go new file mode 100644 index 0000000..ce7ab40 --- /dev/null +++ b/pkg/utils/const.go @@ -0,0 +1,18 @@ +package utils + +type httpRequestConxtKey string + +/* +UserID constant pour stocker le userID dans le context de la requete http +*/ +const UserID = httpRequestConxtKey("userID") + +/* +TokenName le nom utiliser dans le cookie et le parameter de query +*/ +const TokenName = "bow-token" + +/* +TokenHeaderName le nom utiliser pour mettre dans le header de requete http (fallback Authorization) +*/ +const TokenHeaderName = "x-" + TokenName \ No newline at end of file diff --git a/pkg/utils/jwt.go b/pkg/utils/jwt.go new file mode 100644 index 0000000..4283b84 --- /dev/null +++ b/pkg/utils/jwt.go @@ -0,0 +1,55 @@ +package utils + +import ( + "github.com/brianvoe/sjwt" +) + +var secretKey []byte + +/* +JwtInit JWT secret key +*/ +func JwtInit(key []byte) { + secretKey = key +} + +/* +JwtVerify check token and if valide return user id +*/ +func JwtVerify(token string) string { + // check signature + verified := sjwt.Verify(token, secretKey) + if !verified { + return "" + } + + // read token + claims, err := sjwt.Parse(token) + if err != nil { + return "" + } + + // check date + err = claims.Validate() + if err != nil { + return "" + } + + id, err := claims.GetStr("id") + if err != nil { + return "" + } + + return id +} + +/* +JwtGenerate generate JWT token from information in parameter +*/ +func JwtGenerate(id string) string { + claims := sjwt.New() + claims.Set("id", id) + jwt := claims.Generate(secretKey) + + return jwt +} diff --git a/pkg/utils/uuid_test.go b/pkg/utils/uuid_test.go new file mode 100644 index 0000000..c36ab3b --- /dev/null +++ b/pkg/utils/uuid_test.go @@ -0,0 +1,17 @@ +package utils + +import ( + "testing" + "regexp" +) + +func TestGenUUID(t *testing.T) { + uuid, err := GenUUID() + if err != nil { + t.Error("TestGenUUID", err) + } + matched, err := regexp.MatchString(`^\w{8}-\w{4}-\w{4}-\w{4}-\w{12}$`, uuid) + if !matched || err != nil { + t.Error("TestGenUUID", matched, err) + } +} -- To stop receiving notification emails like this one, please contact chorem.org SCM administrator <admin+scm@chorem.org>.