From 6393035e5554d95f388104f33f2e1fb8f6d1fdb9 Mon Sep 17 00:00:00 2001 From: Christoph Kluge Date: Thu, 3 Aug 2023 17:47:09 +0200 Subject: [PATCH 01/27] first iteraton of implementing ip-secured enpoint --- internal/api/rest.go | 79 ++++++++++++++++++++++++++++++++++++++++++++ pkg/schema/config.go | 3 ++ 2 files changed, 82 insertions(+) diff --git a/internal/api/rest.go b/internal/api/rest.go index c199bc2..c1f6fd1 100644 --- a/internal/api/rest.go +++ b/internal/api/rest.go @@ -20,6 +20,7 @@ import ( "time" "github.com/ClusterCockpit/cc-backend/internal/auth" + "github.com/ClusterCockpit/cc-backend/internal/config" "github.com/ClusterCockpit/cc-backend/internal/graph" "github.com/ClusterCockpit/cc-backend/internal/graph/model" "github.com/ClusterCockpit/cc-backend/internal/importer" @@ -75,6 +76,8 @@ func (api *RestApi) MountRoutes(r *mux.Router) { r.HandleFunc("/jobs/delete_job/", api.deleteJobByRequest).Methods(http.MethodDelete) r.HandleFunc("/jobs/delete_job/{id}", api.deleteJobById).Methods(http.MethodDelete) r.HandleFunc("/jobs/delete_job_before/{ts}", api.deleteJobBefore).Methods(http.MethodDelete) + r.HandleFunc("/secured/addProject/{id}/{project}", api.secureUpdateUser).Methods(http.MethodPost) + r.HandleFunc("/secured/addRole/{id}/{role}", api.secureUpdateUser).Methods(http.MethodPost) if api.Authentication != nil { r.HandleFunc("/jwt/", api.getJWT).Methods(http.MethodGet) @@ -103,6 +106,11 @@ type DeleteJobApiResponse struct { Message string `json:"msg"` } +// UpdateUserApiResponse model +type UpdateUserApiResponse struct { + Message string `json:"msg"` +} + // StopJobApiRequest model type StopJobApiRequest struct { // Stop Time of job as epoch @@ -1043,6 +1051,77 @@ func (api *RestApi) updateUser(rw http.ResponseWriter, r *http.Request) { } } +func (api *RestApi) secureUpdateUser(rw http.ResponseWriter, r *http.Request) { + if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleApi) { + handleError(fmt.Errorf("missing role: %v", auth.GetRoleString(auth.RoleApi)), http.StatusForbidden, rw) + return + } + + // If nothing declared in config: deny all request to this endpint + if config.Keys.ApiAllowedAddrs == nil || len(config.Keys.ApiAllowedAddrs) == 0 { + handleError(fmt.Errorf("denied by default policy!"), http.StatusForbidden, rw) + return + } + + // IP CHECK HERE (WIP) + // Probably better as private routine + IPAddress := r.Header.Get("X-Real-Ip") + if IPAddress == "" { + IPAddress = r.Header.Get("X-Forwarded-For") + } + if IPAddress == "" { + IPAddress = r.RemoteAddr + } + + // Also This + ipOk := false + for _, a := range config.Keys.ApiAllowedAddrs { + if a == IPAddress { + ipOk = true + } + } + + if IPAddress == "" || ipOk == false { + handleError(fmt.Errorf("unknown ip: %v", IPAddress), http.StatusForbidden, rw) + return + } + // IP CHECK END + + // Get Values + id := mux.Vars(r)["id"] + newproj := mux.Vars(r)["project"] + newrole := mux.Vars(r)["role"] + + // TODO: Handle anything but roles... + if newrole != "" { + if err := api.Authentication.AddRole(r.Context(), id, newrole); err != nil { + handleError(errors.New(err.Error()), http.StatusUnprocessableEntity, rw) + return + } + + rw.Header().Add("Content-Type", "application/json") + rw.WriteHeader(http.StatusOK) + json.NewEncoder(rw).Encode(UpdateUserApiResponse{ + Message: fmt.Sprintf("Successfully added role %s to %s", newrole, id), + }) + + } else if newproj != "" { + if err := api.Authentication.AddProject(r.Context(), id, newproj); err != nil { + handleError(errors.New(err.Error()), http.StatusUnprocessableEntity, rw) + return + } + + rw.Header().Add("Content-Type", "application/json") + rw.WriteHeader(http.StatusOK) + json.NewEncoder(rw).Encode(UpdateUserApiResponse{ + Message: fmt.Sprintf("Successfully added project %s to %s", newproj, id), + }) + + } else { + handleError(errors.New("Not Add [role|project]?"), http.StatusBadRequest, rw) + } +} + func (api *RestApi) updateConfiguration(rw http.ResponseWriter, r *http.Request) { rw.Header().Set("Content-Type", "text/plain") key, value := r.FormValue("key"), r.FormValue("value") diff --git a/pkg/schema/config.go b/pkg/schema/config.go index 9a88ea2..30515dd 100644 --- a/pkg/schema/config.go +++ b/pkg/schema/config.go @@ -69,6 +69,9 @@ type ProgramConfig struct { // Address where the http (or https) server will listen on (for example: 'localhost:80'). Addr string `json:"addr"` + // Addresses from which the /secured/* API endpoints can be reached + ApiAllowedAddrs []string `json:"apiAllowedAddrs"` + // Drop root permissions once .env was read and the port was taken. User string `json:"user"` Group string `json:"group"` From b8273a9b0265e4ff3a0fa65a557215dd161606c1 Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Fri, 11 Aug 2023 10:00:23 +0200 Subject: [PATCH 02/27] refactor auth module Restructure module Separate JWT auth variants Cleanup code Fixes #189 --- docs/dev-authentication.md | 53 +++-- internal/auth/auth.go | 308 +++++++++------------------- internal/auth/jwt.go | 212 +------------------ internal/auth/jwtCookieSession.go | 224 ++++++++++++++++++++ internal/auth/jwtSession.go | 103 ++++++++++ internal/auth/ldap.go | 9 +- internal/auth/local.go | 7 - internal/auth/roles.go | 165 +++++++++++++++ internal/repository/testdata/job.db | Bin 110592 -> 114688 bytes 9 files changed, 630 insertions(+), 451 deletions(-) create mode 100644 internal/auth/jwtCookieSession.go create mode 100644 internal/auth/jwtSession.go create mode 100644 internal/auth/roles.go diff --git a/docs/dev-authentication.md b/docs/dev-authentication.md index 4237a6a..d40bdb0 100644 --- a/docs/dev-authentication.md +++ b/docs/dev-authentication.md @@ -1,13 +1,15 @@ # Overview -The implementation of authentication is not easy to understand by just looking -at the code. The authentication is implemented in `internal/auth/`. In `auth.go` +The authentication is implemented in `internal/auth/`. In `auth.go` an interface is defined that any authentication provider must fulfill. It also acts as a dispatcher to delegate the calls to the available authentication providers. -The most important routine are: -* `CanLogin()` Check if the authentication method is supported for login attempt +Two authentication types are available: +* JWT authentication for the REST API that does not create a session cookie +* Session based authentication using a session cookie + +The most important routines in auth are: * `Login()` Handle POST request to login user and start a new session * `Auth()` Authenticate user and put User Object in context of the request @@ -30,10 +32,9 @@ secured.Use(func(next http.Handler) http.Handler { }) ``` -For non API routes a JWT token can be used to initiate an authenticated user +A JWT token can be used to initiate an authenticated user session. This can either happen by calling the login route with a token -provided in a header or query URL or via the `Auth()` method on first access -to a secured URL via a special cookie containing the JWT token. +provided in a header or via a special cookie containing the JWT token. For API routes the access is authenticated on every request using the JWT token and no session is initiated. @@ -43,12 +44,13 @@ The Login function (located in `auth.go`): * Extracts the user name and gets the user from the user database table. In case the user is not found the user object is set to nil. * Iterates over all authenticators and: - - Calls the `CanLogin` function which checks if the authentication method is - supported for this user and the user object is valid. - - Calls the `Login` function to authenticate the user. On success a valid user + - Calls its `CanLogin` function which checks if the authentication method is + supported for this user. + - Calls its `Login` function to authenticate the user. On success a valid user object is returned. - Creates a new session object, stores the user attributes in the session and saves the session. + - If the user does not yet exist in the database try to add the user - Starts the `onSuccess` http handler ## Local authenticator @@ -82,17 +84,13 @@ if err := l.Bind(userDn, r.FormValue("password")); err != nil { } ``` -## JWT authenticator +## JWT Session authenticator Login via JWT token will create a session without password. -For login the `X-Auth-Token` header is not supported. -This authenticator is applied if either user is not nil and auth source is -`AuthViaToken` or the Authorization header is present or the URL query key -login-token is present: +For login the `X-Auth-Token` header is not supported. This authenticator is +applied if the Authorization header is present: ``` -return (user != nil && user.AuthSource == AuthViaToken) || - r.Header.Get("Authorization") != "" || - r.URL.Query().Get("login-token") != "" + return r.Header.Get("Authorization") != "" ``` The Login function: @@ -108,6 +106,25 @@ The Login function: - In case user is not yet present add user to user database table with `AuthViaToken` AuthSource. * Return valid user object +## JWT Cookie Session authenticator + +Login via JWT cookie token will create a session without password. +It is first checked if the required configuration keys are set: +* `publicKeyCrossLogin` +* `TrustedExternalIssuer` +* `CookieName` + + This authenticator is applied if the configured cookie is present: +``` + jwtCookie, err := r.Cookie(cookieName) + + if err == nil && jwtCookie.Value != "" { + return true + } +``` + +The Login function: + # Auth The Auth function (located in `auth.go`): diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 9ac75be..8c1dca7 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -1,4 +1,4 @@ -// Copyright (C) 2022 NHR@FAU, University Erlangen-Nuremberg. +// Copyright (C) 2023 NHR@FAU, University Erlangen-Nuremberg. // All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. @@ -7,12 +7,11 @@ package auth import ( "context" "crypto/rand" + "database/sql" "encoding/base64" "errors" - "fmt" "net/http" "os" - "strings" "time" "github.com/ClusterCockpit/cc-backend/pkg/log" @@ -28,172 +27,25 @@ const ( AuthViaToken ) +type AuthType int + +const ( + AuthToken AuthType = iota + AuthSession +) + type User struct { Username string `json:"username"` Password string `json:"-"` Name string `json:"name"` Roles []string `json:"roles"` - AuthSource AuthSource `json:"via"` + AuthType AuthType `json:"authType"` + AuthSource AuthSource `json:"authSource"` Email string `json:"email"` Projects []string `json:"projects"` Expiration time.Time } -type Role int - -const ( - RoleAnonymous Role = iota - RoleApi - RoleUser - RoleManager - RoleSupport - RoleAdmin - RoleError -) - -func GetRoleString(roleInt Role) string { - return [6]string{"anonymous", "api", "user", "manager", "support", "admin"}[roleInt] -} - -func getRoleEnum(roleStr string) Role { - switch strings.ToLower(roleStr) { - case "admin": - return RoleAdmin - case "support": - return RoleSupport - case "manager": - return RoleManager - case "user": - return RoleUser - case "api": - return RoleApi - case "anonymous": - return RoleAnonymous - default: - return RoleError - } -} - -func isValidRole(role string) bool { - return getRoleEnum(role) != RoleError -} - -func (u *User) HasValidRole(role string) (hasRole bool, isValid bool) { - if isValidRole(role) { - for _, r := range u.Roles { - if r == role { - return true, true - } - } - return false, true - } - return false, false -} - -func (u *User) HasRole(role Role) bool { - for _, r := range u.Roles { - if r == GetRoleString(role) { - return true - } - } - return false -} - -// Role-Arrays are short: performance not impacted by nested loop -func (u *User) HasAnyRole(queryroles []Role) bool { - for _, ur := range u.Roles { - for _, qr := range queryroles { - if ur == GetRoleString(qr) { - return true - } - } - } - return false -} - -// Role-Arrays are short: performance not impacted by nested loop -func (u *User) HasAllRoles(queryroles []Role) bool { - target := len(queryroles) - matches := 0 - for _, ur := range u.Roles { - for _, qr := range queryroles { - if ur == GetRoleString(qr) { - matches += 1 - break - } - } - } - - if matches == target { - return true - } else { - return false - } -} - -// Role-Arrays are short: performance not impacted by nested loop -func (u *User) HasNotRoles(queryroles []Role) bool { - matches := 0 - for _, ur := range u.Roles { - for _, qr := range queryroles { - if ur == GetRoleString(qr) { - matches += 1 - break - } - } - } - - if matches == 0 { - return true - } else { - return false - } -} - -// Called by API endpoint '/roles/' from frontend: Only required for admin config -> Check Admin Role -func GetValidRoles(user *User) ([]string, error) { - var vals []string - if user.HasRole(RoleAdmin) { - for i := RoleApi; i < RoleError; i++ { - vals = append(vals, GetRoleString(i)) - } - return vals, nil - } - - return vals, fmt.Errorf("%s: only admins are allowed to fetch a list of roles", user.Username) -} - -// Called by routerConfig web.page setup in backend: Only requires known user -func GetValidRolesMap(user *User) (map[string]Role, error) { - named := make(map[string]Role) - if user.HasNotRoles([]Role{RoleAnonymous}) { - for i := RoleApi; i < RoleError; i++ { - named[GetRoleString(i)] = i - } - return named, nil - } - return named, fmt.Errorf("only known users are allowed to fetch a list of roles") -} - -// Find highest role -func (u *User) GetAuthLevel() Role { - if u.HasRole(RoleAdmin) { - return RoleAdmin - } else if u.HasRole(RoleSupport) { - return RoleSupport - } else if u.HasRole(RoleManager) { - return RoleManager - } else if u.HasRole(RoleUser) { - return RoleUser - } else if u.HasRole(RoleApi) { - return RoleApi - } else if u.HasRole(RoleAnonymous) { - return RoleAnonymous - } else { - return RoleError - } -} - func (u *User) HasProject(project string) bool { for _, p := range u.Projects { if p == project { @@ -216,7 +68,6 @@ type Authenticator interface { Init(auth *Authentication, config interface{}) error CanLogin(user *User, rw http.ResponseWriter, r *http.Request) bool Login(user *User, rw http.ResponseWriter, r *http.Request) (*User, error) - Auth(rw http.ResponseWriter, r *http.Request) (*User, error) } type ContextKey string @@ -234,6 +85,47 @@ type Authentication struct { LocalAuth *LocalAuthenticator } +func (auth *Authentication) AuthViaSession( + rw http.ResponseWriter, + r *http.Request) (*User, error) { + session, err := auth.sessionStore.Get(r, "session") + if err != nil { + log.Error("Error while getting session store") + return nil, err + } + + if session.IsNew { + return nil, nil + } + + var username string + var projects, roles []string + + if val, ok := session.Values["username"]; ok { + username, _ = val.(string) + } else { + return nil, errors.New("No key username in session") + } + if val, ok := session.Values["projects"]; ok { + projects, _ = val.([]string) + } else { + return nil, errors.New("No key projects in session") + } + if val, ok := session.Values["projects"]; ok { + roles, _ = val.([]string) + } else { + return nil, errors.New("No key roles in session") + } + + return &User{ + Username: username, + Projects: projects, + Roles: roles, + AuthType: AuthSession, + AuthSource: -1, + }, nil +} + func Init(db *sqlx.DB, configs map[string]interface{}) (*Authentication, error) { auth := &Authentication{} @@ -257,19 +149,11 @@ func Init(db *sqlx.DB, auth.sessionStore = sessions.NewCookieStore(bytes) } - auth.LocalAuth = &LocalAuthenticator{} - if err := auth.LocalAuth.Init(auth, nil); err != nil { - log.Error("Error while initializing authentication -> localAuth init failed") - return nil, err - } - auth.authenticators = append(auth.authenticators, auth.LocalAuth) - auth.JwtAuth = &JWTAuthenticator{} if err := auth.JwtAuth.Init(auth, configs["jwt"]); err != nil { log.Error("Error while initializing authentication -> jwtAuth init failed") return nil, err } - auth.authenticators = append(auth.authenticators, auth.JwtAuth) if config, ok := configs["ldap"]; ok { auth.LdapAuth = &LdapAuthenticator{} @@ -280,36 +164,30 @@ func Init(db *sqlx.DB, auth.authenticators = append(auth.authenticators, auth.LdapAuth) } + jwtSessionAuth := &JWTSessionAuthenticator{} + if err := jwtSessionAuth.Init(auth, configs["jwt"]); err != nil { + log.Warn("Error while initializing authentication -> jwtSessionAuth init failed") + } else { + auth.authenticators = append(auth.authenticators, jwtSessionAuth) + } + + jwtCookieSessionAuth := &JWTCookieSessionAuthenticator{} + if err := jwtSessionAuth.Init(auth, configs["jwt"]); err != nil { + log.Warn("Error while initializing authentication -> jwtCookieSessionAuth init failed") + } else { + auth.authenticators = append(auth.authenticators, jwtCookieSessionAuth) + } + + auth.LocalAuth = &LocalAuthenticator{} + if err := auth.LocalAuth.Init(auth, nil); err != nil { + log.Error("Error while initializing authentication -> localAuth init failed") + return nil, err + } + auth.authenticators = append(auth.authenticators, auth.LocalAuth) + return auth, nil } -func (auth *Authentication) AuthViaSession( - rw http.ResponseWriter, - r *http.Request) (*User, error) { - - session, err := auth.sessionStore.Get(r, "session") - if err != nil { - log.Error("Error while getting session store") - return nil, err - } - - if session.IsNew { - return nil, nil - } - - // TODO Check if keys are present in session? - username, _ := session.Values["username"].(string) - projects, _ := session.Values["projects"].([]string) - roles, _ := session.Values["roles"].([]string) - return &User{ - Username: username, - Projects: projects, - Roles: roles, - AuthSource: -1, - }, nil -} - -// Handle a POST request that should log the user in, starting a new session. func (auth *Authentication) Login( onsuccess http.Handler, onfailure func(rw http.ResponseWriter, r *http.Request, loginErr error)) http.Handler { @@ -317,18 +195,21 @@ func (auth *Authentication) Login( return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { err := errors.New("no authenticator applied") username := r.FormValue("username") - user := (*User)(nil) + dbUser := (*User)(nil) if username != "" { - user, _ = auth.GetUser(username) + dbUser, err = auth.GetUser(username) + if err != nil && err != sql.ErrNoRows { + log.Errorf("Error while loading user '%v'", username) + } } for _, authenticator := range auth.authenticators { - if !authenticator.CanLogin(user, rw, r) { + if !authenticator.CanLogin(dbUser, rw, r) { continue } - user, err = authenticator.Login(user, rw, r) + user, err := authenticator.Login(dbUser, rw, r) if err != nil { log.Warnf("user login failed: %s", err.Error()) onfailure(rw, r, err) @@ -354,6 +235,14 @@ func (auth *Authentication) Login( return } + if dbUser == nil { + if err := auth.AddUser(user); err != nil { + // TODO Add AuthSource + log.Errorf("Error while adding user '%v' to auth from XX", + user.Username) + } + } + log.Infof("login successfull: user: %#v (roles: %v, projects: %v)", user.Username, user.Roles, user.Projects) ctx := context.WithValue(r.Context(), ContextUserKey, user) onsuccess.ServeHTTP(rw, r.WithContext(ctx)) @@ -365,39 +254,34 @@ func (auth *Authentication) Login( }) } -// Authenticate the user and put a User object in the -// context of the request. If authentication fails, -// do not continue but send client to the login screen. func (auth *Authentication) Auth( onsuccess http.Handler, onfailure func(rw http.ResponseWriter, r *http.Request, authErr error)) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - for _, authenticator := range auth.authenticators { - user, err := authenticator.Auth(rw, r) + + user, err := auth.JwtAuth.AuthViaJWT(rw, r) + if user == nil { + user, err = auth.AuthViaSession(rw, r) if err != nil { log.Infof("authentication failed: %s", err.Error()) http.Error(rw, err.Error(), http.StatusUnauthorized) return } - if user == nil { - continue - } + } + if user != nil { ctx := context.WithValue(r.Context(), ContextUserKey, user) onsuccess.ServeHTTP(rw, r.WithContext(ctx)) return } - log.Debugf("authentication failed: %s", "no authenticator applied") - // http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - onfailure(rw, r, errors.New("unauthorized (login first or use a token)")) + log.Debug("authentication failed: no authenticator applied") + onfailure(rw, r, errors.New("unauthorized (please login first)")) }) } -// Clears the session cookie func (auth *Authentication) Logout(onsuccess http.Handler) http.Handler { - return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { session, err := auth.sessionStore.Get(r, "session") if err != nil { diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go index 8df7017..75a69b8 100644 --- a/internal/auth/jwt.go +++ b/internal/auth/jwt.go @@ -6,10 +6,8 @@ package auth import ( "crypto/ed25519" - "database/sql" "encoding/base64" "errors" - "fmt" "net/http" "os" "strings" @@ -23,17 +21,11 @@ import ( type JWTAuthenticator struct { auth *Authentication - publicKey ed25519.PublicKey - privateKey ed25519.PrivateKey - publicKeyCrossLogin ed25519.PublicKey // For accepting externally generated JWTs - - loginTokenKey []byte // HS256 key - - config *schema.JWTAuthConfig + publicKey ed25519.PublicKey + privateKey ed25519.PrivateKey + config *schema.JWTAuthConfig } -var _ Authenticator = (*JWTAuthenticator)(nil) - func (ja *JWTAuthenticator) Init(auth *Authentication, conf interface{}) error { ja.auth = auth @@ -57,128 +49,10 @@ func (ja *JWTAuthenticator) Init(auth *Authentication, conf interface{}) error { ja.privateKey = ed25519.PrivateKey(bytes) } - if pubKey = os.Getenv("CROSS_LOGIN_JWT_HS512_KEY"); pubKey != "" { - bytes, err := base64.StdEncoding.DecodeString(pubKey) - if err != nil { - log.Warn("Could not decode cross login JWT HS512 key") - return err - } - ja.loginTokenKey = bytes - } - - // Look for external public keys - pubKeyCrossLogin, keyFound := os.LookupEnv("CROSS_LOGIN_JWT_PUBLIC_KEY") - if keyFound && pubKeyCrossLogin != "" { - bytes, err := base64.StdEncoding.DecodeString(pubKeyCrossLogin) - if err != nil { - log.Warn("Could not decode cross login JWT public key") - return err - } - ja.publicKeyCrossLogin = ed25519.PublicKey(bytes) - - // Warn if other necessary settings are not configured - if ja.config != nil { - if ja.config.CookieName == "" { - log.Warn("cookieName for JWTs not configured (cross login via JWT cookie will fail)") - } - if !ja.config.ForceJWTValidationViaDatabase { - log.Warn("forceJWTValidationViaDatabase not set to true: CC will accept users and roles defined in JWTs regardless of its own database!") - } - if ja.config.TrustedExternalIssuer == "" { - log.Warn("trustedExternalIssuer for JWTs not configured (cross login via JWT cookie will fail)") - } - } else { - log.Warn("cookieName and trustedExternalIssuer for JWTs not configured (cross login via JWT cookie will fail)") - } - } else { - ja.publicKeyCrossLogin = nil - log.Debug("environment variable 'CROSS_LOGIN_JWT_PUBLIC_KEY' not set (cross login token based authentication will not work)") - } - return nil } -func (ja *JWTAuthenticator) CanLogin( - user *User, - rw http.ResponseWriter, - r *http.Request) bool { - - return (user != nil && user.AuthSource == AuthViaToken) || - r.Header.Get("Authorization") != "" || - r.URL.Query().Get("login-token") != "" -} - -func (ja *JWTAuthenticator) Login( - user *User, - rw http.ResponseWriter, - r *http.Request) (*User, error) { - - rawtoken := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") - if rawtoken == "" { - rawtoken = r.URL.Query().Get("login-token") - } - - token, err := jwt.Parse(rawtoken, func(t *jwt.Token) (interface{}, error) { - if t.Method == jwt.SigningMethodEdDSA { - return ja.publicKey, nil - } - if t.Method == jwt.SigningMethodHS256 || t.Method == jwt.SigningMethodHS512 { - return ja.loginTokenKey, nil - } - return nil, fmt.Errorf("AUTH/JWT > unkown signing method for login token: %s (known: HS256, HS512, EdDSA)", t.Method.Alg()) - }) - if err != nil { - log.Warn("Error while parsing jwt token") - return nil, err - } - - if err = token.Claims.Valid(); err != nil { - log.Warn("jwt token claims are not valid") - return nil, err - } - - claims := token.Claims.(jwt.MapClaims) - sub, _ := claims["sub"].(string) - exp, _ := claims["exp"].(float64) - var roles []string - if rawroles, ok := claims["roles"].([]interface{}); ok { - for _, rr := range rawroles { - if r, ok := rr.(string); ok { - if isValidRole(r) { - roles = append(roles, r) - } - } - } - } - if rawrole, ok := claims["roles"].(string); ok { - if isValidRole(rawrole) { - roles = append(roles, rawrole) - } - } - - if user == nil { - user, err = ja.auth.GetUser(sub) - if err != nil && err != sql.ErrNoRows { - log.Errorf("Error while loading user '%v'", sub) - return nil, err - } else if user == nil { - user = &User{ - Username: sub, - Roles: roles, - AuthSource: AuthViaToken, - } - if err := ja.auth.AddUser(user); err != nil { - log.Errorf("Error while adding user '%v' to auth from token", user.Username) - return nil, err - } - } - } - - user.Expiration = time.Unix(int64(exp), 0) - return user, nil -} - -func (ja *JWTAuthenticator) Auth( +func (ja *JWTAuthenticator) AuthViaJWT( rw http.ResponseWriter, r *http.Request) (*User, error) { @@ -188,59 +62,17 @@ func (ja *JWTAuthenticator) Auth( rawtoken = strings.TrimPrefix(rawtoken, "Bearer ") } - // If no auth header was found, check for a certain cookie containing a JWT - cookieName := "" - cookieFound := false - if ja.config != nil && ja.config.CookieName != "" { - cookieName = ja.config.CookieName - } - - // Try to read the JWT cookie - if rawtoken == "" && cookieName != "" { - jwtCookie, err := r.Cookie(cookieName) - - if err == nil && jwtCookie.Value != "" { - rawtoken = jwtCookie.Value - cookieFound = true - } - } - - // Because a user can also log in via a token, the - // session cookie must be checked here as well: - if rawtoken == "" { - return ja.auth.AuthViaSession(rw, r) - } - - // Try to parse JWT token, err := jwt.Parse(rawtoken, func(t *jwt.Token) (interface{}, error) { if t.Method != jwt.SigningMethodEdDSA { return nil, errors.New("only Ed25519/EdDSA supported") } - // Is there more than one public key? - if ja.publicKeyCrossLogin != nil && - ja.config != nil && - ja.config.TrustedExternalIssuer != "" { - - // Determine whether to use the external public key - unvalidatedIssuer, success := t.Claims.(jwt.MapClaims)["iss"].(string) - if success && unvalidatedIssuer == ja.config.TrustedExternalIssuer { - // The (unvalidated) issuer seems to be the expected one, - // use public cross login key from config - return ja.publicKeyCrossLogin, nil - } - } - - // No cross login key configured or issuer not expected - // Try own key return ja.publicKey, nil }) if err != nil { - log.Warn("Error while parsing token") + log.Warn("Error while parsing JWT token") return nil, err } - - // Check token validity if err := token.Claims.Valid(); err != nil { log.Warn("jwt token claims are not valid") return nil, err @@ -261,7 +93,6 @@ func (ja *JWTAuthenticator) Auth( log.Warn("Could not find user from JWT in internal database.") return nil, errors.New("unknown user") } - // Take user roles from database instead of trusting the JWT roles = user.Roles } else { @@ -275,41 +106,10 @@ func (ja *JWTAuthenticator) Auth( } } - if cookieFound { - // Create a session so that we no longer need the JTW Cookie - session, err := ja.auth.sessionStore.New(r, "session") - if err != nil { - log.Errorf("session creation failed: %s", err.Error()) - http.Error(rw, err.Error(), http.StatusInternalServerError) - return nil, err - } - - if ja.auth.SessionMaxAge != 0 { - session.Options.MaxAge = int(ja.auth.SessionMaxAge.Seconds()) - } - session.Values["username"] = sub - session.Values["roles"] = roles - - if err := ja.auth.sessionStore.Save(r, rw, session); err != nil { - log.Warnf("session save failed: %s", err.Error()) - http.Error(rw, err.Error(), http.StatusInternalServerError) - return nil, err - } - - // (Ask browser to) Delete JWT cookie - deletedCookie := &http.Cookie{ - Name: cookieName, - Value: "", - Path: "/", - MaxAge: -1, - HttpOnly: true, - } - http.SetCookie(rw, deletedCookie) - } - return &User{ Username: sub, Roles: roles, + AuthType: AuthSession, AuthSource: AuthViaToken, }, nil } diff --git a/internal/auth/jwtCookieSession.go b/internal/auth/jwtCookieSession.go new file mode 100644 index 0000000..1ae3a5b --- /dev/null +++ b/internal/auth/jwtCookieSession.go @@ -0,0 +1,224 @@ +// Copyright (C) 2023 NHR@FAU, University Erlangen-Nuremberg. +// All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. +package auth + +import ( + "crypto/ed25519" + "encoding/base64" + "errors" + "net/http" + "os" + "time" + + "github.com/ClusterCockpit/cc-backend/pkg/log" + "github.com/ClusterCockpit/cc-backend/pkg/schema" + "github.com/golang-jwt/jwt/v4" +) + +type JWTCookieSessionAuthenticator struct { + auth *Authentication + + publicKey ed25519.PublicKey + privateKey ed25519.PrivateKey + publicKeyCrossLogin ed25519.PublicKey // For accepting externally generated JWTs + + loginTokenKey []byte // HS256 key + + config *schema.JWTAuthConfig +} + +var _ Authenticator = (*JWTCookieSessionAuthenticator)(nil) + +func (ja *JWTCookieSessionAuthenticator) Init(auth *Authentication, conf interface{}) error { + + ja.auth = auth + ja.config = conf.(*schema.JWTAuthConfig) + + pubKey, privKey := os.Getenv("JWT_PUBLIC_KEY"), os.Getenv("JWT_PRIVATE_KEY") + if pubKey == "" || privKey == "" { + log.Warn("environment variables 'JWT_PUBLIC_KEY' or 'JWT_PRIVATE_KEY' not set (token based authentication will not work)") + return errors.New("environment variables 'JWT_PUBLIC_KEY' or 'JWT_PRIVATE_KEY' not set (token based authentication will not work)") + } else { + bytes, err := base64.StdEncoding.DecodeString(pubKey) + if err != nil { + log.Warn("Could not decode JWT public key") + return err + } + ja.publicKey = ed25519.PublicKey(bytes) + bytes, err = base64.StdEncoding.DecodeString(privKey) + if err != nil { + log.Warn("Could not decode JWT private key") + return err + } + ja.privateKey = ed25519.PrivateKey(bytes) + } + + if pubKey = os.Getenv("CROSS_LOGIN_JWT_HS512_KEY"); pubKey != "" { + bytes, err := base64.StdEncoding.DecodeString(pubKey) + if err != nil { + log.Warn("Could not decode cross login JWT HS512 key") + return err + } + ja.loginTokenKey = bytes + } + + // Look for external public keys + pubKeyCrossLogin, keyFound := os.LookupEnv("CROSS_LOGIN_JWT_PUBLIC_KEY") + if keyFound && pubKeyCrossLogin != "" { + bytes, err := base64.StdEncoding.DecodeString(pubKeyCrossLogin) + if err != nil { + log.Warn("Could not decode cross login JWT public key") + return err + } + ja.publicKeyCrossLogin = ed25519.PublicKey(bytes) + } else { + ja.publicKeyCrossLogin = nil + log.Debug("environment variable 'CROSS_LOGIN_JWT_PUBLIC_KEY' not set (cross login token based authentication will not work)") + return errors.New("environment variable 'CROSS_LOGIN_JWT_PUBLIC_KEY' not set (cross login token based authentication will not work)") + } + + // Warn if other necessary settings are not configured + if ja.config != nil { + if ja.config.CookieName == "" { + log.Warn("cookieName for JWTs not configured (cross login via JWT cookie will fail)") + return errors.New("cookieName for JWTs not configured (cross login via JWT cookie will fail)") + } + if !ja.config.ForceJWTValidationViaDatabase { + log.Warn("forceJWTValidationViaDatabase not set to true: CC will accept users and roles defined in JWTs regardless of its own database!") + } + if ja.config.TrustedExternalIssuer == "" { + log.Warn("trustedExternalIssuer for JWTs not configured (cross login via JWT cookie will fail)") + return errors.New("trustedExternalIssuer for JWTs not configured (cross login via JWT cookie will fail)") + } + } else { + log.Warn("config for JWTs not configured (cross login via JWT cookie will fail)") + return errors.New("config for JWTs not configured (cross login via JWT cookie will fail)") + } + + return nil +} + +func (ja *JWTCookieSessionAuthenticator) CanLogin( + user *User, + rw http.ResponseWriter, + r *http.Request) bool { + + if ja.publicKeyCrossLogin == nil || + ja.config == nil || + ja.config.TrustedExternalIssuer == "" { + + return false + } + + cookieName := "" + if ja.config != nil && ja.config.CookieName != "" { + cookieName = ja.config.CookieName + } + + // Try to read the JWT cookie + if cookieName != "" { + jwtCookie, err := r.Cookie(cookieName) + + if err == nil && jwtCookie.Value != "" { + return true + } + } + + return false +} + +func (ja *JWTCookieSessionAuthenticator) Login( + user *User, + rw http.ResponseWriter, + r *http.Request) (*User, error) { + + jwtCookie, err := r.Cookie(ja.config.CookieName) + var rawtoken string + + if err == nil && jwtCookie.Value != "" { + rawtoken = jwtCookie.Value + } + + token, err := jwt.Parse(rawtoken, func(t *jwt.Token) (interface{}, error) { + if t.Method != jwt.SigningMethodEdDSA { + return nil, errors.New("only Ed25519/EdDSA supported") + } + + unvalidatedIssuer, success := t.Claims.(jwt.MapClaims)["iss"].(string) + if success && unvalidatedIssuer == ja.config.TrustedExternalIssuer { + // The (unvalidated) issuer seems to be the expected one, + // use public cross login key from config + return ja.publicKeyCrossLogin, nil + } + + // No cross login key configured or issuer not expected + // Try own key + return ja.publicKey, nil + }) + if err != nil { + log.Warn("Error while parsing token") + return nil, err + } + + // Check token validity and extract paypload + if err := token.Claims.Valid(); err != nil { + log.Warn("jwt token claims are not valid") + return nil, err + } + + claims := token.Claims.(jwt.MapClaims) + sub, _ := claims["sub"].(string) + exp, _ := claims["exp"].(float64) + + var name string + if val, ok := claims["name"]; ok { + name, _ = val.(string) + } + + var roles []string + + if ja.config.ForceJWTValidationViaDatabase { + // Deny any logins for unknown usernames + if user == nil { + log.Warn("Could not find user from JWT in internal database.") + return nil, errors.New("unknown user") + } + + // Take user roles from database instead of trusting the JWT + roles = user.Roles + } else { + // Extract roles from JWT (if present) + if rawroles, ok := claims["roles"].([]interface{}); ok { + for _, rr := range rawroles { + if r, ok := rr.(string); ok { + roles = append(roles, r) + } + } + } + } + + // (Ask browser to) Delete JWT cookie + deletedCookie := &http.Cookie{ + Name: ja.config.CookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + } + http.SetCookie(rw, deletedCookie) + + if user == nil { + user = &User{ + Username: sub, + Name: name, + Roles: roles, + AuthType: AuthSession, + AuthSource: AuthViaToken, + } + } + + user.Expiration = time.Unix(int64(exp), 0) + return user, nil +} diff --git a/internal/auth/jwtSession.go b/internal/auth/jwtSession.go new file mode 100644 index 0000000..7c4f6f0 --- /dev/null +++ b/internal/auth/jwtSession.go @@ -0,0 +1,103 @@ +// Copyright (C) 2022 NHR@FAU, University Erlangen-Nuremberg. +// All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. +package auth + +import ( + "encoding/base64" + "fmt" + "net/http" + "os" + "strings" + "time" + + "github.com/ClusterCockpit/cc-backend/pkg/log" + "github.com/golang-jwt/jwt/v4" +) + +type JWTSessionAuthenticator struct { + auth *Authentication + + loginTokenKey []byte // HS256 key +} + +var _ Authenticator = (*JWTSessionAuthenticator)(nil) + +func (ja *JWTSessionAuthenticator) Init(auth *Authentication, conf interface{}) error { + + ja.auth = auth + + if pubKey := os.Getenv("CROSS_LOGIN_JWT_HS512_KEY"); pubKey != "" { + bytes, err := base64.StdEncoding.DecodeString(pubKey) + if err != nil { + log.Warn("Could not decode cross login JWT HS512 key") + return err + } + ja.loginTokenKey = bytes + } + + return nil +} + +func (ja *JWTSessionAuthenticator) CanLogin( + user *User, + rw http.ResponseWriter, + r *http.Request) bool { + + return r.Header.Get("Authorization") != "" +} + +func (ja *JWTSessionAuthenticator) Login( + user *User, + rw http.ResponseWriter, + r *http.Request) (*User, error) { + + rawtoken := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + token, err := jwt.Parse(rawtoken, func(t *jwt.Token) (interface{}, error) { + if t.Method == jwt.SigningMethodHS256 || t.Method == jwt.SigningMethodHS512 { + return ja.loginTokenKey, nil + } + return nil, fmt.Errorf("AUTH/JWT > unkown signing method for login token: %s (known: HS256, HS512, EdDSA)", t.Method.Alg()) + }) + if err != nil { + log.Warn("Error while parsing jwt token") + return nil, err + } + + if err = token.Claims.Valid(); err != nil { + log.Warn("jwt token claims are not valid") + return nil, err + } + + claims := token.Claims.(jwt.MapClaims) + sub, _ := claims["sub"].(string) + exp, _ := claims["exp"].(float64) + + var name string + if val, ok := claims["name"]; ok { + name, _ = val.(string) + } + + var roles []string + if rawroles, ok := claims["roles"]; ok { + for _, r := range rawroles.([]string) { + if isValidRole(r) { + roles = append(roles, r) + } + } + } + + if user == nil { + user = &User{ + Username: sub, + Name: name, + Roles: roles, + AuthType: AuthSession, + AuthSource: AuthViaToken, + } + } + + user.Expiration = time.Unix(int64(exp), 0) + return user, nil +} diff --git a/internal/auth/ldap.go b/internal/auth/ldap.go index 81b0d93..fc9753d 100644 --- a/internal/auth/ldap.go +++ b/internal/auth/ldap.go @@ -1,4 +1,4 @@ -// Copyright (C) 2022 NHR@FAU, University Erlangen-Nuremberg. +// Copyright (C) 2023 NHR@FAU, University Erlangen-Nuremberg. // All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. @@ -93,13 +93,6 @@ func (la *LdapAuthenticator) Login( return user, nil } -func (la *LdapAuthenticator) Auth( - rw http.ResponseWriter, - r *http.Request) (*User, error) { - - return la.auth.AuthViaSession(rw, r) -} - func (la *LdapAuthenticator) Sync() error { const IN_DB int = 1 diff --git a/internal/auth/local.go b/internal/auth/local.go index 29996a4..8854aff 100644 --- a/internal/auth/local.go +++ b/internal/auth/local.go @@ -46,10 +46,3 @@ func (la *LocalAuthenticator) Login( return user, nil } - -func (la *LocalAuthenticator) Auth( - rw http.ResponseWriter, - r *http.Request) (*User, error) { - - return la.auth.AuthViaSession(rw, r) -} diff --git a/internal/auth/roles.go b/internal/auth/roles.go new file mode 100644 index 0000000..e352df1 --- /dev/null +++ b/internal/auth/roles.go @@ -0,0 +1,165 @@ +// Copyright (C) 2023 NHR@FAU, University Erlangen-Nuremberg. +// All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. +package auth + +import ( + "fmt" + "strings" +) + +type Role int + +const ( + RoleAnonymous Role = iota + RoleApi + RoleUser + RoleManager + RoleSupport + RoleAdmin + RoleError +) + +func GetRoleString(roleInt Role) string { + return [6]string{"anonymous", "api", "user", "manager", "support", "admin"}[roleInt] +} + +func getRoleEnum(roleStr string) Role { + switch strings.ToLower(roleStr) { + case "admin": + return RoleAdmin + case "support": + return RoleSupport + case "manager": + return RoleManager + case "user": + return RoleUser + case "api": + return RoleApi + case "anonymous": + return RoleAnonymous + default: + return RoleError + } +} + +func isValidRole(role string) bool { + return getRoleEnum(role) != RoleError +} + +func (u *User) HasValidRole(role string) (hasRole bool, isValid bool) { + if isValidRole(role) { + for _, r := range u.Roles { + if r == role { + return true, true + } + } + return false, true + } + return false, false +} + +func (u *User) HasRole(role Role) bool { + for _, r := range u.Roles { + if r == GetRoleString(role) { + return true + } + } + return false +} + +// Role-Arrays are short: performance not impacted by nested loop +func (u *User) HasAnyRole(queryroles []Role) bool { + for _, ur := range u.Roles { + for _, qr := range queryroles { + if ur == GetRoleString(qr) { + return true + } + } + } + return false +} + +// Role-Arrays are short: performance not impacted by nested loop +func (u *User) HasAllRoles(queryroles []Role) bool { + target := len(queryroles) + matches := 0 + for _, ur := range u.Roles { + for _, qr := range queryroles { + if ur == GetRoleString(qr) { + matches += 1 + break + } + } + } + + if matches == target { + return true + } else { + return false + } +} + +// Role-Arrays are short: performance not impacted by nested loop +func (u *User) HasNotRoles(queryroles []Role) bool { + matches := 0 + for _, ur := range u.Roles { + for _, qr := range queryroles { + if ur == GetRoleString(qr) { + matches += 1 + break + } + } + } + + if matches == 0 { + return true + } else { + return false + } +} + +// Called by API endpoint '/roles/' from frontend: Only required for admin config -> Check Admin Role +func GetValidRoles(user *User) ([]string, error) { + var vals []string + if user.HasRole(RoleAdmin) { + for i := RoleApi; i < RoleError; i++ { + vals = append(vals, GetRoleString(i)) + } + return vals, nil + } + + return vals, fmt.Errorf("%s: only admins are allowed to fetch a list of roles", user.Username) +} + +// Called by routerConfig web.page setup in backend: Only requires known user +func GetValidRolesMap(user *User) (map[string]Role, error) { + named := make(map[string]Role) + if user.HasNotRoles([]Role{RoleAnonymous}) { + for i := RoleApi; i < RoleError; i++ { + named[GetRoleString(i)] = i + } + return named, nil + } + return named, fmt.Errorf("only known users are allowed to fetch a list of roles") +} + +// Find highest role +func (u *User) GetAuthLevel() Role { + if u.HasRole(RoleAdmin) { + return RoleAdmin + } else if u.HasRole(RoleSupport) { + return RoleSupport + } else if u.HasRole(RoleManager) { + return RoleManager + } else if u.HasRole(RoleUser) { + return RoleUser + } else if u.HasRole(RoleApi) { + return RoleApi + } else if u.HasRole(RoleAnonymous) { + return RoleAnonymous + } else { + return RoleError + } +} diff --git a/internal/repository/testdata/job.db b/internal/repository/testdata/job.db index 9b37f8418f890cccde8d9ad665b8b75878a72e65..4b00aa55f041b70f717177bc7baef9eb69d1a226 100644 GIT binary patch delta 850 zcmZp8z}C>fK0#Vggn@xU28g+Um z5Cs=kH^)$)5C!MZpdeSjkoXW!U)SIeN8fZ**a45ISJr6rj;Nr`zW<(VlZ8Ab+1#<~U;x`u`d2If|#mR3dvivk=b2C%a- z0*&L`EZFd#e_{aZq6HI7c!6QX$*9f1Kb2pNDF70%&_LBCDquw>cUX9_0ZsA7AI?A( z0~^jPyuD1XH#al7bFl!^lI!Gi+~yz#*Je&0V@6!ze`}iqBa6afmIj6GEDel*^yPSg z0m#jLlZpQ(e*?cI-*dh;J`>(+yp6n~JR;nnC|SqN4-5%LhRnQ_)QaN597~zWXSgl6 zB^qm_*~Qh>8Jo=~^Yh3nh&uGyACh8cQ3Nz@%%kY9k8S5GNr}C@u#qpW*+VJx8 zuyUW`vf$!p$zpM4e#N|)IgsfC(>x{z#@CGXlLZA@CjaJ6-!8hB@io(CUA9XcT#XfM z?BbG=jLqJYO&R?suVY*^`9GgJSEDNnyST70V^iVeD5j3dJj`a()$AE>Z4&s;&&aY# z0ZbaO39!6n;D5VWP~b7YuplD~gQ$FINoGz`VqQvlW=cuMq5y|&1&k~Ln^_k8={Ep6 z76h1pSOSQ}p%}y#1Y!Xo<_BUvAO?AiWxK%w#tZ!092i*?)C8H9WA!EHHqLxbS&mB_ ztxVULmIED9%fzND#3t@2J^lWEMj89O)be=4to)>SAVaKMN041yTAFd1eo10dPAXIw zN{KoLxjKfpDug&X`M4@T1viJX)v`)@3$Tf+3Nz+r=B1=ofMk*?`CqJ9Gx-et<^!|N}qKpF5m+oWKVb%=boBnbiqulf*K*8xU z`x)gJd8b?NXOx_5!YVpFb3da5BhTdZtP;~h_A?4iU$dW4ck?A~U1kzv3!Ff>*cLJH mALTRV Date: Sat, 12 Aug 2023 09:02:41 +0200 Subject: [PATCH 03/27] Readd URL token and cleanup Fix session values. --- docs/JWT-Handling.md | 44 ++++++++++++++++++-------- internal/auth/auth.go | 51 ++++++++++++++++++------------- internal/auth/jwt.go | 14 +++++++-- internal/auth/jwtCookieSession.go | 18 ----------- internal/auth/jwtSession.go | 6 +++- internal/auth/local.go | 3 +- pkg/schema/config.go | 6 ++-- 7 files changed, 83 insertions(+), 59 deletions(-) diff --git a/docs/JWT-Handling.md b/docs/JWT-Handling.md index 8b03246..d3a6335 100644 --- a/docs/JWT-Handling.md +++ b/docs/JWT-Handling.md @@ -1,11 +1,13 @@ ## Introduction -ClusterCockpit uses JSON Web Tokens (JWT) for authorization of its APIs. -JSON Web Token (JWT) is an open standard (RFC 7519) that defines a compact and self-contained way for securely transmitting information between parties as a JSON object. -This information can be verified and trusted because it is digitally signed. -In ClusterCockpit JWTs are signed using a public/private key pair using ECDSA. -Because tokens are signed using public/private key pairs, the signature also certifies that only the party holding the private key is the one that signed it. -Currently JWT tokens in ClusterCockpit not yet expire. +ClusterCockpit uses JSON Web Tokens (JWT) for authorization of its APIs. JSON +Web Token (JWT) is an open standard (RFC 7519) that defines a compact and +self-contained way for securely transmitting information between parties as a +JSON object. This information can be verified and trusted because it is +digitally signed. In ClusterCockpit JWTs are signed using a public/private key +pair using ECDSA. Because tokens are signed using public/private key pairs, the +signature also certifies that only the party holding the private key is the one +that signed it. Token expiration is set to the configuration option MaxAge. ## JWT Payload @@ -25,8 +27,15 @@ $ ./gen-keypair 2. Add keypair in your `.env` file. A template can be found in `./configs`. There are two usage scenarios: -* The APIs are used during a browser session. In this case on login a JWT token is issued on login, that is used by the web frontend to authorize against the GraphQL and REST APIs. -* The REST API is used outside a browser session, e.g. by scripts. In this case you have to issue a token manually. This possible from within the configuration view or on the command line. It is recommended to issue a JWT token in this case for a special user that only has the `api` role. By using different users for different purposes a fine grained access control and access revocation management is possible. +* The APIs are used during a browser session. In this case on login a JWT token + is issued on login, that is used by the web frontend to authorize against the + GraphQL and REST APIs. +* The REST API is used outside a browser session, e.g. by scripts. In this case + you have to issue a token manually. This possible from within the + configuration view or on the command line. It is recommended to issue a JWT + token in this case for a special user that only has the `api` role. By using + different users for different purposes a fine grained access control and + access revocation management is possible. The token is commonly specified in the Authorization HTTP header using the Bearer schema. @@ -46,16 +55,24 @@ $ curl -X GET "" -H "accept: application/json" -H "Content-Type: ``` ## Accept externally generated JWTs provided via cookie -If there is an external service like an AuthAPI that can generate JWTs and hand them over to ClusterCockpit via cookies, CC can be configured to accept them: +If there is an external service like an AuthAPI that can generate JWTs and hand +them over to ClusterCockpit via cookies, CC can be configured to accept them: -1. `.env`: CC needs a public ed25519 key to verify foreign JWT signatures. Public keys in PEM format can be converted with the instructions in [/tools/convert-pem-pubkey-for-cc](../tools/convert-pem-pubkey-for-cc/Readme.md) . +1. `.env`: CC needs a public ed25519 key to verify foreign JWT signatures. + Public keys in PEM format can be converted with the instructions in + [/tools/convert-pem-pubkey-for-cc](../tools/convert-pem-pubkey-for-cc/Readme.md) + . ``` CROSS_LOGIN_JWT_PUBLIC_KEY="+51iXX8BdLFocrppRxIw52xCOf8xFSH/eNilN5IHVGc=" ``` -2. `config.json`: Insert a name for the cookie (set by the external service) containing the JWT so that CC knows where to look at. Define a trusted issuer (JWT claim 'iss'), otherwise it will be rejected. -If you want usernames and user roles from JWTs ('sub' and 'roles' claim) to be validated against CC's internal database, you need to enable it here. Unknown users will then be rejected and roles set via JWT will be ignored. +2. `config.json`: Insert a name for the cookie (set by the external service) + containing the JWT so that CC knows where to look at. Define a trusted issuer + (JWT claim 'iss'), otherwise it will be rejected. If you want usernames and + user roles from JWTs ('sub' and 'roles' claim) to be validated against CC's + internal database, you need to enable it here. Unknown users will then be + rejected and roles set via JWT will be ignored. ```json "jwts": { @@ -65,7 +82,8 @@ If you want usernames and user roles from JWTs ('sub' and 'roles' claim) to be v } ``` -3. Make sure your external service includes the same issuer (`iss`) in its JWTs. Example JWT payload: +3. Make sure your external service includes the same issuer (`iss`) in its JWTs. + Example JWT payload: ```json { diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 8c1dca7..3d40500 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -97,26 +97,29 @@ func (auth *Authentication) AuthViaSession( if session.IsNew { return nil, nil } - - var username string - var projects, roles []string - - if val, ok := session.Values["username"]; ok { - username, _ = val.(string) - } else { - return nil, errors.New("No key username in session") - } - if val, ok := session.Values["projects"]; ok { - projects, _ = val.([]string) - } else { - return nil, errors.New("No key projects in session") - } - if val, ok := session.Values["projects"]; ok { - roles, _ = val.([]string) - } else { - return nil, errors.New("No key roles in session") - } - + // + // var username string + // var projects, roles []string + // + // if val, ok := session.Values["username"]; ok { + // username, _ = val.(string) + // } else { + // return nil, errors.New("no key username in session") + // } + // if val, ok := session.Values["projects"]; ok { + // projects, _ = val.([]string) + // } else { + // return nil, errors.New("no key projects in session") + // } + // if val, ok := session.Values["projects"]; ok { + // roles, _ = val.([]string) + // } else { + // return nil, errors.New("no key roles in session") + // } + // + username, _ := session.Values["username"].(string) + projects, _ := session.Values["projects"].([]string) + roles, _ := session.Values["roles"].([]string) return &User{ Username: username, Projects: projects, @@ -261,6 +264,12 @@ func (auth *Authentication) Auth( return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { user, err := auth.JwtAuth.AuthViaJWT(rw, r) + if err != nil { + log.Infof("authentication failed: %s", err.Error()) + http.Error(rw, err.Error(), http.StatusUnauthorized) + return + } + if user == nil { user, err = auth.AuthViaSession(rw, r) if err != nil { @@ -276,7 +285,7 @@ func (auth *Authentication) Auth( return } - log.Debug("authentication failed: no authenticator applied") + log.Debug("authentication failed") onfailure(rw, r, errors.New("unauthorized (please login first)")) }) } diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go index 75a69b8..0ac446e 100644 --- a/internal/auth/jwt.go +++ b/internal/auth/jwt.go @@ -62,6 +62,11 @@ func (ja *JWTAuthenticator) AuthViaJWT( rawtoken = strings.TrimPrefix(rawtoken, "Bearer ") } + // there is no token + if rawtoken == "" { + return nil, nil + } + token, err := jwt.Parse(rawtoken, func(t *jwt.Token) (interface{}, error) { if t.Method != jwt.SigningMethodEdDSA { return nil, errors.New("only Ed25519/EdDSA supported") @@ -81,6 +86,11 @@ func (ja *JWTAuthenticator) AuthViaJWT( // Token is valid, extract payload claims := token.Claims.(jwt.MapClaims) sub, _ := claims["sub"].(string) + exp, _ := claims["exp"].(float64) + + if exp < float64(time.Now().Unix()) { + return nil, errors.New("token is expired") + } var roles []string @@ -109,8 +119,8 @@ func (ja *JWTAuthenticator) AuthViaJWT( return &User{ Username: sub, Roles: roles, - AuthType: AuthSession, - AuthSource: AuthViaToken, + AuthType: AuthToken, + AuthSource: -1, }, nil } diff --git a/internal/auth/jwtCookieSession.go b/internal/auth/jwtCookieSession.go index 1ae3a5b..af7fb64 100644 --- a/internal/auth/jwtCookieSession.go +++ b/internal/auth/jwtCookieSession.go @@ -24,8 +24,6 @@ type JWTCookieSessionAuthenticator struct { privateKey ed25519.PrivateKey publicKeyCrossLogin ed25519.PublicKey // For accepting externally generated JWTs - loginTokenKey []byte // HS256 key - config *schema.JWTAuthConfig } @@ -55,15 +53,6 @@ func (ja *JWTCookieSessionAuthenticator) Init(auth *Authentication, conf interfa ja.privateKey = ed25519.PrivateKey(bytes) } - if pubKey = os.Getenv("CROSS_LOGIN_JWT_HS512_KEY"); pubKey != "" { - bytes, err := base64.StdEncoding.DecodeString(pubKey) - if err != nil { - log.Warn("Could not decode cross login JWT HS512 key") - return err - } - ja.loginTokenKey = bytes - } - // Look for external public keys pubKeyCrossLogin, keyFound := os.LookupEnv("CROSS_LOGIN_JWT_PUBLIC_KEY") if keyFound && pubKeyCrossLogin != "" { @@ -105,13 +94,6 @@ func (ja *JWTCookieSessionAuthenticator) CanLogin( rw http.ResponseWriter, r *http.Request) bool { - if ja.publicKeyCrossLogin == nil || - ja.config == nil || - ja.config.TrustedExternalIssuer == "" { - - return false - } - cookieName := "" if ja.config != nil && ja.config.CookieName != "" { cookieName = ja.config.CookieName diff --git a/internal/auth/jwtSession.go b/internal/auth/jwtSession.go index 7c4f6f0..7ee45e7 100644 --- a/internal/auth/jwtSession.go +++ b/internal/auth/jwtSession.go @@ -45,7 +45,7 @@ func (ja *JWTSessionAuthenticator) CanLogin( rw http.ResponseWriter, r *http.Request) bool { - return r.Header.Get("Authorization") != "" + return r.Header.Get("Authorization") != "" || r.URL.Query().Get("login-token") != "" } func (ja *JWTSessionAuthenticator) Login( @@ -54,6 +54,10 @@ func (ja *JWTSessionAuthenticator) Login( r *http.Request) (*User, error) { rawtoken := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + if rawtoken == "" { + rawtoken = r.URL.Query().Get("login-token") + } + token, err := jwt.Parse(rawtoken, func(t *jwt.Token) (interface{}, error) { if t.Method == jwt.SigningMethodHS256 || t.Method == jwt.SigningMethodHS512 { return ja.loginTokenKey, nil diff --git a/internal/auth/local.go b/internal/auth/local.go index 8854aff..dd41a25 100644 --- a/internal/auth/local.go +++ b/internal/auth/local.go @@ -39,7 +39,8 @@ func (la *LocalAuthenticator) Login( rw http.ResponseWriter, r *http.Request) (*User, error) { - if e := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(r.FormValue("password"))); e != nil { + if e := bcrypt.CompareHashAndPassword([]byte(user.Password), + []byte(r.FormValue("password"))); e != nil { log.Errorf("AUTH/LOCAL > Authentication for user %s failed!", user.Username) return nil, fmt.Errorf("AUTH/LOCAL > Authentication failed") } diff --git a/pkg/schema/config.go b/pkg/schema/config.go index 9a88ea2..190ee03 100644 --- a/pkg/schema/config.go +++ b/pkg/schema/config.go @@ -20,7 +20,7 @@ type LdapConfig struct { } type JWTAuthConfig struct { - // Specifies for how long a session or JWT shall be valid + // Specifies for how long a JWT token shall be valid // as a string parsable by time.ParseDuration(). MaxAge int64 `json:"max-age"` @@ -102,7 +102,7 @@ type ProgramConfig struct { LdapConfig *LdapConfig `json:"ldap"` JwtConfig *JWTAuthConfig `json:"jwts"` - // If 0 or empty, the session/token does not expire! + // If 0 or empty, the session does not expire! SessionMaxAge string `json:"session-max-age"` // If both those options are not empty, use HTTPS using those certificates. @@ -113,7 +113,7 @@ type ProgramConfig struct { // redirect every request incoming at port 80 to that url. RedirectHttpTo string `json:"redirect-http-to"` - // If overwriten, at least all the options in the defaults below must + // If overwritten, at least all the options in the defaults below must // be provided! Most options here can be overwritten by the user. UiDefaults map[string]interface{} `json:"ui-defaults"` From 9e3ba41746b91365e94ff9205625e350c74ceab8 Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Sat, 12 Aug 2023 09:30:33 +0200 Subject: [PATCH 04/27] Correct jwt docs --- docs/JWT-Handling.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/JWT-Handling.md b/docs/JWT-Handling.md index d3a6335..8b8d000 100644 --- a/docs/JWT-Handling.md +++ b/docs/JWT-Handling.md @@ -27,9 +27,8 @@ $ ./gen-keypair 2. Add keypair in your `.env` file. A template can be found in `./configs`. There are two usage scenarios: -* The APIs are used during a browser session. In this case on login a JWT token - is issued on login, that is used by the web frontend to authorize against the - GraphQL and REST APIs. +* The APIs are used during a browser session. API accesses are authorized with + the active session. * The REST API is used outside a browser session, e.g. by scripts. In this case you have to issue a token manually. This possible from within the configuration view or on the command line. It is recommended to issue a JWT From 4a2afc7a5a7ad0ac165125396fc5c4d9a661d634 Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Mon, 14 Aug 2023 12:40:21 +0200 Subject: [PATCH 05/27] Add LDAPSyncOnLogin option Cleanup Extend docs Remove obsolete Expiration attribute --- docs/dev-authentication.md | 69 ++++++++++++++----------------- internal/auth/auth.go | 5 +-- internal/auth/jwt.go | 5 --- internal/auth/jwtCookieSession.go | 6 +-- internal/auth/jwtSession.go | 4 +- internal/auth/ldap.go | 49 ++++++++++++++++++++-- internal/auth/local.go | 1 + pkg/schema/config.go | 1 + 8 files changed, 84 insertions(+), 56 deletions(-) diff --git a/docs/dev-authentication.md b/docs/dev-authentication.md index d40bdb0..9b84c4b 100644 --- a/docs/dev-authentication.md +++ b/docs/dev-authentication.md @@ -124,42 +124,8 @@ It is first checked if the required configuration keys are set: ``` The Login function: - -# Auth - -The Auth function (located in `auth.go`): -* Returns a new http handler function that is defined right away -* This handler iterates over all authenticators -* Calls `Auth()` on every authenticator -* If err is not nil and the user object is valid it puts the user object in the - request context and starts the onSuccess http handler -* Otherwise it calls the onFailure handler - -## Local - -Calls the `AuthViaSession()` function in `auth.go`. This will extract username, -projects and roles from the session and initialize a user object with those -values. - -## LDAP - -Calls the `AuthViaSession()` function in `auth.go`. This will extract username, -projects and roles from the session and initialize a user object with those -values. - -# JWT - -Check for JWT token: -* Is token passed in the `X-Auth-Token` or `Authorization` header -* If no token is found in a header it tries to read the token from a configured -cookie. - -Finally it calls AuthViaSession in `auth.go` if a valid session exists. This is -true if a JWT token was previously used to initiate a session. In this case the -user object initialized with the session is returned right away. - -In case a token was found extract and parse the token: -* Check if signing method is Ed25519/EdDSA +* Extracts and parses the token +* Checks if signing method is Ed25519/EdDSA * In case publicKeyCrossLogin is configured: - Check if `iss` issuer claim matched trusted issuer from configuration - Return public cross login key @@ -167,7 +133,34 @@ In case a token was found extract and parse the token: * Check if claims are valid * Depending on the option `ForceJWTValidationViaDatabase ` the roles are extracted from JWT token or taken from user object fetched from database -* In case the token was extracted from cookie create a new session and ask the - browser to delete the JWT cookie +* Ask browser to delete the JWT cookie * Return valid user object +# Auth + +The Auth function (located in `auth.go`): +* Returns a new http handler function that is defined right away +* This handler tries two methods to authenticate a user: + - Via a JWT API token in `AuthViaJWT()` + - Via a valid session in `AuthViaSession()` +* If err is not nil and the user object is valid it puts the user object in the + request context and starts the onSuccess http handler +* Otherwise it calls the onFailure handler + +## AuthViaJWT + +Implemented in JWTAuthenticator: +* Extract token either from header `X-Auth-Token` or `Authorization` with Bearer + prefix +* Parse token and check if it is valid. The Parse routine will also check if the + token is expired. +* If the option `ForceJWTValidationViaDatabase` is set it will ensure the + user object exists in the database and takes the roles from the database user +* Otherwise the roles are extracted from the roles claim +* Returns a valid user object with AuthType set to AuthToken + +## AuthViaSession + +* Extracts session +* Get values username, projects, and roles from session +* Returns a valid user object with AuthType set to AuthSession diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 3d40500..8149bc1 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -43,7 +43,6 @@ type User struct { AuthSource AuthSource `json:"authSource"` Email string `json:"email"` Projects []string `json:"projects"` - Expiration time.Time } func (u *User) HasProject(project string) bool { @@ -66,7 +65,7 @@ func GetUser(ctx context.Context) *User { type Authenticator interface { Init(auth *Authentication, config interface{}) error - CanLogin(user *User, rw http.ResponseWriter, r *http.Request) bool + CanLogin(user *User, username string, rw http.ResponseWriter, r *http.Request) bool Login(user *User, rw http.ResponseWriter, r *http.Request) (*User, error) } @@ -208,7 +207,7 @@ func (auth *Authentication) Login( } for _, authenticator := range auth.authenticators { - if !authenticator.CanLogin(dbUser, rw, r) { + if !authenticator.CanLogin(dbUser, username, rw, r) { continue } diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go index 0ac446e..6a77fc4 100644 --- a/internal/auth/jwt.go +++ b/internal/auth/jwt.go @@ -86,11 +86,6 @@ func (ja *JWTAuthenticator) AuthViaJWT( // Token is valid, extract payload claims := token.Claims.(jwt.MapClaims) sub, _ := claims["sub"].(string) - exp, _ := claims["exp"].(float64) - - if exp < float64(time.Now().Unix()) { - return nil, errors.New("token is expired") - } var roles []string diff --git a/internal/auth/jwtCookieSession.go b/internal/auth/jwtCookieSession.go index af7fb64..8f31335 100644 --- a/internal/auth/jwtCookieSession.go +++ b/internal/auth/jwtCookieSession.go @@ -10,7 +10,6 @@ import ( "errors" "net/http" "os" - "time" "github.com/ClusterCockpit/cc-backend/pkg/log" "github.com/ClusterCockpit/cc-backend/pkg/schema" @@ -91,6 +90,7 @@ func (ja *JWTCookieSessionAuthenticator) Init(auth *Authentication, conf interfa func (ja *JWTCookieSessionAuthenticator) CanLogin( user *User, + username string, rw http.ResponseWriter, r *http.Request) bool { @@ -140,7 +140,7 @@ func (ja *JWTCookieSessionAuthenticator) Login( return ja.publicKey, nil }) if err != nil { - log.Warn("Error while parsing token") + log.Warn("error while parsing token") return nil, err } @@ -152,7 +152,6 @@ func (ja *JWTCookieSessionAuthenticator) Login( claims := token.Claims.(jwt.MapClaims) sub, _ := claims["sub"].(string) - exp, _ := claims["exp"].(float64) var name string if val, ok := claims["name"]; ok { @@ -201,6 +200,5 @@ func (ja *JWTCookieSessionAuthenticator) Login( } } - user.Expiration = time.Unix(int64(exp), 0) return user, nil } diff --git a/internal/auth/jwtSession.go b/internal/auth/jwtSession.go index 7ee45e7..e0f2f8b 100644 --- a/internal/auth/jwtSession.go +++ b/internal/auth/jwtSession.go @@ -10,7 +10,6 @@ import ( "net/http" "os" "strings" - "time" "github.com/ClusterCockpit/cc-backend/pkg/log" "github.com/golang-jwt/jwt/v4" @@ -42,6 +41,7 @@ func (ja *JWTSessionAuthenticator) Init(auth *Authentication, conf interface{}) func (ja *JWTSessionAuthenticator) CanLogin( user *User, + username string, rw http.ResponseWriter, r *http.Request) bool { @@ -76,7 +76,6 @@ func (ja *JWTSessionAuthenticator) Login( claims := token.Claims.(jwt.MapClaims) sub, _ := claims["sub"].(string) - exp, _ := claims["exp"].(float64) var name string if val, ok := claims["name"]; ok { @@ -102,6 +101,5 @@ func (ja *JWTSessionAuthenticator) Login( } } - user.Expiration = time.Unix(int64(exp), 0) return user, nil } diff --git a/internal/auth/ldap.go b/internal/auth/ldap.go index fc9753d..17b5c0c 100644 --- a/internal/auth/ldap.go +++ b/internal/auth/ldap.go @@ -66,10 +66,51 @@ func (la *LdapAuthenticator) Init( func (la *LdapAuthenticator) CanLogin( user *User, + username string, rw http.ResponseWriter, r *http.Request) bool { - return user != nil && user.AuthSource == AuthViaLDAP + if user != nil && user.AuthSource == AuthViaLDAP { + return true + } else { + if la.config.SyncUserOnLogin { + l, err := la.getLdapConnection(true) + if err != nil { + log.Error("LDAP connection error") + } + + // Search for the given username + searchRequest := ldap.NewSearchRequest( + la.config.UserBase, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + fmt.Sprintf("(%s(uid=%s))", la.config.UserFilter, username), + []string{"dn", "uid", "gecos"}, nil) + + sr, err := l.Search(searchRequest) + if err != nil { + log.Warn(err) + return false + } + + if len(sr.Entries) != 1 { + log.Warn("User does not exist or too many entries returned") + return false + } + + entry := sr.Entries[0] + name := entry.GetAttributeValue("gecos") + + if _, err := la.auth.db.Exec(`INSERT INTO user (username, ldap, name, roles) VALUES (?, ?, ?, ?)`, + username, 1, name, "[\""+GetRoleString(RoleUser)+"\"]"); err != nil { + log.Errorf("User '%s' new in LDAP: Insert into DB failed", username) + return false + } + + return true + } + } + + return false } func (la *LdapAuthenticator) Login( @@ -124,8 +165,10 @@ func (la *LdapAuthenticator) Sync() error { defer l.Close() ldapResults, err := l.Search(ldap.NewSearchRequest( - la.config.UserBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, - la.config.UserFilter, []string{"dn", "uid", "gecos"}, nil)) + la.config.UserBase, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + fmt.Sprintf("(%s(uid=%s))", la.config.UserFilter, "*"), + []string{"dn", "uid", "gecos"}, nil)) if err != nil { log.Warn("LDAP search error") return err diff --git a/internal/auth/local.go b/internal/auth/local.go index dd41a25..700db3a 100644 --- a/internal/auth/local.go +++ b/internal/auth/local.go @@ -28,6 +28,7 @@ func (la *LocalAuthenticator) Init( func (la *LocalAuthenticator) CanLogin( user *User, + username string, rw http.ResponseWriter, r *http.Request) bool { diff --git a/pkg/schema/config.go b/pkg/schema/config.go index 190ee03..2a4047c 100644 --- a/pkg/schema/config.go +++ b/pkg/schema/config.go @@ -17,6 +17,7 @@ type LdapConfig struct { UserFilter string `json:"user_filter"` SyncInterval string `json:"sync_interval"` // Parsed using time.ParseDuration. SyncDelOldUsers bool `json:"sync_del_old_users"` + SyncUserOnLogin bool `json:"syncUserOnLogin"` } type JWTAuthConfig struct { From e69f2c425333289d8f34e5aebc845ae20e33cb5f Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Mon, 14 Aug 2023 13:35:32 +0200 Subject: [PATCH 06/27] Update Release notes --- ReleaseNotes.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/ReleaseNotes.md b/ReleaseNotes.md index 127d4a8..d66a27f 100644 --- a/ReleaseNotes.md +++ b/ReleaseNotes.md @@ -1,11 +1,16 @@ -# `cc-backend` version 1.1.0 +# `cc-backend` version 1.2.0 Supports job archive version 1 and database version 6. This is a minor release of `cc-backend`, the API backend and frontend implementation of ClusterCockpit. -** Breaking changes v1 ** +** Breaking changes ** + +The LDAP configuration option user_filter was changed and now should not include +the wildcard. Example: +* Old: `"user_filter": "(&(objectclass=posixAccount)(uid=*))"` +* New: `"user_filter": "&(objectclass=posixAccount)"` The aggregate job statistic core hours is now computed using the job table column `num_hwthreads`. In a future release this column will be renamed to From e550e57ac0a7da7072256236c41e0cb8e87e2461 Mon Sep 17 00:00:00 2001 From: Christoph Kluge Date: Mon, 14 Aug 2023 13:52:26 +0200 Subject: [PATCH 07/27] Fix Java/Grails issued token parsing - Tested locally until successfull login - Initialize empty projects array --- internal/auth/jwtSession.go | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/internal/auth/jwtSession.go b/internal/auth/jwtSession.go index 7ee45e7..90725d4 100644 --- a/internal/auth/jwtSession.go +++ b/internal/auth/jwtSession.go @@ -79,12 +79,26 @@ func (ja *JWTSessionAuthenticator) Login( exp, _ := claims["exp"].(float64) var name string - if val, ok := claims["name"]; ok { + // Java/Grails Issued Token + if wrap, ok := claims["name"].(map[string]interface{}); ok { + if vals, ok := wrap["values"].([]interface{}); ok { + name = fmt.Sprintf("%v %v", vals[0], vals[1]) + } + } else if val, ok := claims["name"]; ok { name, _ = val.(string) } var roles []string - if rawroles, ok := claims["roles"]; ok { + // Java/Grails Issued Token + if rawroles, ok := claims["roles"].([]interface{}); ok { + for _, rr := range rawroles { + if r, ok := rr.(string); ok { + if isValidRole(r) { + roles = append(roles, r) + } + } + } + } else if rawroles, ok := claims["roles"]; ok { for _, r := range rawroles.([]string) { if isValidRole(r) { roles = append(roles, r) @@ -92,11 +106,26 @@ func (ja *JWTSessionAuthenticator) Login( } } + projects := make([]string, 0) + // Java/Grails Issued Token + // if rawprojs, ok := claims["projects"].([]interface{}); ok { + // for _, pp := range rawprojs { + // if p, ok := pp.(string); ok { + // projects = append(projects, p) + // } + // } + // } else if rawprojs, ok := claims["projects"]; ok { + // for _, p := range rawprojs.([]string) { + // projects = append(projects, p) + // } + // } + if user == nil { user = &User{ Username: sub, Name: name, Roles: roles, + Projects: projects, AuthType: AuthSession, AuthSource: AuthViaToken, } From 90bdfcfbb62d88dd5eba5ef06fa70e1a8c38ecf2 Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Mon, 14 Aug 2023 14:33:05 +0200 Subject: [PATCH 08/27] Add secured subrouter for REST API Rename IP filter option Add array helper in util --- internal/api/rest.go | 201 ++++++++++++++++++++++++----------------- internal/util/array.go | 14 +++ pkg/schema/config.go | 4 +- 3 files changed, 135 insertions(+), 84 deletions(-) create mode 100644 internal/util/array.go diff --git a/internal/api/rest.go b/internal/api/rest.go index c1f6fd1..1e758a2 100644 --- a/internal/api/rest.go +++ b/internal/api/rest.go @@ -26,6 +26,7 @@ import ( "github.com/ClusterCockpit/cc-backend/internal/importer" "github.com/ClusterCockpit/cc-backend/internal/metricdata" "github.com/ClusterCockpit/cc-backend/internal/repository" + "github.com/ClusterCockpit/cc-backend/internal/util" "github.com/ClusterCockpit/cc-backend/pkg/archive" "github.com/ClusterCockpit/cc-backend/pkg/log" "github.com/ClusterCockpit/cc-backend/pkg/schema" @@ -76,23 +77,65 @@ func (api *RestApi) MountRoutes(r *mux.Router) { r.HandleFunc("/jobs/delete_job/", api.deleteJobByRequest).Methods(http.MethodDelete) r.HandleFunc("/jobs/delete_job/{id}", api.deleteJobById).Methods(http.MethodDelete) r.HandleFunc("/jobs/delete_job_before/{ts}", api.deleteJobBefore).Methods(http.MethodDelete) - r.HandleFunc("/secured/addProject/{id}/{project}", api.secureUpdateUser).Methods(http.MethodPost) - r.HandleFunc("/secured/addRole/{id}/{role}", api.secureUpdateUser).Methods(http.MethodPost) - - if api.Authentication != nil { - r.HandleFunc("/jwt/", api.getJWT).Methods(http.MethodGet) - r.HandleFunc("/roles/", api.getRoles).Methods(http.MethodGet) - r.HandleFunc("/users/", api.createUser).Methods(http.MethodPost, http.MethodPut) - r.HandleFunc("/users/", api.getUsers).Methods(http.MethodGet) - r.HandleFunc("/users/", api.deleteUser).Methods(http.MethodDelete) - r.HandleFunc("/user/{id}", api.updateUser).Methods(http.MethodPost) - r.HandleFunc("/configuration/", api.updateConfiguration).Methods(http.MethodPost) - } + // r.HandleFunc("/secured/addProject/{id}/{project}", api.secureUpdateUser).Methods(http.MethodPost) + // r.HandleFunc("/secured/addRole/{id}/{role}", api.secureUpdateUser).Methods(http.MethodPost) if api.MachineStateDir != "" { r.HandleFunc("/machine_state/{cluster}/{host}", api.getMachineState).Methods(http.MethodGet) r.HandleFunc("/machine_state/{cluster}/{host}", api.putMachineState).Methods(http.MethodPut, http.MethodPost) } + + if api.Authentication != nil { + rw := r.MatcherFunc( + func(rq *http.Request, rm *mux.RouteMatch) bool { + user := auth.GetUser(rq.Context()) + return user.AuthType == auth.AuthSession + }).Subrouter() + rw.HandleFunc("/jwt/", api.getJWT).Methods(http.MethodGet) + rw.HandleFunc("/roles/", api.getRoles).Methods(http.MethodGet) + rw.HandleFunc("/users/", api.createUser).Methods(http.MethodPost, http.MethodPut) + rw.HandleFunc("/users/", api.getUsers).Methods(http.MethodGet) + rw.HandleFunc("/users/", api.deleteUser).Methods(http.MethodDelete) + rw.HandleFunc("/user/{id}", api.updateUser).Methods(http.MethodPost) + rw.HandleFunc("/configuration/", api.updateConfiguration).Methods(http.MethodPost) + + rs := r.PathPrefix("/secured").MatcherFunc( + func(rq *http.Request, rm *mux.RouteMatch) bool { + user := auth.GetUser(rq.Context()) + // this only applies for token based authorization + if user.AuthType != auth.AuthToken { + return false + } + + // If nothing declared in config: deny all request to this endpoint + if config.Keys.ApiAllowedIPs == nil || len(config.Keys.ApiAllowedIPs) == 0 { + return false + } + + // extract IP address + IPAddress := rq.Header.Get("X-Real-Ip") + if IPAddress == "" { + IPAddress = rq.Header.Get("X-Forwarded-For") + } + if IPAddress == "" { + IPAddress = rq.RemoteAddr + } + + // check if IP is allowed + if !util.Contains(config.Keys.ApiAllowedIPs, IPAddress) { + return false + } + + return true + }).Subrouter() + rs.HandleFunc("/jwt/", api.getJWT).Methods(http.MethodGet) + rs.HandleFunc("/roles/", api.getRoles).Methods(http.MethodGet) + rs.HandleFunc("/users/", api.createUser).Methods(http.MethodPost, http.MethodPut) + rs.HandleFunc("/users/", api.getUsers).Methods(http.MethodGet) + rs.HandleFunc("/users/", api.deleteUser).Methods(http.MethodDelete) + rs.HandleFunc("/user/{id}", api.updateUser).Methods(http.MethodPost) + rs.HandleFunc("/configuration/", api.updateConfiguration).Methods(http.MethodPost) + } } // StartJobApiResponse model @@ -1051,76 +1094,70 @@ func (api *RestApi) updateUser(rw http.ResponseWriter, r *http.Request) { } } -func (api *RestApi) secureUpdateUser(rw http.ResponseWriter, r *http.Request) { - if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleApi) { - handleError(fmt.Errorf("missing role: %v", auth.GetRoleString(auth.RoleApi)), http.StatusForbidden, rw) - return - } - - // If nothing declared in config: deny all request to this endpint - if config.Keys.ApiAllowedAddrs == nil || len(config.Keys.ApiAllowedAddrs) == 0 { - handleError(fmt.Errorf("denied by default policy!"), http.StatusForbidden, rw) - return - } - - // IP CHECK HERE (WIP) - // Probably better as private routine - IPAddress := r.Header.Get("X-Real-Ip") - if IPAddress == "" { - IPAddress = r.Header.Get("X-Forwarded-For") - } - if IPAddress == "" { - IPAddress = r.RemoteAddr - } - - // Also This - ipOk := false - for _, a := range config.Keys.ApiAllowedAddrs { - if a == IPAddress { - ipOk = true - } - } - - if IPAddress == "" || ipOk == false { - handleError(fmt.Errorf("unknown ip: %v", IPAddress), http.StatusForbidden, rw) - return - } - // IP CHECK END - - // Get Values - id := mux.Vars(r)["id"] - newproj := mux.Vars(r)["project"] - newrole := mux.Vars(r)["role"] - - // TODO: Handle anything but roles... - if newrole != "" { - if err := api.Authentication.AddRole(r.Context(), id, newrole); err != nil { - handleError(errors.New(err.Error()), http.StatusUnprocessableEntity, rw) - return - } - - rw.Header().Add("Content-Type", "application/json") - rw.WriteHeader(http.StatusOK) - json.NewEncoder(rw).Encode(UpdateUserApiResponse{ - Message: fmt.Sprintf("Successfully added role %s to %s", newrole, id), - }) - - } else if newproj != "" { - if err := api.Authentication.AddProject(r.Context(), id, newproj); err != nil { - handleError(errors.New(err.Error()), http.StatusUnprocessableEntity, rw) - return - } - - rw.Header().Add("Content-Type", "application/json") - rw.WriteHeader(http.StatusOK) - json.NewEncoder(rw).Encode(UpdateUserApiResponse{ - Message: fmt.Sprintf("Successfully added project %s to %s", newproj, id), - }) - - } else { - handleError(errors.New("Not Add [role|project]?"), http.StatusBadRequest, rw) - } -} +// func (api *RestApi) secureUpdateUser(rw http.ResponseWriter, r *http.Request) { +// if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleApi) { +// handleError(fmt.Errorf("missing role: %v", auth.GetRoleString(auth.RoleApi)), http.StatusForbidden, rw) +// return +// } +// +// // IP CHECK HERE (WIP) +// // Probably better as private routine +// IPAddress := r.Header.Get("X-Real-Ip") +// if IPAddress == "" { +// IPAddress = r.Header.Get("X-Forwarded-For") +// } +// if IPAddress == "" { +// IPAddress = r.RemoteAddr +// } +// +// // Also This +// ipOk := false +// for _, a := range config.Keys.ApiAllowedAddrs { +// if a == IPAddress { +// ipOk = true +// } +// } +// +// if IPAddress == "" || ipOk == false { +// handleError(fmt.Errorf("unknown ip: %v", IPAddress), http.StatusForbidden, rw) +// return +// } +// // IP CHECK END +// +// // Get Values +// id := mux.Vars(r)["id"] +// newproj := mux.Vars(r)["project"] +// newrole := mux.Vars(r)["role"] +// +// // TODO: Handle anything but roles... +// if newrole != "" { +// if err := api.Authentication.AddRole(r.Context(), id, newrole); err != nil { +// handleError(errors.New(err.Error()), http.StatusUnprocessableEntity, rw) +// return +// } +// +// rw.Header().Add("Content-Type", "application/json") +// rw.WriteHeader(http.StatusOK) +// json.NewEncoder(rw).Encode(UpdateUserApiResponse{ +// Message: fmt.Sprintf("Successfully added role %s to %s", newrole, id), +// }) +// +// } else if newproj != "" { +// if err := api.Authentication.AddProject(r.Context(), id, newproj); err != nil { +// handleError(errors.New(err.Error()), http.StatusUnprocessableEntity, rw) +// return +// } +// +// rw.Header().Add("Content-Type", "application/json") +// rw.WriteHeader(http.StatusOK) +// json.NewEncoder(rw).Encode(UpdateUserApiResponse{ +// Message: fmt.Sprintf("Successfully added project %s to %s", newproj, id), +// }) +// +// } else { +// handleError(errors.New("Not Add [role|project]?"), http.StatusBadRequest, rw) +// } +// } func (api *RestApi) updateConfiguration(rw http.ResponseWriter, r *http.Request) { rw.Header().Set("Content-Type", "text/plain") diff --git a/internal/util/array.go b/internal/util/array.go new file mode 100644 index 0000000..bc7ed04 --- /dev/null +++ b/internal/util/array.go @@ -0,0 +1,14 @@ +// Copyright (C) 2023 NHR@FAU, University Erlangen-Nuremberg. +// All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. +package util + +func Contains[T comparable](items []T, item T) bool { + for _, v := range items { + if v == item { + return true + } + } + return false +} diff --git a/pkg/schema/config.go b/pkg/schema/config.go index b59feb3..95cc641 100644 --- a/pkg/schema/config.go +++ b/pkg/schema/config.go @@ -70,8 +70,8 @@ type ProgramConfig struct { // Address where the http (or https) server will listen on (for example: 'localhost:80'). Addr string `json:"addr"` - // Addresses from which the /secured/* API endpoints can be reached - ApiAllowedAddrs []string `json:"apiAllowedAddrs"` + // Addresses from which the /api/secured/* API endpoints can be reached + ApiAllowedIPs []string `json:"apiAllowedIPs"` // Drop root permissions once .env was read and the port was taken. User string `json:"user"` From 202521cbfd3d13e7935e52e04c6482bd5b5f7c3c Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Mon, 14 Aug 2023 18:38:30 +0200 Subject: [PATCH 09/27] Restructure routing and security check --- internal/api/rest.go | 129 +++++++++++++++++++++++++------------------ 1 file changed, 76 insertions(+), 53 deletions(-) diff --git a/internal/api/rest.go b/internal/api/rest.go index 1e758a2..501cf3b 100644 --- a/internal/api/rest.go +++ b/internal/api/rest.go @@ -1,4 +1,4 @@ -// Copyright (C) 2022 NHR@FAU, University Erlangen-Nuremberg. +// Copyright (C) 2023 NHR@FAU, University Erlangen-Nuremberg. // All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. @@ -86,55 +86,13 @@ func (api *RestApi) MountRoutes(r *mux.Router) { } if api.Authentication != nil { - rw := r.MatcherFunc( - func(rq *http.Request, rm *mux.RouteMatch) bool { - user := auth.GetUser(rq.Context()) - return user.AuthType == auth.AuthSession - }).Subrouter() - rw.HandleFunc("/jwt/", api.getJWT).Methods(http.MethodGet) - rw.HandleFunc("/roles/", api.getRoles).Methods(http.MethodGet) - rw.HandleFunc("/users/", api.createUser).Methods(http.MethodPost, http.MethodPut) - rw.HandleFunc("/users/", api.getUsers).Methods(http.MethodGet) - rw.HandleFunc("/users/", api.deleteUser).Methods(http.MethodDelete) - rw.HandleFunc("/user/{id}", api.updateUser).Methods(http.MethodPost) - rw.HandleFunc("/configuration/", api.updateConfiguration).Methods(http.MethodPost) - - rs := r.PathPrefix("/secured").MatcherFunc( - func(rq *http.Request, rm *mux.RouteMatch) bool { - user := auth.GetUser(rq.Context()) - // this only applies for token based authorization - if user.AuthType != auth.AuthToken { - return false - } - - // If nothing declared in config: deny all request to this endpoint - if config.Keys.ApiAllowedIPs == nil || len(config.Keys.ApiAllowedIPs) == 0 { - return false - } - - // extract IP address - IPAddress := rq.Header.Get("X-Real-Ip") - if IPAddress == "" { - IPAddress = rq.Header.Get("X-Forwarded-For") - } - if IPAddress == "" { - IPAddress = rq.RemoteAddr - } - - // check if IP is allowed - if !util.Contains(config.Keys.ApiAllowedIPs, IPAddress) { - return false - } - - return true - }).Subrouter() - rs.HandleFunc("/jwt/", api.getJWT).Methods(http.MethodGet) - rs.HandleFunc("/roles/", api.getRoles).Methods(http.MethodGet) - rs.HandleFunc("/users/", api.createUser).Methods(http.MethodPost, http.MethodPut) - rs.HandleFunc("/users/", api.getUsers).Methods(http.MethodGet) - rs.HandleFunc("/users/", api.deleteUser).Methods(http.MethodDelete) - rs.HandleFunc("/user/{id}", api.updateUser).Methods(http.MethodPost) - rs.HandleFunc("/configuration/", api.updateConfiguration).Methods(http.MethodPost) + r.HandleFunc("/jwt/", api.getJWT).Methods(http.MethodGet) + r.HandleFunc("/roles/", api.getRoles).Methods(http.MethodGet) + r.HandleFunc("/users/", api.createUser).Methods(http.MethodPost, http.MethodPut) + r.HandleFunc("/users/", api.getUsers).Methods(http.MethodGet) + r.HandleFunc("/users/", api.deleteUser).Methods(http.MethodDelete) + r.HandleFunc("/user/{id}", api.updateUser).Methods(http.MethodPost) + r.HandleFunc("/configuration/", api.updateConfiguration).Methods(http.MethodPost) } } @@ -223,6 +181,36 @@ func decode(r io.Reader, val interface{}) error { return dec.Decode(val) } +func securedCheck(r *http.Request) error { + user := auth.GetUser(r.Context()) + if user == nil { + return fmt.Errorf("no user in context") + } + + if user.AuthType == auth.AuthToken { + // If nothing declared in config: deny all request to this endpoint + if config.Keys.ApiAllowedIPs == nil || len(config.Keys.ApiAllowedIPs) == 0 { + return fmt.Errorf("missing configuration key ApiAllowedIPs") + } + + // extract IP address + IPAddress := r.Header.Get("X-Real-Ip") + if IPAddress == "" { + IPAddress = r.Header.Get("X-Forwarded-For") + } + if IPAddress == "" { + IPAddress = r.RemoteAddr + } + + // check if IP is allowed + if !util.Contains(config.Keys.ApiAllowedIPs, IPAddress) { + return fmt.Errorf("unknown ip: %v", IPAddress) + } + } + + return nil +} + // getJobs godoc // @summary Lists all jobs // @tags query @@ -943,6 +931,11 @@ func (api *RestApi) getJobMetrics(rw http.ResponseWriter, r *http.Request) { } func (api *RestApi) getJWT(rw http.ResponseWriter, r *http.Request) { + err := securedCheck(r) + if err != nil { + http.Error(rw, err.Error(), http.StatusForbidden) + } + rw.Header().Set("Content-Type", "text/plain") username := r.FormValue("username") me := auth.GetUser(r.Context()) @@ -971,6 +964,11 @@ func (api *RestApi) getJWT(rw http.ResponseWriter, r *http.Request) { } func (api *RestApi) createUser(rw http.ResponseWriter, r *http.Request) { + err := securedCheck(r) + if err != nil { + http.Error(rw, err.Error(), http.StatusForbidden) + } + rw.Header().Set("Content-Type", "text/plain") me := auth.GetUser(r.Context()) if !me.HasRole(auth.RoleAdmin) { @@ -978,17 +976,22 @@ func (api *RestApi) createUser(rw http.ResponseWriter, r *http.Request) { return } - username, password, role, name, email, project := r.FormValue("username"), r.FormValue("password"), r.FormValue("role"), r.FormValue("name"), r.FormValue("email"), r.FormValue("project") + username, password, role, name, email, project := r.FormValue("username"), + r.FormValue("password"), r.FormValue("role"), r.FormValue("name"), + r.FormValue("email"), r.FormValue("project") + if len(password) == 0 && role != auth.GetRoleString(auth.RoleApi) { http.Error(rw, "Only API users are allowed to have a blank password (login will be impossible)", http.StatusBadRequest) return } if len(project) != 0 && role != auth.GetRoleString(auth.RoleManager) { - http.Error(rw, "only managers require a project (can be changed later)", http.StatusBadRequest) + http.Error(rw, "only managers require a project (can be changed later)", + http.StatusBadRequest) return } else if len(project) == 0 && role == auth.GetRoleString(auth.RoleManager) { - http.Error(rw, "managers require a project to manage (can be changed later)", http.StatusBadRequest) + http.Error(rw, "managers require a project to manage (can be changed later)", + http.StatusBadRequest) return } @@ -1007,6 +1010,11 @@ func (api *RestApi) createUser(rw http.ResponseWriter, r *http.Request) { } func (api *RestApi) deleteUser(rw http.ResponseWriter, r *http.Request) { + err := securedCheck(r) + if err != nil { + http.Error(rw, err.Error(), http.StatusForbidden) + } + if user := auth.GetUser(r.Context()); !user.HasRole(auth.RoleAdmin) { http.Error(rw, "Only admins are allowed to delete a user", http.StatusForbidden) return @@ -1022,6 +1030,11 @@ func (api *RestApi) deleteUser(rw http.ResponseWriter, r *http.Request) { } func (api *RestApi) getUsers(rw http.ResponseWriter, r *http.Request) { + err := securedCheck(r) + if err != nil { + http.Error(rw, err.Error(), http.StatusForbidden) + } + if user := auth.GetUser(r.Context()); !user.HasRole(auth.RoleAdmin) { http.Error(rw, "Only admins are allowed to fetch a list of users", http.StatusForbidden) return @@ -1037,6 +1050,11 @@ func (api *RestApi) getUsers(rw http.ResponseWriter, r *http.Request) { } func (api *RestApi) getRoles(rw http.ResponseWriter, r *http.Request) { + err := securedCheck(r) + if err != nil { + http.Error(rw, err.Error(), http.StatusForbidden) + } + user := auth.GetUser(r.Context()) if !user.HasRole(auth.RoleAdmin) { http.Error(rw, "only admins are allowed to fetch a list of roles", http.StatusForbidden) @@ -1053,6 +1071,11 @@ func (api *RestApi) getRoles(rw http.ResponseWriter, r *http.Request) { } func (api *RestApi) updateUser(rw http.ResponseWriter, r *http.Request) { + err := securedCheck(r) + if err != nil { + http.Error(rw, err.Error(), http.StatusForbidden) + } + if user := auth.GetUser(r.Context()); !user.HasRole(auth.RoleAdmin) { http.Error(rw, "Only admins are allowed to update a user", http.StatusForbidden) return From 4f6d1fec68f53a9dee3f1727f2abfb1b95dfd738 Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Wed, 16 Aug 2023 09:19:41 +0200 Subject: [PATCH 10/27] Fix errors in ldap auth --- internal/auth/auth.go | 13 +++++++------ internal/auth/ldap.go | 4 +++- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 8149bc1..500ef1a 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -158,12 +158,13 @@ func Init(db *sqlx.DB, } if config, ok := configs["ldap"]; ok { - auth.LdapAuth = &LdapAuthenticator{} - if err := auth.LdapAuth.Init(auth, config); err != nil { - log.Error("Error while initializing authentication -> ldapAuth init failed") - return nil, err + ldapAuth := &LdapAuthenticator{} + if err := ldapAuth.Init(auth, config); err != nil { + log.Warn("Error while initializing authentication -> ldapAuth init failed") + } else { + auth.LdapAuth = ldapAuth + auth.authenticators = append(auth.authenticators, auth.LdapAuth) } - auth.authenticators = append(auth.authenticators, auth.LdapAuth) } jwtSessionAuth := &JWTSessionAuthenticator{} @@ -174,7 +175,7 @@ func Init(db *sqlx.DB, } jwtCookieSessionAuth := &JWTCookieSessionAuthenticator{} - if err := jwtSessionAuth.Init(auth, configs["jwt"]); err != nil { + if err := jwtCookieSessionAuth.Init(auth, configs["jwt"]); err != nil { log.Warn("Error while initializing authentication -> jwtCookieSessionAuth init failed") } else { auth.authenticators = append(auth.authenticators, jwtCookieSessionAuth) diff --git a/internal/auth/ldap.go b/internal/auth/ldap.go index 17b5c0c..9feebc1 100644 --- a/internal/auth/ldap.go +++ b/internal/auth/ldap.go @@ -59,6 +59,8 @@ func (la *LdapAuthenticator) Init( log.Print("sync done") } }() + } else { + return fmt.Errorf("missing LDAP configuration") } return nil @@ -73,7 +75,7 @@ func (la *LdapAuthenticator) CanLogin( if user != nil && user.AuthSource == AuthViaLDAP { return true } else { - if la.config.SyncUserOnLogin { + if la.config != nil && la.config.SyncUserOnLogin { l, err := la.getLdapConnection(true) if err != nil { log.Error("LDAP connection error") From 80aed874157cd797f39a39bad06142cd4452c25d Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Wed, 16 Aug 2023 17:21:12 +0200 Subject: [PATCH 11/27] Retry fetching user after CanLogin --- internal/auth/auth.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 500ef1a..57bf3c4 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -211,6 +211,10 @@ func (auth *Authentication) Login( if !authenticator.CanLogin(dbUser, username, rw, r) { continue } + dbUser, err = auth.GetUser(username) + if err != nil && err != sql.ErrNoRows { + log.Errorf("Error while loading user '%v'", username) + } user, err := authenticator.Login(dbUser, rw, r) if err != nil { From 87ce4f63d48a331cf44ea1450224967363a48d9c Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Thu, 17 Aug 2023 10:29:00 +0200 Subject: [PATCH 12/27] Refactor auth module Separate parts Add user repository Add user schema --- cmd/cc-backend/main.go | 23 +- internal/api/rest.go | 97 +++-- internal/auth/auth.go | 73 +--- internal/auth/jwt.go | 18 +- internal/auth/jwtCookieSession.go | 20 +- internal/auth/jwtSession.go | 22 +- internal/auth/ldap.go | 12 +- internal/auth/local.go | 9 +- internal/auth/users.go | 289 ------------- internal/graph/schema.resolvers.go | 15 +- internal/repository/job.go | 11 +- internal/repository/query.go | 9 +- internal/repository/stats.go | 3 +- internal/repository/tags.go | 7 +- internal/repository/user.go | 392 +++++++++++++----- internal/repository/userConfig.go | 137 ++++++ .../{user_test.go => userConfig_test.go} | 4 +- internal/routerConfig/routes.go | 35 +- pkg/schema/config.go | 11 +- internal/auth/roles.go => pkg/schema/user.go | 41 +- .../auth_test.go => pkg/schema/user_test.go | 2 +- web/web.go | 7 +- 22 files changed, 637 insertions(+), 600 deletions(-) delete mode 100644 internal/auth/users.go create mode 100644 internal/repository/userConfig.go rename internal/repository/{user_test.go => userConfig_test.go} (94%) rename internal/auth/roles.go => pkg/schema/user.go (82%) rename internal/auth/auth_test.go => pkg/schema/user_test.go (99%) diff --git a/cmd/cc-backend/main.go b/cmd/cc-backend/main.go index 8164792..60c5edd 100644 --- a/cmd/cc-backend/main.go +++ b/cmd/cc-backend/main.go @@ -228,14 +228,16 @@ func main() { log.Fatal("invalid argument format for user creation") } - if err := authentication.AddUser(&auth.User{ + ur := repository.GetUserRepository() + if err := ur.AddUser(&schema.User{ Username: parts[0], Projects: make([]string, 0), Password: parts[2], Roles: strings.Split(parts[1], ","), }); err != nil { log.Fatalf("adding '%s' user authentication failed: %v", parts[0], err) } } if flagDelUser != "" { - if err := authentication.DelUser(flagDelUser); err != nil { + ur := repository.GetUserRepository() + if err := ur.DelUser(flagDelUser); err != nil { log.Fatalf("deleting user failed: %v", err) } } @@ -252,12 +254,13 @@ func main() { } if flagGenJWT != "" { - user, err := authentication.GetUser(flagGenJWT) + ur := repository.GetUserRepository() + user, err := ur.GetUser(flagGenJWT) if err != nil { log.Fatalf("could not get user from JWT: %v", err) } - if !user.HasRole(auth.RoleApi) { + if !user.HasRole(schema.RoleApi) { log.Warnf("user '%s' does not have the API role", user.Username) } @@ -327,15 +330,15 @@ func main() { r.HandleFunc("/login", func(rw http.ResponseWriter, r *http.Request) { rw.Header().Add("Content-Type", "text/html; charset=utf-8") - web.RenderTemplate(rw, r, "login.tmpl", &web.Page{Title: "Login", Build: buildInfo}) + web.RenderTemplate(rw, "login.tmpl", &web.Page{Title: "Login", Build: buildInfo}) }).Methods(http.MethodGet) r.HandleFunc("/imprint", func(rw http.ResponseWriter, r *http.Request) { rw.Header().Add("Content-Type", "text/html; charset=utf-8") - web.RenderTemplate(rw, r, "imprint.tmpl", &web.Page{Title: "Imprint", Build: buildInfo}) + web.RenderTemplate(rw, "imprint.tmpl", &web.Page{Title: "Imprint", Build: buildInfo}) }) r.HandleFunc("/privacy", func(rw http.ResponseWriter, r *http.Request) { rw.Header().Add("Content-Type", "text/html; charset=utf-8") - web.RenderTemplate(rw, r, "privacy.tmpl", &web.Page{Title: "Privacy", Build: buildInfo}) + web.RenderTemplate(rw, "privacy.tmpl", &web.Page{Title: "Privacy", Build: buildInfo}) }) // Some routes, such as /login or /query, should only be accessible to a user that is logged in. @@ -351,7 +354,7 @@ func main() { func(rw http.ResponseWriter, r *http.Request, err error) { rw.Header().Add("Content-Type", "text/html; charset=utf-8") rw.WriteHeader(http.StatusUnauthorized) - web.RenderTemplate(rw, r, "login.tmpl", &web.Page{ + web.RenderTemplate(rw, "login.tmpl", &web.Page{ Title: "Login failed - ClusterCockpit", MsgType: "alert-warning", Message: err.Error(), @@ -362,7 +365,7 @@ func main() { r.Handle("/logout", authentication.Logout(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { rw.Header().Add("Content-Type", "text/html; charset=utf-8") rw.WriteHeader(http.StatusOK) - web.RenderTemplate(rw, r, "login.tmpl", &web.Page{ + web.RenderTemplate(rw, "login.tmpl", &web.Page{ Title: "Bye - ClusterCockpit", MsgType: "alert-info", Message: "Logout successful", @@ -378,7 +381,7 @@ func main() { // On failure: func(rw http.ResponseWriter, r *http.Request, err error) { rw.WriteHeader(http.StatusUnauthorized) - web.RenderTemplate(rw, r, "login.tmpl", &web.Page{ + web.RenderTemplate(rw, "login.tmpl", &web.Page{ Title: "Authentication failed - ClusterCockpit", MsgType: "alert-danger", Message: err.Error(), diff --git a/internal/api/rest.go b/internal/api/rest.go index 501cf3b..0716514 100644 --- a/internal/api/rest.go +++ b/internal/api/rest.go @@ -182,12 +182,12 @@ func decode(r io.Reader, val interface{}) error { } func securedCheck(r *http.Request) error { - user := auth.GetUser(r.Context()) + user := repository.GetUserFromContext(r.Context()) if user == nil { return fmt.Errorf("no user in context") } - if user.AuthType == auth.AuthToken { + if user.AuthType == schema.AuthToken { // If nothing declared in config: deny all request to this endpoint if config.Keys.ApiAllowedIPs == nil || len(config.Keys.ApiAllowedIPs) == 0 { return fmt.Errorf("missing configuration key ApiAllowedIPs") @@ -232,8 +232,10 @@ func securedCheck(r *http.Request) error { // @router /jobs/ [get] func (api *RestApi) getJobs(rw http.ResponseWriter, r *http.Request) { - if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleApi) { - handleError(fmt.Errorf("missing role: %v", auth.GetRoleString(auth.RoleApi)), http.StatusForbidden, rw) + if user := repository.GetUserFromContext(r.Context()); user != nil && + !user.HasRole(schema.RoleApi) { + + handleError(fmt.Errorf("missing role: %v", schema.GetRoleString(schema.RoleApi)), http.StatusForbidden, rw) return } @@ -374,9 +376,11 @@ func (api *RestApi) getJobs(rw http.ResponseWriter, r *http.Request) { // @security ApiKeyAuth // @router /jobs/{id} [post] func (api *RestApi) getJobById(rw http.ResponseWriter, r *http.Request) { - if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleApi) { + if user := repository.GetUserFromContext(r.Context()); user != nil && + !user.HasRole(schema.RoleApi) { + handleError(fmt.Errorf("missing role: %v", - auth.GetRoleString(auth.RoleApi)), http.StatusForbidden, rw) + schema.GetRoleString(schema.RoleApi)), http.StatusForbidden, rw) return } @@ -465,8 +469,10 @@ func (api *RestApi) getJobById(rw http.ResponseWriter, r *http.Request) { // @security ApiKeyAuth // @router /jobs/tag_job/{id} [post] func (api *RestApi) tagJob(rw http.ResponseWriter, r *http.Request) { - if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleApi) { - handleError(fmt.Errorf("missing role: %v", auth.GetRoleString(auth.RoleApi)), http.StatusForbidden, rw) + if user := repository.GetUserFromContext(r.Context()); user != nil && + !user.HasRole(schema.RoleApi) { + + handleError(fmt.Errorf("missing role: %v", schema.GetRoleString(schema.RoleApi)), http.StatusForbidden, rw) return } @@ -530,8 +536,10 @@ func (api *RestApi) tagJob(rw http.ResponseWriter, r *http.Request) { // @security ApiKeyAuth // @router /jobs/start_job/ [post] func (api *RestApi) startJob(rw http.ResponseWriter, r *http.Request) { - if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleApi) { - handleError(fmt.Errorf("missing role: %v", auth.GetRoleString(auth.RoleApi)), http.StatusForbidden, rw) + if user := repository.GetUserFromContext(r.Context()); user != nil && + !user.HasRole(schema.RoleApi) { + + handleError(fmt.Errorf("missing role: %v", schema.GetRoleString(schema.RoleApi)), http.StatusForbidden, rw) return } @@ -611,8 +619,10 @@ func (api *RestApi) startJob(rw http.ResponseWriter, r *http.Request) { // @security ApiKeyAuth // @router /jobs/stop_job/{id} [post] func (api *RestApi) stopJobById(rw http.ResponseWriter, r *http.Request) { - if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleApi) { - handleError(fmt.Errorf("missing role: %v", auth.GetRoleString(auth.RoleApi)), http.StatusForbidden, rw) + if user := repository.GetUserFromContext(r.Context()); user != nil && + !user.HasRole(schema.RoleApi) { + + handleError(fmt.Errorf("missing role: %v", schema.GetRoleString(schema.RoleApi)), http.StatusForbidden, rw) return } @@ -664,8 +674,10 @@ func (api *RestApi) stopJobById(rw http.ResponseWriter, r *http.Request) { // @security ApiKeyAuth // @router /jobs/stop_job/ [post] func (api *RestApi) stopJobByRequest(rw http.ResponseWriter, r *http.Request) { - if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleApi) { - handleError(fmt.Errorf("missing role: %v", auth.GetRoleString(auth.RoleApi)), http.StatusForbidden, rw) + if user := repository.GetUserFromContext(r.Context()); user != nil && + !user.HasRole(schema.RoleApi) { + + handleError(fmt.Errorf("missing role: %v", schema.GetRoleString(schema.RoleApi)), http.StatusForbidden, rw) return } @@ -710,8 +722,8 @@ func (api *RestApi) stopJobByRequest(rw http.ResponseWriter, r *http.Request) { // @security ApiKeyAuth // @router /jobs/delete_job/{id} [delete] func (api *RestApi) deleteJobById(rw http.ResponseWriter, r *http.Request) { - if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleApi) { - handleError(fmt.Errorf("missing role: %v", auth.GetRoleString(auth.RoleApi)), http.StatusForbidden, rw) + if user := repository.GetUserFromContext(r.Context()); user != nil && !user.HasRole(schema.RoleApi) { + handleError(fmt.Errorf("missing role: %v", schema.GetRoleString(schema.RoleApi)), http.StatusForbidden, rw) return } @@ -758,8 +770,9 @@ func (api *RestApi) deleteJobById(rw http.ResponseWriter, r *http.Request) { // @security ApiKeyAuth // @router /jobs/delete_job/ [delete] func (api *RestApi) deleteJobByRequest(rw http.ResponseWriter, r *http.Request) { - if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleApi) { - handleError(fmt.Errorf("missing role: %v", auth.GetRoleString(auth.RoleApi)), http.StatusForbidden, rw) + if user := repository.GetUserFromContext(r.Context()); user != nil && + !user.HasRole(schema.RoleApi) { + handleError(fmt.Errorf("missing role: %v", schema.GetRoleString(schema.RoleApi)), http.StatusForbidden, rw) return } @@ -814,8 +827,8 @@ func (api *RestApi) deleteJobByRequest(rw http.ResponseWriter, r *http.Request) // @security ApiKeyAuth // @router /jobs/delete_job_before/{ts} [delete] func (api *RestApi) deleteJobBefore(rw http.ResponseWriter, r *http.Request) { - if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleApi) { - handleError(fmt.Errorf("missing role: %v", auth.GetRoleString(auth.RoleApi)), http.StatusForbidden, rw) + if user := repository.GetUserFromContext(r.Context()); user != nil && !user.HasRole(schema.RoleApi) { + handleError(fmt.Errorf("missing role: %v", schema.GetRoleString(schema.RoleApi)), http.StatusForbidden, rw) return } @@ -938,8 +951,8 @@ func (api *RestApi) getJWT(rw http.ResponseWriter, r *http.Request) { rw.Header().Set("Content-Type", "text/plain") username := r.FormValue("username") - me := auth.GetUser(r.Context()) - if !me.HasRole(auth.RoleAdmin) { + me := repository.GetUserFromContext(r.Context()) + if !me.HasRole(schema.RoleAdmin) { if username != me.Username { http.Error(rw, "Only admins are allowed to sign JWTs not for themselves", http.StatusForbidden) @@ -947,7 +960,7 @@ func (api *RestApi) getJWT(rw http.ResponseWriter, r *http.Request) { } } - user, err := api.Authentication.GetUser(username) + user, err := repository.GetUserRepository().GetUser(username) if err != nil { http.Error(rw, err.Error(), http.StatusUnprocessableEntity) return @@ -970,8 +983,8 @@ func (api *RestApi) createUser(rw http.ResponseWriter, r *http.Request) { } rw.Header().Set("Content-Type", "text/plain") - me := auth.GetUser(r.Context()) - if !me.HasRole(auth.RoleAdmin) { + me := repository.GetUserFromContext(r.Context()) + if !me.HasRole(schema.RoleAdmin) { http.Error(rw, "Only admins are allowed to create new users", http.StatusForbidden) return } @@ -980,22 +993,22 @@ func (api *RestApi) createUser(rw http.ResponseWriter, r *http.Request) { r.FormValue("password"), r.FormValue("role"), r.FormValue("name"), r.FormValue("email"), r.FormValue("project") - if len(password) == 0 && role != auth.GetRoleString(auth.RoleApi) { + if len(password) == 0 && role != schema.GetRoleString(schema.RoleApi) { http.Error(rw, "Only API users are allowed to have a blank password (login will be impossible)", http.StatusBadRequest) return } - if len(project) != 0 && role != auth.GetRoleString(auth.RoleManager) { + if len(project) != 0 && role != schema.GetRoleString(schema.RoleManager) { http.Error(rw, "only managers require a project (can be changed later)", http.StatusBadRequest) return - } else if len(project) == 0 && role == auth.GetRoleString(auth.RoleManager) { + } else if len(project) == 0 && role == schema.GetRoleString(schema.RoleManager) { http.Error(rw, "managers require a project to manage (can be changed later)", http.StatusBadRequest) return } - if err := api.Authentication.AddUser(&auth.User{ + if err := repository.GetUserRepository().AddUser(&schema.User{ Username: username, Name: name, Password: password, @@ -1015,13 +1028,13 @@ func (api *RestApi) deleteUser(rw http.ResponseWriter, r *http.Request) { http.Error(rw, err.Error(), http.StatusForbidden) } - if user := auth.GetUser(r.Context()); !user.HasRole(auth.RoleAdmin) { + if user := repository.GetUserFromContext(r.Context()); !user.HasRole(schema.RoleAdmin) { http.Error(rw, "Only admins are allowed to delete a user", http.StatusForbidden) return } username := r.FormValue("username") - if err := api.Authentication.DelUser(username); err != nil { + if err := repository.GetUserRepository().DelUser(username); err != nil { http.Error(rw, err.Error(), http.StatusUnprocessableEntity) return } @@ -1035,12 +1048,12 @@ func (api *RestApi) getUsers(rw http.ResponseWriter, r *http.Request) { http.Error(rw, err.Error(), http.StatusForbidden) } - if user := auth.GetUser(r.Context()); !user.HasRole(auth.RoleAdmin) { + if user := repository.GetUserFromContext(r.Context()); !user.HasRole(schema.RoleAdmin) { http.Error(rw, "Only admins are allowed to fetch a list of users", http.StatusForbidden) return } - users, err := api.Authentication.ListUsers(r.URL.Query().Get("not-just-user") == "true") + users, err := repository.GetUserRepository().ListUsers(r.URL.Query().Get("not-just-user") == "true") if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return @@ -1055,13 +1068,13 @@ func (api *RestApi) getRoles(rw http.ResponseWriter, r *http.Request) { http.Error(rw, err.Error(), http.StatusForbidden) } - user := auth.GetUser(r.Context()) - if !user.HasRole(auth.RoleAdmin) { + user := repository.GetUserFromContext(r.Context()) + if !user.HasRole(schema.RoleAdmin) { http.Error(rw, "only admins are allowed to fetch a list of roles", http.StatusForbidden) return } - roles, err := auth.GetValidRoles(user) + roles, err := schema.GetValidRoles(user) if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return @@ -1076,7 +1089,7 @@ func (api *RestApi) updateUser(rw http.ResponseWriter, r *http.Request) { http.Error(rw, err.Error(), http.StatusForbidden) } - if user := auth.GetUser(r.Context()); !user.HasRole(auth.RoleAdmin) { + if user := repository.GetUserFromContext(r.Context()); !user.HasRole(schema.RoleAdmin) { http.Error(rw, "Only admins are allowed to update a user", http.StatusForbidden) return } @@ -1089,25 +1102,25 @@ func (api *RestApi) updateUser(rw http.ResponseWriter, r *http.Request) { // TODO: Handle anything but roles... if newrole != "" { - if err := api.Authentication.AddRole(r.Context(), mux.Vars(r)["id"], newrole); err != nil { + if err := repository.GetUserRepository().AddRole(r.Context(), mux.Vars(r)["id"], newrole); err != nil { http.Error(rw, err.Error(), http.StatusUnprocessableEntity) return } rw.Write([]byte("Add Role Success")) } else if delrole != "" { - if err := api.Authentication.RemoveRole(r.Context(), mux.Vars(r)["id"], delrole); err != nil { + if err := repository.GetUserRepository().RemoveRole(r.Context(), mux.Vars(r)["id"], delrole); err != nil { http.Error(rw, err.Error(), http.StatusUnprocessableEntity) return } rw.Write([]byte("Remove Role Success")) } else if newproj != "" { - if err := api.Authentication.AddProject(r.Context(), mux.Vars(r)["id"], newproj); err != nil { + if err := repository.GetUserRepository().AddProject(r.Context(), mux.Vars(r)["id"], newproj); err != nil { http.Error(rw, err.Error(), http.StatusUnprocessableEntity) return } rw.Write([]byte("Add Project Success")) } else if delproj != "" { - if err := api.Authentication.RemoveProject(r.Context(), mux.Vars(r)["id"], delproj); err != nil { + if err := repository.GetUserRepository().RemoveProject(r.Context(), mux.Vars(r)["id"], delproj); err != nil { http.Error(rw, err.Error(), http.StatusUnprocessableEntity) return } @@ -1188,7 +1201,7 @@ func (api *RestApi) updateConfiguration(rw http.ResponseWriter, r *http.Request) fmt.Printf("REST > KEY: %#v\nVALUE: %#v\n", key, value) - if err := repository.GetUserCfgRepo().UpdateConfig(key, value, auth.GetUser(r.Context())); err != nil { + if err := repository.GetUserCfgRepo().UpdateConfig(key, value, repository.GetUserFromContext(r.Context())); err != nil { http.Error(rw, err.Error(), http.StatusUnprocessableEntity) return } diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 57bf3c4..d79f28b 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -14,65 +14,19 @@ import ( "os" "time" + "github.com/ClusterCockpit/cc-backend/internal/repository" "github.com/ClusterCockpit/cc-backend/pkg/log" + "github.com/ClusterCockpit/cc-backend/pkg/schema" "github.com/gorilla/sessions" "github.com/jmoiron/sqlx" ) -type AuthSource int - -const ( - AuthViaLocalPassword AuthSource = iota - AuthViaLDAP - AuthViaToken -) - -type AuthType int - -const ( - AuthToken AuthType = iota - AuthSession -) - -type User struct { - Username string `json:"username"` - Password string `json:"-"` - Name string `json:"name"` - Roles []string `json:"roles"` - AuthType AuthType `json:"authType"` - AuthSource AuthSource `json:"authSource"` - Email string `json:"email"` - Projects []string `json:"projects"` -} - -func (u *User) HasProject(project string) bool { - for _, p := range u.Projects { - if p == project { - return true - } - } - return false -} - -func GetUser(ctx context.Context) *User { - x := ctx.Value(ContextUserKey) - if x == nil { - return nil - } - - return x.(*User) -} - type Authenticator interface { Init(auth *Authentication, config interface{}) error - CanLogin(user *User, username string, rw http.ResponseWriter, r *http.Request) bool - Login(user *User, rw http.ResponseWriter, r *http.Request) (*User, error) + CanLogin(user *schema.User, username string, rw http.ResponseWriter, r *http.Request) bool + Login(user *schema.User, rw http.ResponseWriter, r *http.Request) (*schema.User, error) } -type ContextKey string - -const ContextUserKey ContextKey = "user" - type Authentication struct { db *sqlx.DB sessionStore *sessions.CookieStore @@ -86,7 +40,7 @@ type Authentication struct { func (auth *Authentication) AuthViaSession( rw http.ResponseWriter, - r *http.Request) (*User, error) { + r *http.Request) (*schema.User, error) { session, err := auth.sessionStore.Get(r, "session") if err != nil { log.Error("Error while getting session store") @@ -119,11 +73,11 @@ func (auth *Authentication) AuthViaSession( username, _ := session.Values["username"].(string) projects, _ := session.Values["projects"].([]string) roles, _ := session.Values["roles"].([]string) - return &User{ + return &schema.User{ Username: username, Projects: projects, Roles: roles, - AuthType: AuthSession, + AuthType: schema.AuthSession, AuthSource: -1, }, nil } @@ -196,12 +150,13 @@ func (auth *Authentication) Login( onfailure func(rw http.ResponseWriter, r *http.Request, loginErr error)) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + ur := repository.GetUserRepository() err := errors.New("no authenticator applied") username := r.FormValue("username") - dbUser := (*User)(nil) + dbUser := (*schema.User)(nil) if username != "" { - dbUser, err = auth.GetUser(username) + dbUser, err = ur.GetUser(username) if err != nil && err != sql.ErrNoRows { log.Errorf("Error while loading user '%v'", username) } @@ -211,7 +166,7 @@ func (auth *Authentication) Login( if !authenticator.CanLogin(dbUser, username, rw, r) { continue } - dbUser, err = auth.GetUser(username) + dbUser, err = ur.GetUser(username) if err != nil && err != sql.ErrNoRows { log.Errorf("Error while loading user '%v'", username) } @@ -243,7 +198,7 @@ func (auth *Authentication) Login( } if dbUser == nil { - if err := auth.AddUser(user); err != nil { + if err := ur.AddUser(user); err != nil { // TODO Add AuthSource log.Errorf("Error while adding user '%v' to auth from XX", user.Username) @@ -251,7 +206,7 @@ func (auth *Authentication) Login( } log.Infof("login successfull: user: %#v (roles: %v, projects: %v)", user.Username, user.Roles, user.Projects) - ctx := context.WithValue(r.Context(), ContextUserKey, user) + ctx := context.WithValue(r.Context(), repository.ContextUserKey, user) onsuccess.ServeHTTP(rw, r.WithContext(ctx)) return } @@ -284,7 +239,7 @@ func (auth *Authentication) Auth( } if user != nil { - ctx := context.WithValue(r.Context(), ContextUserKey, user) + ctx := context.WithValue(r.Context(), repository.ContextUserKey, user) onsuccess.ServeHTTP(rw, r.WithContext(ctx)) return } diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go index 6a77fc4..0690b9b 100644 --- a/internal/auth/jwt.go +++ b/internal/auth/jwt.go @@ -13,22 +13,19 @@ import ( "strings" "time" + "github.com/ClusterCockpit/cc-backend/internal/repository" "github.com/ClusterCockpit/cc-backend/pkg/log" "github.com/ClusterCockpit/cc-backend/pkg/schema" "github.com/golang-jwt/jwt/v4" ) type JWTAuthenticator struct { - auth *Authentication - publicKey ed25519.PublicKey privateKey ed25519.PrivateKey config *schema.JWTAuthConfig } func (ja *JWTAuthenticator) Init(auth *Authentication, conf interface{}) error { - - ja.auth = auth ja.config = conf.(*schema.JWTAuthConfig) pubKey, privKey := os.Getenv("JWT_PUBLIC_KEY"), os.Getenv("JWT_PRIVATE_KEY") @@ -54,7 +51,7 @@ func (ja *JWTAuthenticator) Init(auth *Authentication, conf interface{}) error { func (ja *JWTAuthenticator) AuthViaJWT( rw http.ResponseWriter, - r *http.Request) (*User, error) { + r *http.Request) (*schema.User, error) { rawtoken := r.Header.Get("X-Auth-Token") if rawtoken == "" { @@ -90,8 +87,9 @@ func (ja *JWTAuthenticator) AuthViaJWT( var roles []string // Validate user + roles from JWT against database? - if ja.config != nil && ja.config.ForceJWTValidationViaDatabase { - user, err := ja.auth.GetUser(sub) + if ja.config != nil && ja.config.ValidateUser { + ur := repository.GetUserRepository() + user, err := ur.GetUser(sub) // Deny any logins for unknown usernames if err != nil { @@ -111,16 +109,16 @@ func (ja *JWTAuthenticator) AuthViaJWT( } } - return &User{ + return &schema.User{ Username: sub, Roles: roles, - AuthType: AuthToken, + AuthType: schema.AuthToken, AuthSource: -1, }, nil } // Generate a new JWT that can be used for authentication -func (ja *JWTAuthenticator) ProvideJWT(user *User) (string, error) { +func (ja *JWTAuthenticator) ProvideJWT(user *schema.User) (string, error) { if ja.privateKey == nil { return "", errors.New("environment variable 'JWT_PRIVATE_KEY' not set") diff --git a/internal/auth/jwtCookieSession.go b/internal/auth/jwtCookieSession.go index 8f31335..4787107 100644 --- a/internal/auth/jwtCookieSession.go +++ b/internal/auth/jwtCookieSession.go @@ -73,10 +73,10 @@ func (ja *JWTCookieSessionAuthenticator) Init(auth *Authentication, conf interfa log.Warn("cookieName for JWTs not configured (cross login via JWT cookie will fail)") return errors.New("cookieName for JWTs not configured (cross login via JWT cookie will fail)") } - if !ja.config.ForceJWTValidationViaDatabase { + if !ja.config.ValidateUser { log.Warn("forceJWTValidationViaDatabase not set to true: CC will accept users and roles defined in JWTs regardless of its own database!") } - if ja.config.TrustedExternalIssuer == "" { + if ja.config.TrustedIssuer == "" { log.Warn("trustedExternalIssuer for JWTs not configured (cross login via JWT cookie will fail)") return errors.New("trustedExternalIssuer for JWTs not configured (cross login via JWT cookie will fail)") } @@ -89,7 +89,7 @@ func (ja *JWTCookieSessionAuthenticator) Init(auth *Authentication, conf interfa } func (ja *JWTCookieSessionAuthenticator) CanLogin( - user *User, + user *schema.User, username string, rw http.ResponseWriter, r *http.Request) bool { @@ -112,9 +112,9 @@ func (ja *JWTCookieSessionAuthenticator) CanLogin( } func (ja *JWTCookieSessionAuthenticator) Login( - user *User, + user *schema.User, rw http.ResponseWriter, - r *http.Request) (*User, error) { + r *http.Request) (*schema.User, error) { jwtCookie, err := r.Cookie(ja.config.CookieName) var rawtoken string @@ -129,7 +129,7 @@ func (ja *JWTCookieSessionAuthenticator) Login( } unvalidatedIssuer, success := t.Claims.(jwt.MapClaims)["iss"].(string) - if success && unvalidatedIssuer == ja.config.TrustedExternalIssuer { + if success && unvalidatedIssuer == ja.config.TrustedIssuer { // The (unvalidated) issuer seems to be the expected one, // use public cross login key from config return ja.publicKeyCrossLogin, nil @@ -160,7 +160,7 @@ func (ja *JWTCookieSessionAuthenticator) Login( var roles []string - if ja.config.ForceJWTValidationViaDatabase { + if ja.config.ValidateUser { // Deny any logins for unknown usernames if user == nil { log.Warn("Could not find user from JWT in internal database.") @@ -191,12 +191,12 @@ func (ja *JWTCookieSessionAuthenticator) Login( http.SetCookie(rw, deletedCookie) if user == nil { - user = &User{ + user = &schema.User{ Username: sub, Name: name, Roles: roles, - AuthType: AuthSession, - AuthSource: AuthViaToken, + AuthType: schema.AuthSession, + AuthSource: schema.AuthViaToken, } } diff --git a/internal/auth/jwtSession.go b/internal/auth/jwtSession.go index 13af7c1..59250ac 100644 --- a/internal/auth/jwtSession.go +++ b/internal/auth/jwtSession.go @@ -12,21 +12,17 @@ import ( "strings" "github.com/ClusterCockpit/cc-backend/pkg/log" + "github.com/ClusterCockpit/cc-backend/pkg/schema" "github.com/golang-jwt/jwt/v4" ) type JWTSessionAuthenticator struct { - auth *Authentication - loginTokenKey []byte // HS256 key } var _ Authenticator = (*JWTSessionAuthenticator)(nil) func (ja *JWTSessionAuthenticator) Init(auth *Authentication, conf interface{}) error { - - ja.auth = auth - if pubKey := os.Getenv("CROSS_LOGIN_JWT_HS512_KEY"); pubKey != "" { bytes, err := base64.StdEncoding.DecodeString(pubKey) if err != nil { @@ -40,7 +36,7 @@ func (ja *JWTSessionAuthenticator) Init(auth *Authentication, conf interface{}) } func (ja *JWTSessionAuthenticator) CanLogin( - user *User, + user *schema.User, username string, rw http.ResponseWriter, r *http.Request) bool { @@ -49,9 +45,9 @@ func (ja *JWTSessionAuthenticator) CanLogin( } func (ja *JWTSessionAuthenticator) Login( - user *User, + user *schema.User, rw http.ResponseWriter, - r *http.Request) (*User, error) { + r *http.Request) (*schema.User, error) { rawtoken := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") if rawtoken == "" { @@ -92,14 +88,14 @@ func (ja *JWTSessionAuthenticator) Login( if rawroles, ok := claims["roles"].([]interface{}); ok { for _, rr := range rawroles { if r, ok := rr.(string); ok { - if isValidRole(r) { + if schema.IsValidRole(r) { roles = append(roles, r) } } } } else if rawroles, ok := claims["roles"]; ok { for _, r := range rawroles.([]string) { - if isValidRole(r) { + if schema.IsValidRole(r) { roles = append(roles, r) } } @@ -120,13 +116,13 @@ func (ja *JWTSessionAuthenticator) Login( // } if user == nil { - user = &User{ + user = &schema.User{ Username: sub, Name: name, Roles: roles, Projects: projects, - AuthType: AuthSession, - AuthSource: AuthViaToken, + AuthType: schema.AuthSession, + AuthSource: schema.AuthViaToken, } } diff --git a/internal/auth/ldap.go b/internal/auth/ldap.go index 9feebc1..925e967 100644 --- a/internal/auth/ldap.go +++ b/internal/auth/ldap.go @@ -67,12 +67,12 @@ func (la *LdapAuthenticator) Init( } func (la *LdapAuthenticator) CanLogin( - user *User, + user *schema.User, username string, rw http.ResponseWriter, r *http.Request) bool { - if user != nil && user.AuthSource == AuthViaLDAP { + if user != nil && user.AuthSource == schema.AuthViaLDAP { return true } else { if la.config != nil && la.config.SyncUserOnLogin { @@ -103,7 +103,7 @@ func (la *LdapAuthenticator) CanLogin( name := entry.GetAttributeValue("gecos") if _, err := la.auth.db.Exec(`INSERT INTO user (username, ldap, name, roles) VALUES (?, ?, ?, ?)`, - username, 1, name, "[\""+GetRoleString(RoleUser)+"\"]"); err != nil { + username, 1, name, "[\""+schema.GetRoleString(schema.RoleUser)+"\"]"); err != nil { log.Errorf("User '%s' new in LDAP: Insert into DB failed", username) return false } @@ -116,9 +116,9 @@ func (la *LdapAuthenticator) CanLogin( } func (la *LdapAuthenticator) Login( - user *User, + user *schema.User, rw http.ResponseWriter, - r *http.Request) (*User, error) { + r *http.Request) (*schema.User, error) { l, err := la.getLdapConnection(false) if err != nil { @@ -203,7 +203,7 @@ func (la *LdapAuthenticator) Sync() error { name := newnames[username] log.Debugf("sync: add %v (name: %v, roles: [user], ldap: true)", username, name) if _, err := la.auth.db.Exec(`INSERT INTO user (username, ldap, name, roles) VALUES (?, ?, ?, ?)`, - username, 1, name, "[\""+GetRoleString(RoleUser)+"\"]"); err != nil { + username, 1, name, "[\""+schema.GetRoleString(schema.RoleUser)+"\"]"); err != nil { log.Errorf("User '%s' new in LDAP: Insert into DB failed", username) return err } diff --git a/internal/auth/local.go b/internal/auth/local.go index 700db3a..fb1ba0f 100644 --- a/internal/auth/local.go +++ b/internal/auth/local.go @@ -9,6 +9,7 @@ import ( "net/http" "github.com/ClusterCockpit/cc-backend/pkg/log" + "github.com/ClusterCockpit/cc-backend/pkg/schema" "golang.org/x/crypto/bcrypt" ) @@ -27,18 +28,18 @@ func (la *LocalAuthenticator) Init( } func (la *LocalAuthenticator) CanLogin( - user *User, + user *schema.User, username string, rw http.ResponseWriter, r *http.Request) bool { - return user != nil && user.AuthSource == AuthViaLocalPassword + return user != nil && user.AuthSource == schema.AuthViaLocalPassword } func (la *LocalAuthenticator) Login( - user *User, + user *schema.User, rw http.ResponseWriter, - r *http.Request) (*User, error) { + r *http.Request) (*schema.User, error) { if e := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(r.FormValue("password"))); e != nil { diff --git a/internal/auth/users.go b/internal/auth/users.go deleted file mode 100644 index b69533b..0000000 --- a/internal/auth/users.go +++ /dev/null @@ -1,289 +0,0 @@ -// Copyright (C) 2022 NHR@FAU, University Erlangen-Nuremberg. -// All rights reserved. -// Use of this source code is governed by a MIT-style -// license that can be found in the LICENSE file. -package auth - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - "strings" - - "github.com/ClusterCockpit/cc-backend/internal/graph/model" - "github.com/ClusterCockpit/cc-backend/pkg/log" - sq "github.com/Masterminds/squirrel" - "github.com/jmoiron/sqlx" - "golang.org/x/crypto/bcrypt" -) - -func (auth *Authentication) GetUser(username string) (*User, error) { - - user := &User{Username: username} - var hashedPassword, name, rawRoles, email, rawProjects sql.NullString - if err := sq.Select("password", "ldap", "name", "roles", "email", "projects").From("user"). - Where("user.username = ?", username).RunWith(auth.db). - QueryRow().Scan(&hashedPassword, &user.AuthSource, &name, &rawRoles, &email, &rawProjects); err != nil { - log.Warnf("Error while querying user '%v' from database", username) - return nil, err - } - - user.Password = hashedPassword.String - user.Name = name.String - user.Email = email.String - if rawRoles.Valid { - if err := json.Unmarshal([]byte(rawRoles.String), &user.Roles); err != nil { - log.Warn("Error while unmarshaling raw roles from DB") - return nil, err - } - } - if rawProjects.Valid { - if err := json.Unmarshal([]byte(rawProjects.String), &user.Projects); err != nil { - return nil, err - } - } - - return user, nil -} - -func (auth *Authentication) AddUser(user *User) error { - - rolesJson, _ := json.Marshal(user.Roles) - projectsJson, _ := json.Marshal(user.Projects) - - cols := []string{"username", "roles", "projects"} - vals := []interface{}{user.Username, string(rolesJson), string(projectsJson)} - - if user.Name != "" { - cols = append(cols, "name") - vals = append(vals, user.Name) - } - if user.Email != "" { - cols = append(cols, "email") - vals = append(vals, user.Email) - } - if user.Password != "" { - password, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost) - if err != nil { - log.Error("Error while encrypting new user password") - return err - } - cols = append(cols, "password") - vals = append(vals, string(password)) - } - - if _, err := sq.Insert("user").Columns(cols...).Values(vals...).RunWith(auth.db).Exec(); err != nil { - log.Errorf("Error while inserting new user '%v' into DB", user.Username) - return err - } - - log.Infof("new user %#v created (roles: %s, auth-source: %d, projects: %s)", user.Username, rolesJson, user.AuthSource, projectsJson) - return nil -} - -func (auth *Authentication) DelUser(username string) error { - - _, err := auth.db.Exec(`DELETE FROM user WHERE user.username = ?`, username) - log.Errorf("Error while deleting user '%s' from DB", username) - return err -} - -func (auth *Authentication) ListUsers(specialsOnly bool) ([]*User, error) { - - q := sq.Select("username", "name", "email", "roles", "projects").From("user") - if specialsOnly { - q = q.Where("(roles != '[\"user\"]' AND roles != '[]')") - } - - rows, err := q.RunWith(auth.db).Query() - if err != nil { - log.Warn("Error while querying user list") - return nil, err - } - - users := make([]*User, 0) - defer rows.Close() - for rows.Next() { - rawroles := "" - rawprojects := "" - user := &User{} - var name, email sql.NullString - if err := rows.Scan(&user.Username, &name, &email, &rawroles, &rawprojects); err != nil { - log.Warn("Error while scanning user list") - return nil, err - } - - if err := json.Unmarshal([]byte(rawroles), &user.Roles); err != nil { - log.Warn("Error while unmarshaling raw role list") - return nil, err - } - - if err := json.Unmarshal([]byte(rawprojects), &user.Projects); err != nil { - return nil, err - } - - user.Name = name.String - user.Email = email.String - users = append(users, user) - } - return users, nil -} - -func (auth *Authentication) AddRole( - ctx context.Context, - username string, - queryrole string) error { - - newRole := strings.ToLower(queryrole) - user, err := auth.GetUser(username) - if err != nil { - log.Warnf("Could not load user '%s'", username) - return err - } - - exists, valid := user.HasValidRole(newRole) - - if !valid { - return fmt.Errorf("Supplied role is no valid option : %v", newRole) - } - if exists { - return fmt.Errorf("User %v already has role %v", username, newRole) - } - - roles, _ := json.Marshal(append(user.Roles, newRole)) - if _, err := sq.Update("user").Set("roles", roles).Where("user.username = ?", username).RunWith(auth.db).Exec(); err != nil { - log.Errorf("Error while adding new role for user '%s'", user.Username) - return err - } - return nil -} - -func (auth *Authentication) RemoveRole(ctx context.Context, username string, queryrole string) error { - oldRole := strings.ToLower(queryrole) - user, err := auth.GetUser(username) - if err != nil { - log.Warnf("Could not load user '%s'", username) - return err - } - - exists, valid := user.HasValidRole(oldRole) - - if !valid { - return fmt.Errorf("Supplied role is no valid option : %v", oldRole) - } - if !exists { - return fmt.Errorf("Role already deleted for user '%v': %v", username, oldRole) - } - - if oldRole == GetRoleString(RoleManager) && len(user.Projects) != 0 { - return fmt.Errorf("Cannot remove role 'manager' while user %s still has assigned project(s) : %v", username, user.Projects) - } - - var newroles []string - for _, r := range user.Roles { - if r != oldRole { - newroles = append(newroles, r) // Append all roles not matching requested to be deleted role - } - } - - var mroles, _ = json.Marshal(newroles) - if _, err := sq.Update("user").Set("roles", mroles).Where("user.username = ?", username).RunWith(auth.db).Exec(); err != nil { - log.Errorf("Error while removing role for user '%s'", user.Username) - return err - } - return nil -} - -func (auth *Authentication) AddProject( - ctx context.Context, - username string, - project string) error { - - user, err := auth.GetUser(username) - if err != nil { - return err - } - - if !user.HasRole(RoleManager) { - return fmt.Errorf("user '%s' is not a manager!", username) - } - - if user.HasProject(project) { - return fmt.Errorf("user '%s' already manages project '%s'", username, project) - } - - projects, _ := json.Marshal(append(user.Projects, project)) - if _, err := sq.Update("user").Set("projects", projects).Where("user.username = ?", username).RunWith(auth.db).Exec(); err != nil { - return err - } - - return nil -} - -func (auth *Authentication) RemoveProject(ctx context.Context, username string, project string) error { - user, err := auth.GetUser(username) - if err != nil { - return err - } - - if !user.HasRole(RoleManager) { - return fmt.Errorf("user '%#v' is not a manager!", username) - } - - if !user.HasProject(project) { - return fmt.Errorf("user '%#v': Cannot remove project '%#v' - Does not match!", username, project) - } - - var exists bool - var newprojects []string - for _, p := range user.Projects { - if p != project { - newprojects = append(newprojects, p) // Append all projects not matching requested to be deleted project - } else { - exists = true - } - } - - if exists == true { - var result interface{} - if len(newprojects) == 0 { - result = "[]" - } else { - result, _ = json.Marshal(newprojects) - } - if _, err := sq.Update("user").Set("projects", result).Where("user.username = ?", username).RunWith(auth.db).Exec(); err != nil { - return err - } - return nil - } else { - return fmt.Errorf("user %s already does not manage project %s", username, project) - } -} - -func FetchUser(ctx context.Context, db *sqlx.DB, username string) (*model.User, error) { - me := GetUser(ctx) - if me != nil && me.Username != username && me.HasNotRoles([]Role{RoleAdmin, RoleSupport, RoleManager}) { - return nil, errors.New("forbidden") - } - - user := &model.User{Username: username} - var name, email sql.NullString - if err := sq.Select("name", "email").From("user").Where("user.username = ?", username). - RunWith(db).QueryRow().Scan(&name, &email); err != nil { - if err == sql.ErrNoRows { - /* This warning will be logged *often* for non-local users, i.e. users mentioned only in job-table or archive, */ - /* since FetchUser will be called to retrieve full name and mail for every job in query/list */ - // log.Warnf("User '%s' Not found in DB", username) - return nil, nil - } - - log.Warnf("Error while fetching user '%s'", username) - return nil, err - } - - user.Name = name.String - user.Email = email.String - return user, nil -} diff --git a/internal/graph/schema.resolvers.go b/internal/graph/schema.resolvers.go index cd24a2a..1be455c 100644 --- a/internal/graph/schema.resolvers.go +++ b/internal/graph/schema.resolvers.go @@ -11,7 +11,6 @@ import ( "strconv" "time" - "github.com/ClusterCockpit/cc-backend/internal/auth" "github.com/ClusterCockpit/cc-backend/internal/graph/generated" "github.com/ClusterCockpit/cc-backend/internal/graph/model" "github.com/ClusterCockpit/cc-backend/internal/metricdata" @@ -51,7 +50,7 @@ func (r *jobResolver) MetaData(ctx context.Context, obj *schema.Job) (interface{ // UserData is the resolver for the userData field. func (r *jobResolver) UserData(ctx context.Context, obj *schema.Job) (*model.User, error) { - return auth.FetchUser(ctx, r.DB, obj.User) + return repository.GetUserRepository().FetchUserInCtx(ctx, obj.User) } // CreateTag is the resolver for the createTag field. @@ -122,7 +121,7 @@ func (r *mutationResolver) RemoveTagsFromJob(ctx context.Context, job string, ta // UpdateConfiguration is the resolver for the updateConfiguration field. func (r *mutationResolver) UpdateConfiguration(ctx context.Context, name string, value string) (*string, error) { - if err := repository.GetUserCfgRepo().UpdateConfig(name, value, auth.GetUser(ctx)); err != nil { + if err := repository.GetUserCfgRepo().UpdateConfig(name, value, repository.GetUserFromContext(ctx)); err != nil { log.Warn("Error while updating user config") return nil, err } @@ -142,7 +141,7 @@ func (r *queryResolver) Tags(ctx context.Context) ([]*schema.Tag, error) { // User is the resolver for the user field. func (r *queryResolver) User(ctx context.Context, username string) (*model.User, error) { - return auth.FetchUser(ctx, r.DB, username) + return repository.GetUserRepository().FetchUserInCtx(ctx, username) } // AllocatedNodes is the resolver for the allocatedNodes field. @@ -178,7 +177,9 @@ func (r *queryResolver) Job(ctx context.Context, id string) (*schema.Job, error) return nil, err } - if user := auth.GetUser(ctx); user != nil && job.User != user.Username && user.HasNotRoles([]auth.Role{auth.RoleAdmin, auth.RoleSupport, auth.RoleManager}) { + if user := repository.GetUserFromContext(ctx); user != nil && + job.User != user.Username && + user.HasNotRoles([]schema.Role{schema.RoleAdmin, schema.RoleSupport, schema.RoleManager}) { return nil, errors.New("you are not allowed to see this job") } @@ -318,8 +319,8 @@ func (r *queryResolver) RooflineHeatmap(ctx context.Context, filter []*model.Job // NodeMetrics is the resolver for the nodeMetrics field. func (r *queryResolver) NodeMetrics(ctx context.Context, cluster string, nodes []string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time) ([]*model.NodeMetrics, error) { - user := auth.GetUser(ctx) - if user != nil && !user.HasRole(auth.RoleAdmin) { + user := repository.GetUserFromContext(ctx) + if user != nil && !user.HasRole(schema.RoleAdmin) { return nil, errors.New("you need to be an administrator for this query") } diff --git a/internal/repository/job.go b/internal/repository/job.go index b712a86..449c267 100644 --- a/internal/repository/job.go +++ b/internal/repository/job.go @@ -14,7 +14,6 @@ import ( "sync" "time" - "github.com/ClusterCockpit/cc-backend/internal/auth" "github.com/ClusterCockpit/cc-backend/internal/graph/model" "github.com/ClusterCockpit/cc-backend/internal/metricdata" "github.com/ClusterCockpit/cc-backend/pkg/log" @@ -615,7 +614,7 @@ func (r *JobRepository) WaitForArchiving() { r.archivePending.Wait() } -func (r *JobRepository) FindUserOrProjectOrJobname(user *auth.User, searchterm string) (jobid string, username string, project string, jobname string) { +func (r *JobRepository) FindUserOrProjectOrJobname(user *schema.User, searchterm string) (jobid string, username string, project string, jobname string) { if _, err := strconv.Atoi(searchterm); err == nil { // Return empty on successful conversion: parent method will redirect for integer jobId return searchterm, "", "", "" } else { // Has to have letters and logged-in user for other guesses @@ -644,14 +643,14 @@ func (r *JobRepository) FindUserOrProjectOrJobname(user *auth.User, searchterm s var ErrNotFound = errors.New("no such jobname, project or user") var ErrForbidden = errors.New("not authorized") -func (r *JobRepository) FindColumnValue(user *auth.User, searchterm string, table string, selectColumn string, whereColumn string, isLike bool) (result string, err error) { +func (r *JobRepository) FindColumnValue(user *schema.User, searchterm string, table string, selectColumn string, whereColumn string, isLike bool) (result string, err error) { compareStr := " = ?" query := searchterm if isLike { compareStr = " LIKE ?" query = "%" + searchterm + "%" } - if user.HasAnyRole([]auth.Role{auth.RoleAdmin, auth.RoleSupport, auth.RoleManager}) { + if user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport, schema.RoleManager}) { theQuery := sq.Select(table+"."+selectColumn).Distinct().From(table). Where(table+"."+whereColumn+compareStr, query) @@ -676,9 +675,9 @@ func (r *JobRepository) FindColumnValue(user *auth.User, searchterm string, tabl } } -func (r *JobRepository) FindColumnValues(user *auth.User, query string, table string, selectColumn string, whereColumn string) (results []string, err error) { +func (r *JobRepository) FindColumnValues(user *schema.User, query string, table string, selectColumn string, whereColumn string) (results []string, err error) { emptyResult := make([]string, 0) - if user.HasAnyRole([]auth.Role{auth.RoleAdmin, auth.RoleSupport, auth.RoleManager}) { + if user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport, schema.RoleManager}) { rows, err := sq.Select(table+"."+selectColumn).Distinct().From(table). Where(table+"."+whereColumn+" LIKE ?", fmt.Sprint("%", query, "%")). RunWith(r.stmtCache).Query() diff --git a/internal/repository/query.go b/internal/repository/query.go index 02e5304..0501fe1 100644 --- a/internal/repository/query.go +++ b/internal/repository/query.go @@ -12,7 +12,6 @@ import ( "strings" "time" - "github.com/ClusterCockpit/cc-backend/internal/auth" "github.com/ClusterCockpit/cc-backend/internal/graph/model" "github.com/ClusterCockpit/cc-backend/pkg/log" "github.com/ClusterCockpit/cc-backend/pkg/schema" @@ -130,20 +129,20 @@ func (r *JobRepository) CountJobs( } func SecurityCheck(ctx context.Context, query sq.SelectBuilder) (sq.SelectBuilder, error) { - user := auth.GetUser(ctx) + user := GetUserFromContext(ctx) if user == nil { var qnil sq.SelectBuilder return qnil, fmt.Errorf("user context is nil!") - } else if user.HasAnyRole([]auth.Role{auth.RoleAdmin, auth.RoleSupport, auth.RoleApi}) { // Admin & Co. : All jobs + } else if user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport, schema.RoleApi}) { // Admin & Co. : All jobs return query, nil - } else if user.HasRole(auth.RoleManager) { // Manager : Add filter for managed projects' jobs only + personal jobs + } else if user.HasRole(schema.RoleManager) { // Manager : Add filter for managed projects' jobs only + personal jobs if len(user.Projects) != 0 { return query.Where(sq.Or{sq.Eq{"job.project": user.Projects}, sq.Eq{"job.user": user.Username}}), nil } else { log.Debugf("Manager-User '%s' has no defined projects to lookup! Query only personal jobs ...", user.Username) return query.Where("job.user = ?", user.Username), nil } - } else if user.HasRole(auth.RoleUser) { // User : Only personal jobs + } else if user.HasRole(schema.RoleUser) { // User : Only personal jobs return query.Where("job.user = ?", user.Username), nil } else { // Shortterm compatibility: Return User-Query if no roles: diff --git a/internal/repository/stats.go b/internal/repository/stats.go index 158d34a..8a74008 100644 --- a/internal/repository/stats.go +++ b/internal/repository/stats.go @@ -10,7 +10,6 @@ import ( "fmt" "time" - "github.com/ClusterCockpit/cc-backend/internal/auth" "github.com/ClusterCockpit/cc-backend/internal/config" "github.com/ClusterCockpit/cc-backend/internal/graph/model" "github.com/ClusterCockpit/cc-backend/pkg/log" @@ -86,7 +85,7 @@ func (r *JobRepository) buildStatsQuery( } func (r *JobRepository) getUserName(ctx context.Context, id string) string { - user := auth.GetUser(ctx) + user := GetUserFromContext(ctx) name, _ := r.FindColumnValue(user, id, "user", "name", "username", false) if name != "" { return name diff --git a/internal/repository/tags.go b/internal/repository/tags.go index 6c46352..52bc836 100644 --- a/internal/repository/tags.go +++ b/internal/repository/tags.go @@ -7,7 +7,6 @@ package repository import ( "strings" - "github.com/ClusterCockpit/cc-backend/internal/auth" "github.com/ClusterCockpit/cc-backend/pkg/archive" "github.com/ClusterCockpit/cc-backend/pkg/log" "github.com/ClusterCockpit/cc-backend/pkg/schema" @@ -68,7 +67,7 @@ func (r *JobRepository) CreateTag(tagType string, tagName string) (tagId int64, return res.LastInsertId() } -func (r *JobRepository) CountTags(user *auth.User) (tags []schema.Tag, counts map[string]int, err error) { +func (r *JobRepository) CountTags(user *schema.User) (tags []schema.Tag, counts map[string]int, err error) { tags = make([]schema.Tag, 0, 100) xrows, err := r.DB.Queryx("SELECT id, tag_type, tag_name FROM tag") if err != nil { @@ -88,10 +87,10 @@ func (r *JobRepository) CountTags(user *auth.User) (tags []schema.Tag, counts ma LeftJoin("jobtag jt ON t.id = jt.tag_id"). GroupBy("t.tag_name") - if user != nil && user.HasAnyRole([]auth.Role{auth.RoleAdmin, auth.RoleSupport}) { // ADMIN || SUPPORT: Count all jobs + if user != nil && user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) { // ADMIN || SUPPORT: Count all jobs log.Debug("CountTags: User Admin or Support -> Count all Jobs for Tags") // Unchanged: Needs to be own case still, due to UserRole/NoRole compatibility handling in else case - } else if user != nil && user.HasRole(auth.RoleManager) { // MANAGER: Count own jobs plus project's jobs + } else if user != nil && user.HasRole(schema.RoleManager) { // MANAGER: Count own jobs plus project's jobs // Build ("project1", "project2", ...) list of variable length directly in SQL string q = q.Where("jt.job_id IN (SELECT id FROM job WHERE job.user = ? OR job.project IN (\""+strings.Join(user.Projects, "\",\"")+"\"))", user.Username) } else if user != nil { // USER OR NO ROLE (Compatibility): Only count own jobs diff --git a/internal/repository/user.go b/internal/repository/user.go index 6a6fe62..3db7e4d 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -1,137 +1,325 @@ -// Copyright (C) 2022 NHR@FAU, University Erlangen-Nuremberg. +// Copyright (C) 2023 NHR@FAU, University Erlangen-Nuremberg. // All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. package repository import ( + "context" + "database/sql" "encoding/json" + "errors" + "fmt" + "strings" "sync" - "time" - "github.com/ClusterCockpit/cc-backend/internal/auth" - "github.com/ClusterCockpit/cc-backend/internal/config" + "github.com/ClusterCockpit/cc-backend/internal/graph/model" "github.com/ClusterCockpit/cc-backend/pkg/log" - "github.com/ClusterCockpit/cc-backend/pkg/lrucache" + "github.com/ClusterCockpit/cc-backend/pkg/schema" + sq "github.com/Masterminds/squirrel" "github.com/jmoiron/sqlx" + "golang.org/x/crypto/bcrypt" ) var ( - userCfgRepoOnce sync.Once - userCfgRepoInstance *UserCfgRepo + userRepoOnce sync.Once + userRepoInstance *UserRepository ) -type UserCfgRepo struct { - DB *sqlx.DB - Lookup *sqlx.Stmt - lock sync.RWMutex - uiDefaults map[string]interface{} - cache *lrucache.Cache +type UserRepository struct { + DB *sqlx.DB + driver string } -func GetUserCfgRepo() *UserCfgRepo { - userCfgRepoOnce.Do(func() { +func GetUserRepository() *UserRepository { + userRepoOnce.Do(func() { db := GetConnection() - lookupConfigStmt, err := db.DB.Preparex(`SELECT confkey, value FROM configuration WHERE configuration.username = ?`) - if err != nil { - log.Fatalf("db.DB.Preparex() error: %v", err) - } - - userCfgRepoInstance = &UserCfgRepo{ - DB: db.DB, - Lookup: lookupConfigStmt, - uiDefaults: config.Keys.UiDefaults, - cache: lrucache.New(1024), + userRepoInstance = &UserRepository{ + DB: db.DB, + driver: db.Driver, } }) - - return userCfgRepoInstance + return userRepoInstance } -// Return the personalised UI config for the currently authenticated -// user or return the plain default config. -func (uCfg *UserCfgRepo) GetUIConfig(user *auth.User) (map[string]interface{}, error) { - if user == nil { - uCfg.lock.RLock() - copy := make(map[string]interface{}, len(uCfg.uiDefaults)) - for k, v := range uCfg.uiDefaults { - copy[k] = v - } - uCfg.lock.RUnlock() - return copy, nil - } - - data := uCfg.cache.Get(user.Username, func() (interface{}, time.Duration, int) { - uiconfig := make(map[string]interface{}, len(uCfg.uiDefaults)) - for k, v := range uCfg.uiDefaults { - uiconfig[k] = v - } - - rows, err := uCfg.Lookup.Query(user.Username) - if err != nil { - log.Warnf("Error while looking up user uiconfig for user '%v'", user.Username) - return err, 0, 0 - } - - size := 0 - defer rows.Close() - for rows.Next() { - var key, rawval string - if err := rows.Scan(&key, &rawval); err != nil { - log.Warn("Error while scanning user uiconfig values") - return err, 0, 0 - } - - var val interface{} - if err := json.Unmarshal([]byte(rawval), &val); err != nil { - log.Warn("Error while unmarshaling raw user uiconfig json") - return err, 0, 0 - } - - size += len(key) - size += len(rawval) - uiconfig[key] = val - } - - // Add global ShortRunningJobsDuration setting as plot_list_hideShortRunningJobs - uiconfig["plot_list_hideShortRunningJobs"] = config.Keys.ShortRunningJobsDuration - - return uiconfig, 24 * time.Hour, size - }) - if err, ok := data.(error); ok { - log.Error("Error in returned dataset") +func (r *UserRepository) GetUser(username string) (*schema.User, error) { + user := &schema.User{Username: username} + var hashedPassword, name, rawRoles, email, rawProjects sql.NullString + if err := sq.Select("password", "ldap", "name", "roles", "email", "projects").From("user"). + Where("user.username = ?", username).RunWith(r.DB). + QueryRow().Scan(&hashedPassword, &user.AuthSource, &name, &rawRoles, &email, &rawProjects); err != nil { + log.Warnf("Error while querying user '%v' from database", username) return nil, err } - return data.(map[string]interface{}), nil -} - -// If the context does not have a user, update the global ui configuration -// without persisting it! If there is a (authenticated) user, update only his -// configuration. -func (uCfg *UserCfgRepo) UpdateConfig( - key, value string, - user *auth.User) error { - - if user == nil { - var val interface{} - if err := json.Unmarshal([]byte(value), &val); err != nil { - log.Warn("Error while unmarshaling raw user config json") - return err + user.Password = hashedPassword.String + user.Name = name.String + user.Email = email.String + if rawRoles.Valid { + if err := json.Unmarshal([]byte(rawRoles.String), &user.Roles); err != nil { + log.Warn("Error while unmarshaling raw roles from DB") + return nil, err + } + } + if rawProjects.Valid { + if err := json.Unmarshal([]byte(rawProjects.String), &user.Projects); err != nil { + return nil, err } - - uCfg.lock.Lock() - defer uCfg.lock.Unlock() - uCfg.uiDefaults[key] = val - return nil } - if _, err := uCfg.DB.Exec(`REPLACE INTO configuration (username, confkey, value) VALUES (?, ?, ?)`, user.Username, key, value); err != nil { - log.Warnf("Error while replacing user config in DB for user '%v'", user.Username) + return user, nil +} + +func (r *UserRepository) AddUser(user *schema.User) error { + rolesJson, _ := json.Marshal(user.Roles) + projectsJson, _ := json.Marshal(user.Projects) + + cols := []string{"username", "roles", "projects"} + vals := []interface{}{user.Username, string(rolesJson), string(projectsJson)} + + if user.Name != "" { + cols = append(cols, "name") + vals = append(vals, user.Name) + } + if user.Email != "" { + cols = append(cols, "email") + vals = append(vals, user.Email) + } + if user.Password != "" { + password, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost) + if err != nil { + log.Error("Error while encrypting new user password") + return err + } + cols = append(cols, "password") + vals = append(vals, string(password)) + } + + if _, err := sq.Insert("user").Columns(cols...).Values(vals...).RunWith(r.DB).Exec(); err != nil { + log.Errorf("Error while inserting new user '%v' into DB", user.Username) return err } - uCfg.cache.Del(user.Username) + log.Infof("new user %#v created (roles: %s, auth-source: %d, projects: %s)", user.Username, rolesJson, user.AuthSource, projectsJson) return nil } + +func (r *UserRepository) DelUser(username string) error { + + _, err := r.DB.Exec(`DELETE FROM user WHERE user.username = ?`, username) + log.Errorf("Error while deleting user '%s' from DB", username) + return err +} + +func (r *UserRepository) ListUsers(specialsOnly bool) ([]*schema.User, error) { + + q := sq.Select("username", "name", "email", "roles", "projects").From("user") + if specialsOnly { + q = q.Where("(roles != '[\"user\"]' AND roles != '[]')") + } + + rows, err := q.RunWith(r.DB).Query() + if err != nil { + log.Warn("Error while querying user list") + return nil, err + } + + users := make([]*schema.User, 0) + defer rows.Close() + for rows.Next() { + rawroles := "" + rawprojects := "" + user := &schema.User{} + var name, email sql.NullString + if err := rows.Scan(&user.Username, &name, &email, &rawroles, &rawprojects); err != nil { + log.Warn("Error while scanning user list") + return nil, err + } + + if err := json.Unmarshal([]byte(rawroles), &user.Roles); err != nil { + log.Warn("Error while unmarshaling raw role list") + return nil, err + } + + if err := json.Unmarshal([]byte(rawprojects), &user.Projects); err != nil { + return nil, err + } + + user.Name = name.String + user.Email = email.String + users = append(users, user) + } + return users, nil +} + +func (r *UserRepository) AddRole( + ctx context.Context, + username string, + queryrole string) error { + + newRole := strings.ToLower(queryrole) + user, err := r.GetUser(username) + if err != nil { + log.Warnf("Could not load user '%s'", username) + return err + } + + exists, valid := user.HasValidRole(newRole) + + if !valid { + return fmt.Errorf("Supplied role is no valid option : %v", newRole) + } + if exists { + return fmt.Errorf("User %v already has role %v", username, newRole) + } + + roles, _ := json.Marshal(append(user.Roles, newRole)) + if _, err := sq.Update("user").Set("roles", roles).Where("user.username = ?", username).RunWith(r.DB).Exec(); err != nil { + log.Errorf("Error while adding new role for user '%s'", user.Username) + return err + } + return nil +} + +func (r *UserRepository) RemoveRole(ctx context.Context, username string, queryrole string) error { + oldRole := strings.ToLower(queryrole) + user, err := r.GetUser(username) + if err != nil { + log.Warnf("Could not load user '%s'", username) + return err + } + + exists, valid := user.HasValidRole(oldRole) + + if !valid { + return fmt.Errorf("Supplied role is no valid option : %v", oldRole) + } + if !exists { + return fmt.Errorf("Role already deleted for user '%v': %v", username, oldRole) + } + + if oldRole == schema.GetRoleString(schema.RoleManager) && len(user.Projects) != 0 { + return fmt.Errorf("Cannot remove role 'manager' while user %s still has assigned project(s) : %v", username, user.Projects) + } + + var newroles []string + for _, r := range user.Roles { + if r != oldRole { + newroles = append(newroles, r) // Append all roles not matching requested to be deleted role + } + } + + var mroles, _ = json.Marshal(newroles) + if _, err := sq.Update("user").Set("roles", mroles).Where("user.username = ?", username).RunWith(r.DB).Exec(); err != nil { + log.Errorf("Error while removing role for user '%s'", user.Username) + return err + } + return nil +} + +func (r *UserRepository) AddProject( + ctx context.Context, + username string, + project string) error { + + user, err := r.GetUser(username) + if err != nil { + return err + } + + if !user.HasRole(schema.RoleManager) { + return fmt.Errorf("user '%s' is not a manager!", username) + } + + if user.HasProject(project) { + return fmt.Errorf("user '%s' already manages project '%s'", username, project) + } + + projects, _ := json.Marshal(append(user.Projects, project)) + if _, err := sq.Update("user").Set("projects", projects).Where("user.username = ?", username).RunWith(r.DB).Exec(); err != nil { + return err + } + + return nil +} + +func (r *UserRepository) RemoveProject(ctx context.Context, username string, project string) error { + user, err := r.GetUser(username) + if err != nil { + return err + } + + if !user.HasRole(schema.RoleManager) { + return fmt.Errorf("user '%#v' is not a manager!", username) + } + + if !user.HasProject(project) { + return fmt.Errorf("user '%#v': Cannot remove project '%#v' - Does not match!", username, project) + } + + var exists bool + var newprojects []string + for _, p := range user.Projects { + if p != project { + newprojects = append(newprojects, p) // Append all projects not matching requested to be deleted project + } else { + exists = true + } + } + + if exists == true { + var result interface{} + if len(newprojects) == 0 { + result = "[]" + } else { + result, _ = json.Marshal(newprojects) + } + if _, err := sq.Update("user").Set("projects", result).Where("user.username = ?", username).RunWith(r.DB).Exec(); err != nil { + return err + } + return nil + } else { + return fmt.Errorf("user %s already does not manage project %s", username, project) + } +} + +type ContextKey string + +const ContextUserKey ContextKey = "user" + +func GetUserFromContext(ctx context.Context) *schema.User { + x := ctx.Value(ContextUserKey) + if x == nil { + return nil + } + + return x.(*schema.User) +} + +func (r *UserRepository) FetchUserInCtx(ctx context.Context, username string) (*model.User, error) { + me := GetUserFromContext(ctx) + if me != nil && me.Username != username && + me.HasNotRoles([]schema.Role{schema.RoleAdmin, schema.RoleSupport, schema.RoleManager}) { + return nil, errors.New("forbidden") + } + + user := &model.User{Username: username} + var name, email sql.NullString + if err := sq.Select("name", "email").From("user").Where("user.username = ?", username). + RunWith(r.DB).QueryRow().Scan(&name, &email); err != nil { + if err == sql.ErrNoRows { + /* This warning will be logged *often* for non-local users, i.e. users mentioned only in job-table or archive, */ + /* since FetchUser will be called to retrieve full name and mail for every job in query/list */ + // log.Warnf("User '%s' Not found in DB", username) + return nil, nil + } + + log.Warnf("Error while fetching user '%s'", username) + return nil, err + } + + user.Name = name.String + user.Email = email.String + return user, nil +} diff --git a/internal/repository/userConfig.go b/internal/repository/userConfig.go new file mode 100644 index 0000000..fb8c3f5 --- /dev/null +++ b/internal/repository/userConfig.go @@ -0,0 +1,137 @@ +// Copyright (C) 2022 NHR@FAU, University Erlangen-Nuremberg. +// All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. +package repository + +import ( + "encoding/json" + "sync" + "time" + + "github.com/ClusterCockpit/cc-backend/internal/config" + "github.com/ClusterCockpit/cc-backend/pkg/log" + "github.com/ClusterCockpit/cc-backend/pkg/lrucache" + "github.com/ClusterCockpit/cc-backend/pkg/schema" + "github.com/jmoiron/sqlx" +) + +var ( + userCfgRepoOnce sync.Once + userCfgRepoInstance *UserCfgRepo +) + +type UserCfgRepo struct { + DB *sqlx.DB + Lookup *sqlx.Stmt + lock sync.RWMutex + uiDefaults map[string]interface{} + cache *lrucache.Cache +} + +func GetUserCfgRepo() *UserCfgRepo { + userCfgRepoOnce.Do(func() { + db := GetConnection() + + lookupConfigStmt, err := db.DB.Preparex(`SELECT confkey, value FROM configuration WHERE configuration.username = ?`) + if err != nil { + log.Fatalf("db.DB.Preparex() error: %v", err) + } + + userCfgRepoInstance = &UserCfgRepo{ + DB: db.DB, + Lookup: lookupConfigStmt, + uiDefaults: config.Keys.UiDefaults, + cache: lrucache.New(1024), + } + }) + + return userCfgRepoInstance +} + +// Return the personalised UI config for the currently authenticated +// user or return the plain default config. +func (uCfg *UserCfgRepo) GetUIConfig(user *schema.User) (map[string]interface{}, error) { + if user == nil { + uCfg.lock.RLock() + copy := make(map[string]interface{}, len(uCfg.uiDefaults)) + for k, v := range uCfg.uiDefaults { + copy[k] = v + } + uCfg.lock.RUnlock() + return copy, nil + } + + data := uCfg.cache.Get(user.Username, func() (interface{}, time.Duration, int) { + uiconfig := make(map[string]interface{}, len(uCfg.uiDefaults)) + for k, v := range uCfg.uiDefaults { + uiconfig[k] = v + } + + rows, err := uCfg.Lookup.Query(user.Username) + if err != nil { + log.Warnf("Error while looking up user uiconfig for user '%v'", user.Username) + return err, 0, 0 + } + + size := 0 + defer rows.Close() + for rows.Next() { + var key, rawval string + if err := rows.Scan(&key, &rawval); err != nil { + log.Warn("Error while scanning user uiconfig values") + return err, 0, 0 + } + + var val interface{} + if err := json.Unmarshal([]byte(rawval), &val); err != nil { + log.Warn("Error while unmarshaling raw user uiconfig json") + return err, 0, 0 + } + + size += len(key) + size += len(rawval) + uiconfig[key] = val + } + + // Add global ShortRunningJobsDuration setting as plot_list_hideShortRunningJobs + uiconfig["plot_list_hideShortRunningJobs"] = config.Keys.ShortRunningJobsDuration + + return uiconfig, 24 * time.Hour, size + }) + if err, ok := data.(error); ok { + log.Error("Error in returned dataset") + return nil, err + } + + return data.(map[string]interface{}), nil +} + +// If the context does not have a user, update the global ui configuration +// without persisting it! If there is a (authenticated) user, update only his +// configuration. +func (uCfg *UserCfgRepo) UpdateConfig( + key, value string, + user *schema.User) error { + + if user == nil { + var val interface{} + if err := json.Unmarshal([]byte(value), &val); err != nil { + log.Warn("Error while unmarshaling raw user config json") + return err + } + + uCfg.lock.Lock() + defer uCfg.lock.Unlock() + uCfg.uiDefaults[key] = val + return nil + } + + if _, err := uCfg.DB.Exec(`REPLACE INTO configuration (username, confkey, value) VALUES (?, ?, ?)`, user.Username, key, value); err != nil { + log.Warnf("Error while replacing user config in DB for user '%v'", user.Username) + return err + } + + uCfg.cache.Del(user.Username) + return nil +} diff --git a/internal/repository/user_test.go b/internal/repository/userConfig_test.go similarity index 94% rename from internal/repository/user_test.go rename to internal/repository/userConfig_test.go index ac3b0d5..0beec7b 100644 --- a/internal/repository/user_test.go +++ b/internal/repository/userConfig_test.go @@ -9,9 +9,9 @@ import ( "path/filepath" "testing" - "github.com/ClusterCockpit/cc-backend/internal/auth" "github.com/ClusterCockpit/cc-backend/internal/config" "github.com/ClusterCockpit/cc-backend/pkg/log" + "github.com/ClusterCockpit/cc-backend/pkg/schema" _ "github.com/mattn/go-sqlite3" ) @@ -53,7 +53,7 @@ func setupUserTest(t *testing.T) *UserCfgRepo { func TestGetUIConfig(t *testing.T) { r := setupUserTest(t) - u := auth.User{Username: "demo"} + u := schema.User{Username: "demo"} cfg, err := r.GetUIConfig(&u) if err != nil { diff --git a/internal/routerConfig/routes.go b/internal/routerConfig/routes.go index 5fad301..322cbf3 100644 --- a/internal/routerConfig/routes.go +++ b/internal/routerConfig/routes.go @@ -13,11 +13,11 @@ import ( "strings" "time" - "github.com/ClusterCockpit/cc-backend/internal/auth" "github.com/ClusterCockpit/cc-backend/internal/graph/model" "github.com/ClusterCockpit/cc-backend/internal/repository" "github.com/ClusterCockpit/cc-backend/internal/util" "github.com/ClusterCockpit/cc-backend/pkg/log" + "github.com/ClusterCockpit/cc-backend/pkg/schema" "github.com/ClusterCockpit/cc-backend/web" "github.com/gorilla/mux" ) @@ -81,12 +81,11 @@ func setupJobRoute(i InfoType, r *http.Request) InfoType { } func setupUserRoute(i InfoType, r *http.Request) InfoType { - jobRepo := repository.GetJobRepository() username := mux.Vars(r)["id"] i["id"] = username i["username"] = username // TODO: If forbidden (== err exists), redirect to error page - if user, _ := auth.FetchUser(r.Context(), jobRepo.DB, username); user != nil { + if user, _ := repository.GetUserRepository().FetchUserInCtx(r.Context(), username); user != nil { i["name"] = user.Name i["email"] = user.Email } @@ -125,7 +124,7 @@ func setupAnalysisRoute(i InfoType, r *http.Request) InfoType { func setupTaglistRoute(i InfoType, r *http.Request) InfoType { jobRepo := repository.GetJobRepository() - user := auth.GetUser(r.Context()) + user := repository.GetUserFromContext(r.Context()) tags, counts, err := jobRepo.CountTags(user) tagMap := make(map[string][]map[string]interface{}) @@ -255,7 +254,7 @@ func SetupRoutes(router *mux.Router, buildInfo web.Build) { for _, route := range routes { route := route router.HandleFunc(route.Route, func(rw http.ResponseWriter, r *http.Request) { - conf, err := userCfgRepo.GetUIConfig(auth.GetUser(r.Context())) + conf, err := userCfgRepo.GetUIConfig(repository.GetUserFromContext(r.Context())) if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return @@ -268,9 +267,9 @@ func SetupRoutes(router *mux.Router, buildInfo web.Build) { } // Get User -> What if NIL? - user := auth.GetUser(r.Context()) + user := repository.GetUserFromContext(r.Context()) // Get Roles - availableRoles, _ := auth.GetValidRolesMap(user) + availableRoles, _ := schema.GetValidRolesMap(user) page := web.Page{ Title: title, @@ -285,14 +284,14 @@ func SetupRoutes(router *mux.Router, buildInfo web.Build) { page.FilterPresets = buildFilterPresets(r.URL.Query()) } - web.RenderTemplate(rw, r, route.Template, &page) + web.RenderTemplate(rw, route.Template, &page) }) } } func HandleSearchBar(rw http.ResponseWriter, r *http.Request, buildInfo web.Build) { - user := auth.GetUser(r.Context()) - availableRoles, _ := auth.GetValidRolesMap(user) + user := repository.GetUserFromContext(r.Context()) + availableRoles, _ := schema.GetValidRolesMap(user) if search := r.URL.Query().Get("searchId"); search != "" { repo := repository.GetJobRepository() @@ -309,10 +308,10 @@ func HandleSearchBar(rw http.ResponseWriter, r *http.Request, buildInfo web.Buil case "arrayJobId": http.Redirect(rw, r, "/monitoring/jobs/?arrayJobId="+url.QueryEscape(strings.Trim(splitSearch[1], " ")), http.StatusFound) // All Users: Redirect to Tablequery case "username": - if user.HasAnyRole([]auth.Role{auth.RoleAdmin, auth.RoleSupport, auth.RoleManager}) { + if user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport, schema.RoleManager}) { http.Redirect(rw, r, "/monitoring/users/?user="+url.QueryEscape(strings.Trim(splitSearch[1], " ")), http.StatusFound) } else { - web.RenderTemplate(rw, r, "message.tmpl", &web.Page{Title: "Error", MsgType: "alert-danger", Message: "Missing Access Rights", User: *user, Roles: availableRoles, Build: buildInfo}) + web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Error", MsgType: "alert-danger", Message: "Missing Access Rights", User: *user, Roles: availableRoles, Build: buildInfo}) } case "name": usernames, _ := repo.FindColumnValues(user, strings.Trim(splitSearch[1], " "), "user", "username", "name") @@ -320,14 +319,14 @@ func HandleSearchBar(rw http.ResponseWriter, r *http.Request, buildInfo web.Buil joinedNames := strings.Join(usernames, "&user=") http.Redirect(rw, r, "/monitoring/users/?user="+joinedNames, http.StatusFound) } else { - if user.HasAnyRole([]auth.Role{auth.RoleAdmin, auth.RoleSupport, auth.RoleManager}) { + if user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport, schema.RoleManager}) { http.Redirect(rw, r, "/monitoring/users/?user=NoUserNameFound", http.StatusPermanentRedirect) } else { - web.RenderTemplate(rw, r, "message.tmpl", &web.Page{Title: "Error", MsgType: "alert-danger", Message: "Missing Access Rights", User: *user, Roles: availableRoles, Build: buildInfo}) + web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Error", MsgType: "alert-danger", Message: "Missing Access Rights", User: *user, Roles: availableRoles, Build: buildInfo}) } } default: - web.RenderTemplate(rw, r, "message.tmpl", &web.Page{Title: "Warning", MsgType: "alert-warning", Message: fmt.Sprintf("Unknown search type: %s", strings.Trim(splitSearch[0], " ")), User: *user, Roles: availableRoles, Build: buildInfo}) + web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Warning", MsgType: "alert-warning", Message: fmt.Sprintf("Unknown search type: %s", strings.Trim(splitSearch[0], " ")), User: *user, Roles: availableRoles, Build: buildInfo}) } } else if len(splitSearch) == 1 { @@ -342,13 +341,13 @@ func HandleSearchBar(rw http.ResponseWriter, r *http.Request, buildInfo web.Buil } else if jobname != "" { http.Redirect(rw, r, "/monitoring/jobs/?jobName="+url.QueryEscape(jobname), http.StatusFound) // JobName (contains) } else { - web.RenderTemplate(rw, r, "message.tmpl", &web.Page{Title: "Info", MsgType: "alert-info", Message: "Search without result", User: *user, Roles: availableRoles, Build: buildInfo}) + web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Info", MsgType: "alert-info", Message: "Search without result", User: *user, Roles: availableRoles, Build: buildInfo}) } } else { - web.RenderTemplate(rw, r, "message.tmpl", &web.Page{Title: "Error", MsgType: "alert-danger", Message: "Searchbar query parameters malformed", User: *user, Roles: availableRoles, Build: buildInfo}) + web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Error", MsgType: "alert-danger", Message: "Searchbar query parameters malformed", User: *user, Roles: availableRoles, Build: buildInfo}) } } else { - web.RenderTemplate(rw, r, "message.tmpl", &web.Page{Title: "Warning", MsgType: "alert-warning", Message: "Empty search", User: *user, Roles: availableRoles, Build: buildInfo}) + web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Warning", MsgType: "alert-warning", Message: "Empty search", User: *user, Roles: availableRoles, Build: buildInfo}) } } diff --git a/pkg/schema/config.go b/pkg/schema/config.go index 95cc641..efcd6f9 100644 --- a/pkg/schema/config.go +++ b/pkg/schema/config.go @@ -17,7 +17,9 @@ type LdapConfig struct { UserFilter string `json:"user_filter"` SyncInterval string `json:"sync_interval"` // Parsed using time.ParseDuration. SyncDelOldUsers bool `json:"sync_del_old_users"` - SyncUserOnLogin bool `json:"syncUserOnLogin"` + + // Should an non-existent user be added to the DB if user exists in ldap directory + SyncUserOnLogin bool `json:"syncUserOnLogin"` } type JWTAuthConfig struct { @@ -30,10 +32,13 @@ type JWTAuthConfig struct { // Deny login for users not in database (but defined in JWT). // Ignore user roles defined in JWTs ('roles' claim), get them from db. - ForceJWTValidationViaDatabase bool `json:"forceJWTValidationViaDatabase"` + ValidateUser bool `json:"validateUser"` // Specifies which issuer should be accepted when validating external JWTs ('iss' claim) - TrustedExternalIssuer string `json:"trustedExternalIssuer"` + TrustedIssuer string `json:"trustedIssuer"` + + // Should an non-existent user be added to the DB based on the information in the token + SyncUserOnLogin bool `json:"syncUserOnLogin"` } type IntRange struct { diff --git a/internal/auth/roles.go b/pkg/schema/user.go similarity index 82% rename from internal/auth/roles.go rename to pkg/schema/user.go index e352df1..1547d3f 100644 --- a/internal/auth/roles.go +++ b/pkg/schema/user.go @@ -2,7 +2,7 @@ // All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. -package auth +package schema import ( "fmt" @@ -21,6 +21,41 @@ const ( RoleError ) +type AuthSource int + +const ( + AuthViaLocalPassword AuthSource = iota + AuthViaLDAP + AuthViaToken +) + +type AuthType int + +const ( + AuthToken AuthType = iota + AuthSession +) + +type User struct { + Username string `json:"username"` + Password string `json:"-"` + Name string `json:"name"` + Roles []string `json:"roles"` + AuthType AuthType `json:"authType"` + AuthSource AuthSource `json:"authSource"` + Email string `json:"email"` + Projects []string `json:"projects"` +} + +func (u *User) HasProject(project string) bool { + for _, p := range u.Projects { + if p == project { + return true + } + } + return false +} + func GetRoleString(roleInt Role) string { return [6]string{"anonymous", "api", "user", "manager", "support", "admin"}[roleInt] } @@ -44,12 +79,12 @@ func getRoleEnum(roleStr string) Role { } } -func isValidRole(role string) bool { +func IsValidRole(role string) bool { return getRoleEnum(role) != RoleError } func (u *User) HasValidRole(role string) (hasRole bool, isValid bool) { - if isValidRole(role) { + if IsValidRole(role) { for _, r := range u.Roles { if r == role { return true, true diff --git a/internal/auth/auth_test.go b/pkg/schema/user_test.go similarity index 99% rename from internal/auth/auth_test.go rename to pkg/schema/user_test.go index 81aa4eb..cd054c3 100644 --- a/internal/auth/auth_test.go +++ b/pkg/schema/user_test.go @@ -2,7 +2,7 @@ // All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. -package auth +package schema import ( "testing" diff --git a/web/web.go b/web/web.go index c94c2b1..8d4ce4b 100644 --- a/web/web.go +++ b/web/web.go @@ -11,7 +11,6 @@ import ( "net/http" "strings" - "github.com/ClusterCockpit/cc-backend/internal/auth" "github.com/ClusterCockpit/cc-backend/internal/config" "github.com/ClusterCockpit/cc-backend/internal/util" "github.com/ClusterCockpit/cc-backend/pkg/log" @@ -92,8 +91,8 @@ type Page struct { Title string // Page title MsgType string // For generic use in message boxes Message string // For generic use in message boxes - User auth.User // Information about the currently logged in user (Full User Info) - Roles map[string]auth.Role // Available roles for frontend render checks + User schema.User // Information about the currently logged in user (Full User Info) + Roles map[string]schema.Role // Available roles for frontend render checks Build Build // Latest information about the application Clusters []schema.ClusterConfig // List of all clusters for use in the Header FilterPresets map[string]interface{} // For pages with the Filter component, this can be used to set initial filters. @@ -101,7 +100,7 @@ type Page struct { Config map[string]interface{} // UI settings for the currently logged in user (e.g. line width, ...) } -func RenderTemplate(rw http.ResponseWriter, r *http.Request, file string, page *Page) { +func RenderTemplate(rw http.ResponseWriter, file string, page *Page) { t, ok := templates[file] if !ok { log.Errorf("WEB/WEB > template '%s' not found", file) From c7a04328d9c91151fb3a9b55a36c99c4f55a8bb7 Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Thu, 17 Aug 2023 10:35:16 +0200 Subject: [PATCH 13/27] Fix schema and tests --- configs/config.json | 4 ++-- pkg/schema/schemas/config.schema.json | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/configs/config.json b/configs/config.json index 1cc2550..6c4d4bd 100644 --- a/configs/config.json +++ b/configs/config.json @@ -42,9 +42,9 @@ ], "jwts": { "cookieName": "", - "forceJWTValidationViaDatabase": false, + "validateUser": false, "max-age": 0, - "trustedExternalIssuer": "" + "trustedIssuer": "" }, "short-running-jobs-duration": 300 } diff --git a/pkg/schema/schemas/config.schema.json b/pkg/schema/schemas/config.schema.json index 6518281..7505c1c 100644 --- a/pkg/schema/schemas/config.schema.json +++ b/pkg/schema/schemas/config.schema.json @@ -107,10 +107,6 @@ "description": "Specifies for how long a session shall be valid as a string parsable by time.ParseDuration(). If 0 or empty, the session/token does not expire!", "type": "string" }, - "jwt-max-age": { - "description": "Specifies for how long a JWT token shall be valid as a string parsable by time.ParseDuration(). If 0 or empty, the session/token does not expire!", - "type": "string" - }, "https-cert-file": { "description": "Filepath to SSL certificate. If also https-key-file is set use HTTPS using those certificates.", "type": "string" @@ -166,6 +162,10 @@ "sync_del_old_users": { "description": "Delete obsolete users in database.", "type": "boolean" + }, + "syncUserOnLogin": { + "description": "Add non-existent user to DB at login attempt if user exists in Ldap directory", + "type": "boolean" } }, "required": [ From 15231bc68358719deb48b4583999744ea38dc92d Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Thu, 17 Aug 2023 12:34:30 +0200 Subject: [PATCH 14/27] Cleanup and adapt to new structure --- cmd/cc-backend/main.go | 2 +- internal/auth/auth.go | 33 ++++------------ internal/auth/jwt.go | 2 +- internal/auth/jwtCookieSession.go | 6 +-- internal/auth/jwtSession.go | 11 +++++- internal/auth/ldap.go | 66 +++++++++++++++++++------------ internal/auth/local.go | 2 - internal/repository/user.go | 26 ++++++++++++ pkg/schema/user.go | 1 + 9 files changed, 89 insertions(+), 60 deletions(-) diff --git a/cmd/cc-backend/main.go b/cmd/cc-backend/main.go index 60c5edd..dd00046 100644 --- a/cmd/cc-backend/main.go +++ b/cmd/cc-backend/main.go @@ -211,7 +211,7 @@ func main() { var authentication *auth.Authentication if !config.Keys.DisableAuthentication { var err error - if authentication, err = auth.Init(db.DB, map[string]interface{}{ + if authentication, err = auth.Init(map[string]interface{}{ "ldap": config.Keys.LdapConfig, "jwt": config.Keys.JwtConfig, }); err != nil { diff --git a/internal/auth/auth.go b/internal/auth/auth.go index d79f28b..cc2bcb4 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -18,17 +18,15 @@ import ( "github.com/ClusterCockpit/cc-backend/pkg/log" "github.com/ClusterCockpit/cc-backend/pkg/schema" "github.com/gorilla/sessions" - "github.com/jmoiron/sqlx" ) type Authenticator interface { - Init(auth *Authentication, config interface{}) error + Init(config interface{}) error CanLogin(user *schema.User, username string, rw http.ResponseWriter, r *http.Request) bool Login(user *schema.User, rw http.ResponseWriter, r *http.Request) (*schema.User, error) } type Authentication struct { - db *sqlx.DB sessionStore *sessions.CookieStore SessionMaxAge time.Duration @@ -82,10 +80,8 @@ func (auth *Authentication) AuthViaSession( }, nil } -func Init(db *sqlx.DB, - configs map[string]interface{}) (*Authentication, error) { +func Init(configs map[string]interface{}) (*Authentication, error) { auth := &Authentication{} - auth.db = db sessKey := os.Getenv("SESSION_KEY") if sessKey == "" { @@ -106,14 +102,14 @@ func Init(db *sqlx.DB, } auth.JwtAuth = &JWTAuthenticator{} - if err := auth.JwtAuth.Init(auth, configs["jwt"]); err != nil { + if err := auth.JwtAuth.Init(configs["jwt"]); err != nil { log.Error("Error while initializing authentication -> jwtAuth init failed") return nil, err } if config, ok := configs["ldap"]; ok { ldapAuth := &LdapAuthenticator{} - if err := ldapAuth.Init(auth, config); err != nil { + if err := ldapAuth.Init(config); err != nil { log.Warn("Error while initializing authentication -> ldapAuth init failed") } else { auth.LdapAuth = ldapAuth @@ -122,21 +118,21 @@ func Init(db *sqlx.DB, } jwtSessionAuth := &JWTSessionAuthenticator{} - if err := jwtSessionAuth.Init(auth, configs["jwt"]); err != nil { + if err := jwtSessionAuth.Init(configs["jwt"]); err != nil { log.Warn("Error while initializing authentication -> jwtSessionAuth init failed") } else { auth.authenticators = append(auth.authenticators, jwtSessionAuth) } jwtCookieSessionAuth := &JWTCookieSessionAuthenticator{} - if err := jwtCookieSessionAuth.Init(auth, configs["jwt"]); err != nil { + if err := jwtCookieSessionAuth.Init(configs["jwt"]); err != nil { log.Warn("Error while initializing authentication -> jwtCookieSessionAuth init failed") } else { auth.authenticators = append(auth.authenticators, jwtCookieSessionAuth) } auth.LocalAuth = &LocalAuthenticator{} - if err := auth.LocalAuth.Init(auth, nil); err != nil { + if err := auth.LocalAuth.Init(nil); err != nil { log.Error("Error while initializing authentication -> localAuth init failed") return nil, err } @@ -150,13 +146,12 @@ func (auth *Authentication) Login( onfailure func(rw http.ResponseWriter, r *http.Request, loginErr error)) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - ur := repository.GetUserRepository() err := errors.New("no authenticator applied") username := r.FormValue("username") dbUser := (*schema.User)(nil) if username != "" { - dbUser, err = ur.GetUser(username) + dbUser, err = repository.GetUserRepository().GetUser(username) if err != nil && err != sql.ErrNoRows { log.Errorf("Error while loading user '%v'", username) } @@ -166,10 +161,6 @@ func (auth *Authentication) Login( if !authenticator.CanLogin(dbUser, username, rw, r) { continue } - dbUser, err = ur.GetUser(username) - if err != nil && err != sql.ErrNoRows { - log.Errorf("Error while loading user '%v'", username) - } user, err := authenticator.Login(dbUser, rw, r) if err != nil { @@ -197,14 +188,6 @@ func (auth *Authentication) Login( return } - if dbUser == nil { - if err := ur.AddUser(user); err != nil { - // TODO Add AuthSource - log.Errorf("Error while adding user '%v' to auth from XX", - user.Username) - } - } - log.Infof("login successfull: user: %#v (roles: %v, projects: %v)", user.Username, user.Roles, user.Projects) ctx := context.WithValue(r.Context(), repository.ContextUserKey, user) onsuccess.ServeHTTP(rw, r.WithContext(ctx)) diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go index 0690b9b..9c0166d 100644 --- a/internal/auth/jwt.go +++ b/internal/auth/jwt.go @@ -25,7 +25,7 @@ type JWTAuthenticator struct { config *schema.JWTAuthConfig } -func (ja *JWTAuthenticator) Init(auth *Authentication, conf interface{}) error { +func (ja *JWTAuthenticator) Init(conf interface{}) error { ja.config = conf.(*schema.JWTAuthConfig) pubKey, privKey := os.Getenv("JWT_PUBLIC_KEY"), os.Getenv("JWT_PRIVATE_KEY") diff --git a/internal/auth/jwtCookieSession.go b/internal/auth/jwtCookieSession.go index 4787107..ad0b033 100644 --- a/internal/auth/jwtCookieSession.go +++ b/internal/auth/jwtCookieSession.go @@ -17,8 +17,6 @@ import ( ) type JWTCookieSessionAuthenticator struct { - auth *Authentication - publicKey ed25519.PublicKey privateKey ed25519.PrivateKey publicKeyCrossLogin ed25519.PublicKey // For accepting externally generated JWTs @@ -28,9 +26,7 @@ type JWTCookieSessionAuthenticator struct { var _ Authenticator = (*JWTCookieSessionAuthenticator)(nil) -func (ja *JWTCookieSessionAuthenticator) Init(auth *Authentication, conf interface{}) error { - - ja.auth = auth +func (ja *JWTCookieSessionAuthenticator) Init(conf interface{}) error { ja.config = conf.(*schema.JWTAuthConfig) pubKey, privKey := os.Getenv("JWT_PUBLIC_KEY"), os.Getenv("JWT_PRIVATE_KEY") diff --git a/internal/auth/jwtSession.go b/internal/auth/jwtSession.go index 59250ac..5a29360 100644 --- a/internal/auth/jwtSession.go +++ b/internal/auth/jwtSession.go @@ -11,6 +11,7 @@ import ( "os" "strings" + "github.com/ClusterCockpit/cc-backend/internal/repository" "github.com/ClusterCockpit/cc-backend/pkg/log" "github.com/ClusterCockpit/cc-backend/pkg/schema" "github.com/golang-jwt/jwt/v4" @@ -18,11 +19,15 @@ import ( type JWTSessionAuthenticator struct { loginTokenKey []byte // HS256 key + + config *schema.JWTAuthConfig } var _ Authenticator = (*JWTSessionAuthenticator)(nil) -func (ja *JWTSessionAuthenticator) Init(auth *Authentication, conf interface{}) error { +func (ja *JWTSessionAuthenticator) Init(conf interface{}) error { + ja.config = conf.(*schema.JWTAuthConfig) + if pubKey := os.Getenv("CROSS_LOGIN_JWT_HS512_KEY"); pubKey != "" { bytes, err := base64.StdEncoding.DecodeString(pubKey) if err != nil { @@ -124,6 +129,10 @@ func (ja *JWTSessionAuthenticator) Login( AuthType: schema.AuthSession, AuthSource: schema.AuthViaToken, } + + if err := repository.GetUserRepository().AddUser(user); err != nil { + log.Errorf("Error while adding user '%s' to DB", user.Username) + } } return user, nil diff --git a/internal/auth/ldap.go b/internal/auth/ldap.go index 925e967..c3bde4a 100644 --- a/internal/auth/ldap.go +++ b/internal/auth/ldap.go @@ -12,24 +12,21 @@ import ( "strings" "time" + "github.com/ClusterCockpit/cc-backend/internal/repository" "github.com/ClusterCockpit/cc-backend/pkg/log" "github.com/ClusterCockpit/cc-backend/pkg/schema" "github.com/go-ldap/ldap/v3" ) type LdapAuthenticator struct { - auth *Authentication config *schema.LdapConfig syncPassword string } var _ Authenticator = (*LdapAuthenticator)(nil) -func (la *LdapAuthenticator) Init( - auth *Authentication, - conf interface{}) error { +func (la *LdapAuthenticator) Init(conf interface{}) error { - la.auth = auth la.config = conf.(*schema.LdapConfig) la.syncPassword = os.Getenv("LDAP_ADMIN_PASSWORD") @@ -101,13 +98,30 @@ func (la *LdapAuthenticator) CanLogin( entry := sr.Entries[0] name := entry.GetAttributeValue("gecos") + var roles []string + roles = append(roles, schema.GetRoleString(schema.RoleUser)) + projects := make([]string, 0) - if _, err := la.auth.db.Exec(`INSERT INTO user (username, ldap, name, roles) VALUES (?, ?, ?, ?)`, - username, 1, name, "[\""+schema.GetRoleString(schema.RoleUser)+"\"]"); err != nil { - log.Errorf("User '%s' new in LDAP: Insert into DB failed", username) + user = &schema.User{ + Username: username, + Name: name, + Roles: roles, + Projects: projects, + AuthType: schema.AuthSession, + AuthSource: schema.AuthViaLDAP, + } + + if err := repository.GetUserRepository().AddUser(user); err != nil { + log.Errorf("User '%s' LDAP: Insert into DB failed", username) return false } + // if _, err := la.auth.db.Exec(`INSERT INTO user (username, ldap, name, roles) VALUES (?, ?, ?, ?)`, + // username, 1, name, "[\""+schema.GetRoleString(schema.RoleUser)+"\"]"); err != nil { + // log.Errorf("User '%s' new in LDAP: Insert into DB failed", username) + // return false + // } + return true } } @@ -137,25 +151,18 @@ func (la *LdapAuthenticator) Login( } func (la *LdapAuthenticator) Sync() error { - const IN_DB int = 1 const IN_LDAP int = 2 const IN_BOTH int = 3 + ur := repository.GetUserRepository() users := map[string]int{} - rows, err := la.auth.db.Query(`SELECT username FROM user WHERE user.ldap = 1`) + usernames, err := ur.GetLdapUsernames() if err != nil { - log.Warn("Error while querying LDAP users") return err } - for rows.Next() { - var username string - if err := rows.Scan(&username); err != nil { - log.Warnf("Error while scanning for user '%s'", username) - return err - } - + for _, username := range usernames { users[username] = IN_DB } @@ -194,17 +201,26 @@ func (la *LdapAuthenticator) Sync() error { for username, where := range users { if where == IN_DB && la.config.SyncDelOldUsers { + ur.DelUser(username) log.Debugf("sync: remove %v (does not show up in LDAP anymore)", username) - if _, err := la.auth.db.Exec(`DELETE FROM user WHERE user.username = ?`, username); err != nil { - log.Errorf("User '%s' not in LDAP anymore: Delete from DB failed", username) - return err - } } else if where == IN_LDAP { name := newnames[username] + + var roles []string + roles = append(roles, schema.GetRoleString(schema.RoleUser)) + projects := make([]string, 0) + + user := &schema.User{ + Username: username, + Name: name, + Roles: roles, + Projects: projects, + AuthSource: schema.AuthViaLDAP, + } + log.Debugf("sync: add %v (name: %v, roles: [user], ldap: true)", username, name) - if _, err := la.auth.db.Exec(`INSERT INTO user (username, ldap, name, roles) VALUES (?, ?, ?, ?)`, - username, 1, name, "[\""+schema.GetRoleString(schema.RoleUser)+"\"]"); err != nil { - log.Errorf("User '%s' new in LDAP: Insert into DB failed", username) + if err := ur.AddUser(user); err != nil { + log.Errorf("User '%s' LDAP: Insert into DB failed", username) return err } } diff --git a/internal/auth/local.go b/internal/auth/local.go index fb1ba0f..0c6303a 100644 --- a/internal/auth/local.go +++ b/internal/auth/local.go @@ -20,10 +20,8 @@ type LocalAuthenticator struct { var _ Authenticator = (*LocalAuthenticator)(nil) func (la *LocalAuthenticator) Init( - auth *Authentication, _ interface{}) error { - la.auth = auth return nil } diff --git a/internal/repository/user.go b/internal/repository/user.go index 3db7e4d..5439b6e 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -71,6 +71,28 @@ func (r *UserRepository) GetUser(username string) (*schema.User, error) { return user, nil } +func (r *UserRepository) GetLdapUsernames() ([]string, error) { + + var users []string + rows, err := r.DB.Query(`SELECT username FROM user WHERE user.ldap = 1`) + if err != nil { + log.Warn("Error while querying usernames") + return nil, err + } + + for rows.Next() { + var username string + if err := rows.Scan(&username); err != nil { + log.Warnf("Error while scanning for user '%s'", username) + return nil, err + } + + users = append(users, username) + } + + return users, nil +} + func (r *UserRepository) AddUser(user *schema.User) error { rolesJson, _ := json.Marshal(user.Roles) projectsJson, _ := json.Marshal(user.Projects) @@ -95,6 +117,10 @@ func (r *UserRepository) AddUser(user *schema.User) error { cols = append(cols, "password") vals = append(vals, string(password)) } + if user.AuthSource != -1 { + cols = append(cols, "ldap") + vals = append(vals, int(user.AuthSource)) + } if _, err := sq.Insert("user").Columns(cols...).Values(vals...).RunWith(r.DB).Exec(); err != nil { log.Errorf("Error while inserting new user '%v' into DB", user.Username) diff --git a/pkg/schema/user.go b/pkg/schema/user.go index 1547d3f..047f617 100644 --- a/pkg/schema/user.go +++ b/pkg/schema/user.go @@ -27,6 +27,7 @@ const ( AuthViaLocalPassword AuthSource = iota AuthViaLDAP AuthViaToken + AuthViaAll ) type AuthType int From 29552fadc3d15ec3136e207315b8a6d3cb65899d Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Thu, 17 Aug 2023 14:02:04 +0200 Subject: [PATCH 15/27] Cleanup SyncOnLogin Handling --- internal/auth/auth.go | 10 ++++++---- internal/auth/jwtCookieSession.go | 13 ++++++++++--- internal/auth/jwtSession.go | 10 ++++++---- internal/auth/ldap.go | 19 ++++++++++--------- internal/auth/local.go | 4 ++-- 5 files changed, 34 insertions(+), 22 deletions(-) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index cc2bcb4..8da45da 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -22,7 +22,7 @@ import ( type Authenticator interface { Init(config interface{}) error - CanLogin(user *schema.User, username string, rw http.ResponseWriter, r *http.Request) bool + CanLogin(user *schema.User, username string, rw http.ResponseWriter, r *http.Request) (*schema.User, bool) Login(user *schema.User, rw http.ResponseWriter, r *http.Request) (*schema.User, error) } @@ -148,7 +148,7 @@ func (auth *Authentication) Login( return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { err := errors.New("no authenticator applied") username := r.FormValue("username") - dbUser := (*schema.User)(nil) + var dbUser *schema.User if username != "" { dbUser, err = repository.GetUserRepository().GetUser(username) @@ -158,11 +158,13 @@ func (auth *Authentication) Login( } for _, authenticator := range auth.authenticators { - if !authenticator.CanLogin(dbUser, username, rw, r) { + var ok bool + var user *schema.User + if user, ok = authenticator.CanLogin(dbUser, username, rw, r); !ok { continue } - user, err := authenticator.Login(dbUser, rw, r) + user, err = authenticator.Login(user, rw, r) if err != nil { log.Warnf("user login failed: %s", err.Error()) onfailure(rw, r, err) diff --git a/internal/auth/jwtCookieSession.go b/internal/auth/jwtCookieSession.go index ad0b033..42ebcd2 100644 --- a/internal/auth/jwtCookieSession.go +++ b/internal/auth/jwtCookieSession.go @@ -11,6 +11,7 @@ import ( "net/http" "os" + "github.com/ClusterCockpit/cc-backend/internal/repository" "github.com/ClusterCockpit/cc-backend/pkg/log" "github.com/ClusterCockpit/cc-backend/pkg/schema" "github.com/golang-jwt/jwt/v4" @@ -88,7 +89,7 @@ func (ja *JWTCookieSessionAuthenticator) CanLogin( user *schema.User, username string, rw http.ResponseWriter, - r *http.Request) bool { + r *http.Request) (*schema.User, bool) { cookieName := "" if ja.config != nil && ja.config.CookieName != "" { @@ -100,11 +101,11 @@ func (ja *JWTCookieSessionAuthenticator) CanLogin( jwtCookie, err := r.Cookie(cookieName) if err == nil && jwtCookie.Value != "" { - return true + return user, true } } - return false + return nil, false } func (ja *JWTCookieSessionAuthenticator) Login( @@ -194,6 +195,12 @@ func (ja *JWTCookieSessionAuthenticator) Login( AuthType: schema.AuthSession, AuthSource: schema.AuthViaToken, } + + if ja.config.SyncUserOnLogin { + if err := repository.GetUserRepository().AddUser(user); err != nil { + log.Errorf("Error while adding user '%s' to DB", user.Username) + } + } } return user, nil diff --git a/internal/auth/jwtSession.go b/internal/auth/jwtSession.go index 5a29360..d9dce85 100644 --- a/internal/auth/jwtSession.go +++ b/internal/auth/jwtSession.go @@ -44,9 +44,9 @@ func (ja *JWTSessionAuthenticator) CanLogin( user *schema.User, username string, rw http.ResponseWriter, - r *http.Request) bool { + r *http.Request) (*schema.User, bool) { - return r.Header.Get("Authorization") != "" || r.URL.Query().Get("login-token") != "" + return user, r.Header.Get("Authorization") != "" || r.URL.Query().Get("login-token") != "" } func (ja *JWTSessionAuthenticator) Login( @@ -130,8 +130,10 @@ func (ja *JWTSessionAuthenticator) Login( AuthSource: schema.AuthViaToken, } - if err := repository.GetUserRepository().AddUser(user); err != nil { - log.Errorf("Error while adding user '%s' to DB", user.Username) + if ja.config.SyncUserOnLogin { + if err := repository.GetUserRepository().AddUser(user); err != nil { + log.Errorf("Error while adding user '%s' to DB", user.Username) + } } } diff --git a/internal/auth/ldap.go b/internal/auth/ldap.go index c3bde4a..a20e415 100644 --- a/internal/auth/ldap.go +++ b/internal/auth/ldap.go @@ -67,33 +67,34 @@ func (la *LdapAuthenticator) CanLogin( user *schema.User, username string, rw http.ResponseWriter, - r *http.Request) bool { + r *http.Request) (*schema.User, bool) { if user != nil && user.AuthSource == schema.AuthViaLDAP { - return true + return user, true } else { if la.config != nil && la.config.SyncUserOnLogin { l, err := la.getLdapConnection(true) if err != nil { log.Error("LDAP connection error") } + defer l.Close() // Search for the given username searchRequest := ldap.NewSearchRequest( la.config.UserBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, - fmt.Sprintf("(%s(uid=%s))", la.config.UserFilter, username), + fmt.Sprintf("(&%s(uid=%s))", la.config.UserFilter, username), []string{"dn", "uid", "gecos"}, nil) sr, err := l.Search(searchRequest) if err != nil { log.Warn(err) - return false + return user, false } if len(sr.Entries) != 1 { log.Warn("User does not exist or too many entries returned") - return false + return user, false } entry := sr.Entries[0] @@ -113,7 +114,7 @@ func (la *LdapAuthenticator) CanLogin( if err := repository.GetUserRepository().AddUser(user); err != nil { log.Errorf("User '%s' LDAP: Insert into DB failed", username) - return false + return nil, false } // if _, err := la.auth.db.Exec(`INSERT INTO user (username, ldap, name, roles) VALUES (?, ?, ?, ?)`, @@ -122,11 +123,11 @@ func (la *LdapAuthenticator) CanLogin( // return false // } - return true + return user, true } } - return false + return nil, false } func (la *LdapAuthenticator) Login( @@ -176,7 +177,7 @@ func (la *LdapAuthenticator) Sync() error { ldapResults, err := l.Search(ldap.NewSearchRequest( la.config.UserBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, - fmt.Sprintf("(%s(uid=%s))", la.config.UserFilter, "*"), + la.config.UserFilter, []string{"dn", "uid", "gecos"}, nil)) if err != nil { log.Warn("LDAP search error") diff --git a/internal/auth/local.go b/internal/auth/local.go index 0c6303a..dd6ec2c 100644 --- a/internal/auth/local.go +++ b/internal/auth/local.go @@ -29,9 +29,9 @@ func (la *LocalAuthenticator) CanLogin( user *schema.User, username string, rw http.ResponseWriter, - r *http.Request) bool { + r *http.Request) (*schema.User, bool) { - return user != nil && user.AuthSource == schema.AuthViaLocalPassword + return user, user != nil && user.AuthSource == schema.AuthViaLocalPassword } func (la *LocalAuthenticator) Login( From d51be5c308420f95c66cae484c9f252b9c7e7c18 Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Fri, 18 Aug 2023 08:49:25 +0200 Subject: [PATCH 16/27] Formatting and minor fixes --- internal/auth/auth.go | 28 +++++++++++------- internal/auth/jwt.go | 5 +--- internal/auth/jwtCookieSession.go | 18 ++++++++++-- internal/auth/jwtSession.go | 48 +++++++++++++++++++------------ internal/auth/ldap.go | 25 +++++++--------- 5 files changed, 74 insertions(+), 50 deletions(-) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 8da45da..b10c3bd 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -102,7 +102,7 @@ func Init(configs map[string]interface{}) (*Authentication, error) { } auth.JwtAuth = &JWTAuthenticator{} - if err := auth.JwtAuth.Init(configs["jwt"]); err != nil { + if err := auth.JwtAuth.Init(); err != nil { log.Error("Error while initializing authentication -> jwtAuth init failed") return nil, err } @@ -115,20 +115,26 @@ func Init(configs map[string]interface{}) (*Authentication, error) { auth.LdapAuth = ldapAuth auth.authenticators = append(auth.authenticators, auth.LdapAuth) } + } else { + log.Info("Missing LDAP configuration: No LDAP support!") } - jwtSessionAuth := &JWTSessionAuthenticator{} - if err := jwtSessionAuth.Init(configs["jwt"]); err != nil { - log.Warn("Error while initializing authentication -> jwtSessionAuth init failed") - } else { - auth.authenticators = append(auth.authenticators, jwtSessionAuth) - } + if config, ok := configs["jwt"]; ok { + jwtSessionAuth := &JWTSessionAuthenticator{} + if err := jwtSessionAuth.Init(config); err != nil { + log.Warn("Error while initializing authentication -> jwtSessionAuth init failed") + } else { + auth.authenticators = append(auth.authenticators, jwtSessionAuth) + } - jwtCookieSessionAuth := &JWTCookieSessionAuthenticator{} - if err := jwtCookieSessionAuth.Init(configs["jwt"]); err != nil { - log.Warn("Error while initializing authentication -> jwtCookieSessionAuth init failed") + jwtCookieSessionAuth := &JWTCookieSessionAuthenticator{} + if err := jwtCookieSessionAuth.Init(configs["jwt"]); err != nil { + log.Warn("Error while initializing authentication -> jwtCookieSessionAuth init failed") + } else { + auth.authenticators = append(auth.authenticators, jwtCookieSessionAuth) + } } else { - auth.authenticators = append(auth.authenticators, jwtCookieSessionAuth) + log.Info("Missing JWT configuration: No JWT token login support!") } auth.LocalAuth = &LocalAuthenticator{} diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go index 9c0166d..4a1e4b8 100644 --- a/internal/auth/jwt.go +++ b/internal/auth/jwt.go @@ -22,12 +22,9 @@ import ( type JWTAuthenticator struct { publicKey ed25519.PublicKey privateKey ed25519.PrivateKey - config *schema.JWTAuthConfig } -func (ja *JWTAuthenticator) Init(conf interface{}) error { - ja.config = conf.(*schema.JWTAuthConfig) - +func (ja *JWTAuthenticator) Init() error { pubKey, privKey := os.Getenv("JWT_PUBLIC_KEY"), os.Getenv("JWT_PRIVATE_KEY") if pubKey == "" || privKey == "" { log.Warn("environment variables 'JWT_PUBLIC_KEY' or 'JWT_PRIVATE_KEY' not set (token based authentication will not work)") diff --git a/internal/auth/jwtCookieSession.go b/internal/auth/jwtCookieSession.go index 42ebcd2..9dcba57 100644 --- a/internal/auth/jwtCookieSession.go +++ b/internal/auth/jwtCookieSession.go @@ -8,6 +8,7 @@ import ( "crypto/ed25519" "encoding/base64" "errors" + "fmt" "net/http" "os" @@ -82,6 +83,7 @@ func (ja *JWTCookieSessionAuthenticator) Init(conf interface{}) error { return errors.New("config for JWTs not configured (cross login via JWT cookie will fail)") } + log.Info("JWT Cookie Session authenticator successfully registered") return nil } @@ -137,7 +139,7 @@ func (ja *JWTCookieSessionAuthenticator) Login( return ja.publicKey, nil }) if err != nil { - log.Warn("error while parsing token") + log.Warn("JWT cookie session: error while parsing token") return nil, err } @@ -151,8 +153,16 @@ func (ja *JWTCookieSessionAuthenticator) Login( sub, _ := claims["sub"].(string) var name string - if val, ok := claims["name"]; ok { - name, _ = val.(string) + if wrap, ok := claims["name"].(map[string]interface{}); ok { + if vals, ok := wrap["values"].([]interface{}); ok { + if len(vals) != 0 { + name = fmt.Sprintf("%v", vals[0]) + + for i := 1; i < len(vals); i++ { + name += fmt.Sprintf(" %v", vals[i]) + } + } + } } var roles []string @@ -188,10 +198,12 @@ func (ja *JWTCookieSessionAuthenticator) Login( http.SetCookie(rw, deletedCookie) if user == nil { + projects := make([]string, 0) user = &schema.User{ Username: sub, Name: name, Roles: roles, + Projects: projects, AuthType: schema.AuthSession, AuthSource: schema.AuthViaToken, } diff --git a/internal/auth/jwtSession.go b/internal/auth/jwtSession.go index d9dce85..f68de90 100644 --- a/internal/auth/jwtSession.go +++ b/internal/auth/jwtSession.go @@ -6,6 +6,7 @@ package auth import ( "encoding/base64" + "errors" "fmt" "net/http" "os" @@ -37,6 +38,7 @@ func (ja *JWTSessionAuthenticator) Init(conf interface{}) error { ja.loginTokenKey = bytes } + log.Info("JWT Session authenticator successfully registered") return nil } @@ -46,7 +48,8 @@ func (ja *JWTSessionAuthenticator) CanLogin( rw http.ResponseWriter, r *http.Request) (*schema.User, bool) { - return user, r.Header.Get("Authorization") != "" || r.URL.Query().Get("login-token") != "" + return user, r.Header.Get("Authorization") != "" || + r.URL.Query().Get("login-token") != "" } func (ja *JWTSessionAuthenticator) Login( @@ -79,29 +82,38 @@ func (ja *JWTSessionAuthenticator) Login( sub, _ := claims["sub"].(string) var name string - // Java/Grails Issued Token if wrap, ok := claims["name"].(map[string]interface{}); ok { if vals, ok := wrap["values"].([]interface{}); ok { - name = fmt.Sprintf("%v %v", vals[0], vals[1]) - } - } else if val, ok := claims["name"]; ok { - name, _ = val.(string) - } + if len(vals) != 0 { + name = fmt.Sprintf("%v", vals[0]) - var roles []string - // Java/Grails Issued Token - if rawroles, ok := claims["roles"].([]interface{}); ok { - for _, rr := range rawroles { - if r, ok := rr.(string); ok { - if schema.IsValidRole(r) { - roles = append(roles, r) + for i := 1; i < len(vals); i++ { + name += fmt.Sprintf(" %v", vals[i]) } } } - } else if rawroles, ok := claims["roles"]; ok { - for _, r := range rawroles.([]string) { - if schema.IsValidRole(r) { - roles = append(roles, r) + } + + var roles []string + + if ja.config.ValidateUser { + // Deny any logins for unknown usernames + if user == nil { + log.Warn("Could not find user from JWT in internal database.") + return nil, errors.New("unknown user") + } + + // Take user roles from database instead of trusting the JWT + roles = user.Roles + } else { + // Extract roles from JWT (if present) + if rawroles, ok := claims["roles"].([]interface{}); ok { + for _, rr := range rawroles { + if r, ok := rr.(string); ok { + if schema.IsValidRole(r) { + roles = append(roles, r) + } + } } } } diff --git a/internal/auth/ldap.go b/internal/auth/ldap.go index a20e415..529179e 100644 --- a/internal/auth/ldap.go +++ b/internal/auth/ldap.go @@ -57,7 +57,7 @@ func (la *LdapAuthenticator) Init(conf interface{}) error { } }() } else { - return fmt.Errorf("missing LDAP configuration") + log.Info("Missing LDAP configuration key sync_interval") } return nil @@ -69,10 +69,12 @@ func (la *LdapAuthenticator) CanLogin( rw http.ResponseWriter, r *http.Request) (*schema.User, bool) { - if user != nil && user.AuthSource == schema.AuthViaLDAP { - return user, true + if user != nil { + if user.AuthSource == schema.AuthViaLDAP { + return user, true + } } else { - if la.config != nil && la.config.SyncUserOnLogin { + if la.config.SyncUserOnLogin { l, err := la.getLdapConnection(true) if err != nil { log.Error("LDAP connection error") @@ -89,12 +91,12 @@ func (la *LdapAuthenticator) CanLogin( sr, err := l.Search(searchRequest) if err != nil { log.Warn(err) - return user, false + return nil, false } if len(sr.Entries) != 1 { - log.Warn("User does not exist or too many entries returned") - return user, false + log.Warn("LDAP: User does not exist or too many entries returned") + return nil, false } entry := sr.Entries[0] @@ -117,12 +119,6 @@ func (la *LdapAuthenticator) CanLogin( return nil, false } - // if _, err := la.auth.db.Exec(`INSERT INTO user (username, ldap, name, roles) VALUES (?, ?, ?, ?)`, - // username, 1, name, "[\""+schema.GetRoleString(schema.RoleUser)+"\"]"); err != nil { - // log.Errorf("User '%s' new in LDAP: Insert into DB failed", username) - // return false - // } - return user, true } } @@ -144,7 +140,8 @@ func (la *LdapAuthenticator) Login( userDn := strings.Replace(la.config.UserBind, "{username}", user.Username, -1) if err := l.Bind(userDn, r.FormValue("password")); err != nil { - log.Errorf("AUTH/LOCAL > Authentication for user %s failed: %v", user.Username, err) + log.Errorf("AUTH/LOCAL > Authentication for user %s failed: %v", + user.Username, err) return nil, fmt.Errorf("AUTH/LDAP > Authentication failed") } From cfcf939339c2af2cd84f4bac2f54ca83c951c03c Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Fri, 18 Aug 2023 08:57:56 +0200 Subject: [PATCH 17/27] Add config to jwt again --- internal/auth/auth.go | 12 ++++++------ internal/auth/jwt.go | 5 ++++- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index b10c3bd..8c873ad 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -101,12 +101,6 @@ func Init(configs map[string]interface{}) (*Authentication, error) { auth.sessionStore = sessions.NewCookieStore(bytes) } - auth.JwtAuth = &JWTAuthenticator{} - if err := auth.JwtAuth.Init(); err != nil { - log.Error("Error while initializing authentication -> jwtAuth init failed") - return nil, err - } - if config, ok := configs["ldap"]; ok { ldapAuth := &LdapAuthenticator{} if err := ldapAuth.Init(config); err != nil { @@ -120,6 +114,12 @@ func Init(configs map[string]interface{}) (*Authentication, error) { } if config, ok := configs["jwt"]; ok { + auth.JwtAuth = &JWTAuthenticator{} + if err := auth.JwtAuth.Init(config); err != nil { + log.Error("Error while initializing authentication -> jwtAuth init failed") + return nil, err + } + jwtSessionAuth := &JWTSessionAuthenticator{} if err := jwtSessionAuth.Init(config); err != nil { log.Warn("Error while initializing authentication -> jwtSessionAuth init failed") diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go index 4a1e4b8..9c0166d 100644 --- a/internal/auth/jwt.go +++ b/internal/auth/jwt.go @@ -22,9 +22,12 @@ import ( type JWTAuthenticator struct { publicKey ed25519.PublicKey privateKey ed25519.PrivateKey + config *schema.JWTAuthConfig } -func (ja *JWTAuthenticator) Init() error { +func (ja *JWTAuthenticator) Init(conf interface{}) error { + ja.config = conf.(*schema.JWTAuthConfig) + pubKey, privKey := os.Getenv("JWT_PUBLIC_KEY"), os.Getenv("JWT_PRIVATE_KEY") if pubKey == "" || privKey == "" { log.Warn("environment variables 'JWT_PUBLIC_KEY' or 'JWT_PRIVATE_KEY' not set (token based authentication will not work)") From 56d559fdd712fc04d2b4f3cda7ffd2b08d6d3a18 Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Fri, 18 Aug 2023 09:19:30 +0200 Subject: [PATCH 18/27] Fix bug with jwt max-age option --- internal/auth/jwt.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go index 9c0166d..3d87f68 100644 --- a/internal/auth/jwt.go +++ b/internal/auth/jwt.go @@ -130,8 +130,12 @@ func (ja *JWTAuthenticator) ProvideJWT(user *schema.User) (string, error) { "roles": user.Roles, "iat": now.Unix(), } - if ja.config != nil && ja.config.MaxAge != 0 { - claims["exp"] = now.Add(time.Duration(ja.config.MaxAge)).Unix() + if ja.config != nil && ja.config.MaxAge != "" { + d, err := time.ParseDuration(ja.config.MaxAge) + if err != nil { + return "", errors.New("cannot parse max-age config key") + } + claims["exp"] = now.Add(d).Unix() } return jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims).SignedString(ja.privateKey) From 6185635aa9622e367de3c236fa652c87d352fb3b Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Fri, 18 Aug 2023 09:19:55 +0200 Subject: [PATCH 19/27] Extend config schema --- pkg/schema/config.go | 2 +- pkg/schema/schemas/config.schema.json | 32 ++++++++++++++++++++++++--- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/pkg/schema/config.go b/pkg/schema/config.go index efcd6f9..443fad7 100644 --- a/pkg/schema/config.go +++ b/pkg/schema/config.go @@ -25,7 +25,7 @@ type LdapConfig struct { type JWTAuthConfig struct { // Specifies for how long a JWT token shall be valid // as a string parsable by time.ParseDuration(). - MaxAge int64 `json:"max-age"` + MaxAge string `json:"max-age"` // Specifies which cookie should be checked for a JWT token (if no authorization header is present) CookieName string `json:"cookieName"` diff --git a/pkg/schema/schemas/config.schema.json b/pkg/schema/schemas/config.schema.json index 7505c1c..84983ec 100644 --- a/pkg/schema/schemas/config.schema.json +++ b/pkg/schema/schemas/config.schema.json @@ -127,9 +127,34 @@ "description": "Do not show running jobs shorter than X seconds.", "type": "integer" }, - "": { - "description": "", - "type": "string" + "jwts": { + "description": "For JWT token authentication.", + "type": "object", + "properties": { + "max-age": { + "description": "Configure how long a token is valid. As string parsable by time.ParseDuration()", + "type": "string" + }, + "cookieName": { + "description": "Cookie that should be checked for a JWT token.", + "type": "string" + }, + "validateUser": { + "description": "Deny login for users not in database (but defined in JWT). Overwrite roles in JWT with database roles.", + "type": "boolean" + }, + "trustedIssuer": { + "description": "Issuer that should be accepted when validating external JWTs ", + "type": "string" + }, + "syncUserOnLogin": { + "description": "Add non-existent user to DB at login attempt with values provided in JWT.", + "type": "boolean" + } + }, + "required": [ + "max-age" + ] }, "ldap": { "description": "For LDAP Authentication and user synchronisation.", @@ -398,6 +423,7 @@ } }, "required": [ + "jwts", "clusters" ] } From 14c487c9e49fc4bb63de61c3f692a5726976a1d4 Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Fri, 18 Aug 2023 09:31:57 +0200 Subject: [PATCH 20/27] Update test inputs --- configs/config-demo.json | 3 +++ configs/config.json | 2 +- internal/api/api_test.go | 3 +++ internal/importer/importer_test.go | 3 +++ internal/repository/userConfig_test.go | 3 +++ pkg/schema/validate_test.go | 7 ++++--- 6 files changed, 17 insertions(+), 4 deletions(-) diff --git a/configs/config-demo.json b/configs/config-demo.json index f48d224..578aa37 100644 --- a/configs/config-demo.json +++ b/configs/config-demo.json @@ -4,6 +4,9 @@ "kind": "file", "path": "./var/job-archive" }, + "jwts": { + "max-age": "2m" + }, "clusters": [ { "name": "fritz", diff --git a/configs/config.json b/configs/config.json index 6c4d4bd..3a2c64b 100644 --- a/configs/config.json +++ b/configs/config.json @@ -43,7 +43,7 @@ "jwts": { "cookieName": "", "validateUser": false, - "max-age": 0, + "max-age": "2m", "trustedIssuer": "" }, "short-running-jobs-duration": 300 diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 9e11383..ecffc82 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -39,6 +39,9 @@ func setup(t *testing.T) *api.RestApi { "kind": "file", "path": "./var/job-archive" }, + "jwts": { + "max-age": "2m" + }, "clusters": [ { "name": "testcluster", diff --git a/internal/importer/importer_test.go b/internal/importer/importer_test.go index 83ba5eb..01ac2f2 100644 --- a/internal/importer/importer_test.go +++ b/internal/importer/importer_test.go @@ -42,6 +42,9 @@ func setup(t *testing.T) *repository.JobRepository { "kind": "file", "path": "./var/job-archive" }, + "jwts": { + "max-age": "2m" + }, "clusters": [ { "name": "testcluster", diff --git a/internal/repository/userConfig_test.go b/internal/repository/userConfig_test.go index 0beec7b..3526919 100644 --- a/internal/repository/userConfig_test.go +++ b/internal/repository/userConfig_test.go @@ -22,6 +22,9 @@ func setupUserTest(t *testing.T) *UserCfgRepo { "kind": "file", "path": "./var/job-archive" }, + "jwts": { + "max-age": "2m" + }, "clusters": [ { "name": "testcluster", diff --git a/pkg/schema/validate_test.go b/pkg/schema/validate_test.go index 195e044..c3e918f 100644 --- a/pkg/schema/validate_test.go +++ b/pkg/schema/validate_test.go @@ -11,6 +11,9 @@ import ( func TestValidateConfig(t *testing.T) { json := []byte(`{ + "jwts": { + "max-age": "2m" + }, "clusters": [ { "name": "testcluster", @@ -21,9 +24,7 @@ func TestValidateConfig(t *testing.T) { "numNodes": { "from": 1, "to": 64 }, "duration": { "from": 0, "to": 86400 }, "startTime": { "from": "2022-01-01T00:00:00Z", "to": null } - } - } - ] + }}] }`) if err := Validate(Config, bytes.NewReader(json)); err != nil { From 32b0c8bdd7cef2259caf647c06023fd2cb5fdeb5 Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Fri, 18 Aug 2023 10:43:06 +0200 Subject: [PATCH 21/27] Refactor and cleanup Auth configuration --- cmd/cc-backend/main.go | 5 +--- internal/auth/auth.go | 24 +++++++++---------- internal/auth/jwt.go | 12 ++++------ internal/auth/jwtCookieSession.go | 38 +++++++++++++++---------------- internal/auth/jwtSession.go | 11 ++++----- internal/auth/ldap.go | 38 ++++++++++++++++--------------- internal/auth/local.go | 4 +--- internal/config/config.go | 1 - 8 files changed, 62 insertions(+), 71 deletions(-) diff --git a/cmd/cc-backend/main.go b/cmd/cc-backend/main.go index dd00046..e165092 100644 --- a/cmd/cc-backend/main.go +++ b/cmd/cc-backend/main.go @@ -211,10 +211,7 @@ func main() { var authentication *auth.Authentication if !config.Keys.DisableAuthentication { var err error - if authentication, err = auth.Init(map[string]interface{}{ - "ldap": config.Keys.LdapConfig, - "jwt": config.Keys.JwtConfig, - }); err != nil { + if authentication, err = auth.Init(); err != nil { log.Fatalf("auth initialization failed: %v", err) } diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 8c873ad..8c9beef 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -14,6 +14,7 @@ import ( "os" "time" + "github.com/ClusterCockpit/cc-backend/internal/config" "github.com/ClusterCockpit/cc-backend/internal/repository" "github.com/ClusterCockpit/cc-backend/pkg/log" "github.com/ClusterCockpit/cc-backend/pkg/schema" @@ -21,7 +22,6 @@ import ( ) type Authenticator interface { - Init(config interface{}) error CanLogin(user *schema.User, username string, rw http.ResponseWriter, r *http.Request) (*schema.User, bool) Login(user *schema.User, rw http.ResponseWriter, r *http.Request) (*schema.User, error) } @@ -80,7 +80,7 @@ func (auth *Authentication) AuthViaSession( }, nil } -func Init(configs map[string]interface{}) (*Authentication, error) { +func Init() (*Authentication, error) { auth := &Authentication{} sessKey := os.Getenv("SESSION_KEY") @@ -101,9 +101,9 @@ func Init(configs map[string]interface{}) (*Authentication, error) { auth.sessionStore = sessions.NewCookieStore(bytes) } - if config, ok := configs["ldap"]; ok { + if config.Keys.LdapConfig != nil { ldapAuth := &LdapAuthenticator{} - if err := ldapAuth.Init(config); err != nil { + if err := ldapAuth.Init(); err != nil { log.Warn("Error while initializing authentication -> ldapAuth init failed") } else { auth.LdapAuth = ldapAuth @@ -113,32 +113,32 @@ func Init(configs map[string]interface{}) (*Authentication, error) { log.Info("Missing LDAP configuration: No LDAP support!") } - if config, ok := configs["jwt"]; ok { + if config.Keys.JwtConfig != nil { auth.JwtAuth = &JWTAuthenticator{} - if err := auth.JwtAuth.Init(config); err != nil { + if err := auth.JwtAuth.Init(); err != nil { log.Error("Error while initializing authentication -> jwtAuth init failed") return nil, err } jwtSessionAuth := &JWTSessionAuthenticator{} - if err := jwtSessionAuth.Init(config); err != nil { - log.Warn("Error while initializing authentication -> jwtSessionAuth init failed") + if err := jwtSessionAuth.Init(); err != nil { + log.Info("jwtSessionAuth init failed: No JWT login support!") } else { auth.authenticators = append(auth.authenticators, jwtSessionAuth) } jwtCookieSessionAuth := &JWTCookieSessionAuthenticator{} - if err := jwtCookieSessionAuth.Init(configs["jwt"]); err != nil { - log.Warn("Error while initializing authentication -> jwtCookieSessionAuth init failed") + if err := jwtCookieSessionAuth.Init(); err != nil { + log.Info("jwtCookieSessionAuth init failed: No JWT cookie login support!") } else { auth.authenticators = append(auth.authenticators, jwtCookieSessionAuth) } } else { - log.Info("Missing JWT configuration: No JWT token login support!") + log.Info("Missing JWT configuration: No JWT token support!") } auth.LocalAuth = &LocalAuthenticator{} - if err := auth.LocalAuth.Init(nil); err != nil { + if err := auth.LocalAuth.Init(); err != nil { log.Error("Error while initializing authentication -> localAuth init failed") return nil, err } diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go index 3d87f68..83bfee3 100644 --- a/internal/auth/jwt.go +++ b/internal/auth/jwt.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "github.com/ClusterCockpit/cc-backend/internal/config" "github.com/ClusterCockpit/cc-backend/internal/repository" "github.com/ClusterCockpit/cc-backend/pkg/log" "github.com/ClusterCockpit/cc-backend/pkg/schema" @@ -22,12 +23,9 @@ import ( type JWTAuthenticator struct { publicKey ed25519.PublicKey privateKey ed25519.PrivateKey - config *schema.JWTAuthConfig } -func (ja *JWTAuthenticator) Init(conf interface{}) error { - ja.config = conf.(*schema.JWTAuthConfig) - +func (ja *JWTAuthenticator) Init() error { pubKey, privKey := os.Getenv("JWT_PUBLIC_KEY"), os.Getenv("JWT_PRIVATE_KEY") if pubKey == "" || privKey == "" { log.Warn("environment variables 'JWT_PUBLIC_KEY' or 'JWT_PRIVATE_KEY' not set (token based authentication will not work)") @@ -87,7 +85,7 @@ func (ja *JWTAuthenticator) AuthViaJWT( var roles []string // Validate user + roles from JWT against database? - if ja.config != nil && ja.config.ValidateUser { + if config.Keys.JwtConfig.ValidateUser { ur := repository.GetUserRepository() user, err := ur.GetUser(sub) @@ -130,8 +128,8 @@ func (ja *JWTAuthenticator) ProvideJWT(user *schema.User) (string, error) { "roles": user.Roles, "iat": now.Unix(), } - if ja.config != nil && ja.config.MaxAge != "" { - d, err := time.ParseDuration(ja.config.MaxAge) + if config.Keys.JwtConfig.MaxAge != "" { + d, err := time.ParseDuration(config.Keys.JwtConfig.MaxAge) if err != nil { return "", errors.New("cannot parse max-age config key") } diff --git a/internal/auth/jwtCookieSession.go b/internal/auth/jwtCookieSession.go index 9dcba57..bff08f2 100644 --- a/internal/auth/jwtCookieSession.go +++ b/internal/auth/jwtCookieSession.go @@ -12,6 +12,7 @@ import ( "net/http" "os" + "github.com/ClusterCockpit/cc-backend/internal/config" "github.com/ClusterCockpit/cc-backend/internal/repository" "github.com/ClusterCockpit/cc-backend/pkg/log" "github.com/ClusterCockpit/cc-backend/pkg/schema" @@ -22,15 +23,11 @@ type JWTCookieSessionAuthenticator struct { publicKey ed25519.PublicKey privateKey ed25519.PrivateKey publicKeyCrossLogin ed25519.PublicKey // For accepting externally generated JWTs - - config *schema.JWTAuthConfig } var _ Authenticator = (*JWTCookieSessionAuthenticator)(nil) -func (ja *JWTCookieSessionAuthenticator) Init(conf interface{}) error { - ja.config = conf.(*schema.JWTAuthConfig) - +func (ja *JWTCookieSessionAuthenticator) Init() error { pubKey, privKey := os.Getenv("JWT_PUBLIC_KEY"), os.Getenv("JWT_PRIVATE_KEY") if pubKey == "" || privKey == "" { log.Warn("environment variables 'JWT_PUBLIC_KEY' or 'JWT_PRIVATE_KEY' not set (token based authentication will not work)") @@ -65,17 +62,18 @@ func (ja *JWTCookieSessionAuthenticator) Init(conf interface{}) error { return errors.New("environment variable 'CROSS_LOGIN_JWT_PUBLIC_KEY' not set (cross login token based authentication will not work)") } + jc := config.Keys.JwtConfig // Warn if other necessary settings are not configured - if ja.config != nil { - if ja.config.CookieName == "" { - log.Warn("cookieName for JWTs not configured (cross login via JWT cookie will fail)") + if jc != nil { + if jc.CookieName == "" { + log.Info("cookieName for JWTs not configured (cross login via JWT cookie will fail)") return errors.New("cookieName for JWTs not configured (cross login via JWT cookie will fail)") } - if !ja.config.ValidateUser { - log.Warn("forceJWTValidationViaDatabase not set to true: CC will accept users and roles defined in JWTs regardless of its own database!") + if !jc.ValidateUser { + log.Info("forceJWTValidationViaDatabase not set to true: CC will accept users and roles defined in JWTs regardless of its own database!") } - if ja.config.TrustedIssuer == "" { - log.Warn("trustedExternalIssuer for JWTs not configured (cross login via JWT cookie will fail)") + if jc.TrustedIssuer == "" { + log.Info("trustedExternalIssuer for JWTs not configured (cross login via JWT cookie will fail)") return errors.New("trustedExternalIssuer for JWTs not configured (cross login via JWT cookie will fail)") } } else { @@ -93,9 +91,10 @@ func (ja *JWTCookieSessionAuthenticator) CanLogin( rw http.ResponseWriter, r *http.Request) (*schema.User, bool) { + jc := config.Keys.JwtConfig cookieName := "" - if ja.config != nil && ja.config.CookieName != "" { - cookieName = ja.config.CookieName + if jc.CookieName != "" { + cookieName = jc.CookieName } // Try to read the JWT cookie @@ -115,7 +114,8 @@ func (ja *JWTCookieSessionAuthenticator) Login( rw http.ResponseWriter, r *http.Request) (*schema.User, error) { - jwtCookie, err := r.Cookie(ja.config.CookieName) + jc := config.Keys.JwtConfig + jwtCookie, err := r.Cookie(jc.CookieName) var rawtoken string if err == nil && jwtCookie.Value != "" { @@ -128,7 +128,7 @@ func (ja *JWTCookieSessionAuthenticator) Login( } unvalidatedIssuer, success := t.Claims.(jwt.MapClaims)["iss"].(string) - if success && unvalidatedIssuer == ja.config.TrustedIssuer { + if success && unvalidatedIssuer == jc.TrustedIssuer { // The (unvalidated) issuer seems to be the expected one, // use public cross login key from config return ja.publicKeyCrossLogin, nil @@ -167,7 +167,7 @@ func (ja *JWTCookieSessionAuthenticator) Login( var roles []string - if ja.config.ValidateUser { + if jc.ValidateUser { // Deny any logins for unknown usernames if user == nil { log.Warn("Could not find user from JWT in internal database.") @@ -189,7 +189,7 @@ func (ja *JWTCookieSessionAuthenticator) Login( // (Ask browser to) Delete JWT cookie deletedCookie := &http.Cookie{ - Name: ja.config.CookieName, + Name: jc.CookieName, Value: "", Path: "/", MaxAge: -1, @@ -208,7 +208,7 @@ func (ja *JWTCookieSessionAuthenticator) Login( AuthSource: schema.AuthViaToken, } - if ja.config.SyncUserOnLogin { + if jc.SyncUserOnLogin { if err := repository.GetUserRepository().AddUser(user); err != nil { log.Errorf("Error while adding user '%s' to DB", user.Username) } diff --git a/internal/auth/jwtSession.go b/internal/auth/jwtSession.go index f68de90..2aa194d 100644 --- a/internal/auth/jwtSession.go +++ b/internal/auth/jwtSession.go @@ -12,6 +12,7 @@ import ( "os" "strings" + "github.com/ClusterCockpit/cc-backend/internal/config" "github.com/ClusterCockpit/cc-backend/internal/repository" "github.com/ClusterCockpit/cc-backend/pkg/log" "github.com/ClusterCockpit/cc-backend/pkg/schema" @@ -20,15 +21,11 @@ import ( type JWTSessionAuthenticator struct { loginTokenKey []byte // HS256 key - - config *schema.JWTAuthConfig } var _ Authenticator = (*JWTSessionAuthenticator)(nil) -func (ja *JWTSessionAuthenticator) Init(conf interface{}) error { - ja.config = conf.(*schema.JWTAuthConfig) - +func (ja *JWTSessionAuthenticator) Init() error { if pubKey := os.Getenv("CROSS_LOGIN_JWT_HS512_KEY"); pubKey != "" { bytes, err := base64.StdEncoding.DecodeString(pubKey) if err != nil { @@ -96,7 +93,7 @@ func (ja *JWTSessionAuthenticator) Login( var roles []string - if ja.config.ValidateUser { + if config.Keys.JwtConfig.ValidateUser { // Deny any logins for unknown usernames if user == nil { log.Warn("Could not find user from JWT in internal database.") @@ -142,7 +139,7 @@ func (ja *JWTSessionAuthenticator) Login( AuthSource: schema.AuthViaToken, } - if ja.config.SyncUserOnLogin { + if config.Keys.JwtConfig.SyncUserOnLogin { if err := repository.GetUserRepository().AddUser(user); err != nil { log.Errorf("Error while adding user '%s' to DB", user.Username) } diff --git a/internal/auth/ldap.go b/internal/auth/ldap.go index 529179e..6a8c483 100644 --- a/internal/auth/ldap.go +++ b/internal/auth/ldap.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/ClusterCockpit/cc-backend/internal/config" "github.com/ClusterCockpit/cc-backend/internal/repository" "github.com/ClusterCockpit/cc-backend/pkg/log" "github.com/ClusterCockpit/cc-backend/pkg/schema" @@ -19,25 +20,22 @@ import ( ) type LdapAuthenticator struct { - config *schema.LdapConfig syncPassword string } var _ Authenticator = (*LdapAuthenticator)(nil) -func (la *LdapAuthenticator) Init(conf interface{}) error { - - la.config = conf.(*schema.LdapConfig) - +func (la *LdapAuthenticator) Init() error { la.syncPassword = os.Getenv("LDAP_ADMIN_PASSWORD") if la.syncPassword == "" { log.Warn("environment variable 'LDAP_ADMIN_PASSWORD' not set (ldap sync will not work)") } - if la.config != nil && la.config.SyncInterval != "" { - interval, err := time.ParseDuration(la.config.SyncInterval) + if config.Keys.LdapConfig.SyncInterval != "" { + interval, err := time.ParseDuration(config.Keys.LdapConfig.SyncInterval) if err != nil { - log.Warnf("Could not parse duration for sync interval: %v", la.config.SyncInterval) + log.Warnf("Could not parse duration for sync interval: %v", + config.Keys.LdapConfig.SyncInterval) return err } @@ -57,7 +55,7 @@ func (la *LdapAuthenticator) Init(conf interface{}) error { } }() } else { - log.Info("Missing LDAP configuration key sync_interval") + log.Info("LDAP configuration key sync_interval invalid") } return nil @@ -69,12 +67,14 @@ func (la *LdapAuthenticator) CanLogin( rw http.ResponseWriter, r *http.Request) (*schema.User, bool) { + lc := config.Keys.LdapConfig + if user != nil { if user.AuthSource == schema.AuthViaLDAP { return user, true } } else { - if la.config.SyncUserOnLogin { + if lc.SyncUserOnLogin { l, err := la.getLdapConnection(true) if err != nil { log.Error("LDAP connection error") @@ -83,9 +83,9 @@ func (la *LdapAuthenticator) CanLogin( // Search for the given username searchRequest := ldap.NewSearchRequest( - la.config.UserBase, + lc.UserBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, - fmt.Sprintf("(&%s(uid=%s))", la.config.UserFilter, username), + fmt.Sprintf("(&%s(uid=%s))", lc.UserFilter, username), []string{"dn", "uid", "gecos"}, nil) sr, err := l.Search(searchRequest) @@ -138,7 +138,7 @@ func (la *LdapAuthenticator) Login( } defer l.Close() - userDn := strings.Replace(la.config.UserBind, "{username}", user.Username, -1) + userDn := strings.Replace(config.Keys.LdapConfig.UserBind, "{username}", user.Username, -1) if err := l.Bind(userDn, r.FormValue("password")); err != nil { log.Errorf("AUTH/LOCAL > Authentication for user %s failed: %v", user.Username, err) @@ -153,6 +153,7 @@ func (la *LdapAuthenticator) Sync() error { const IN_LDAP int = 2 const IN_BOTH int = 3 ur := repository.GetUserRepository() + lc := config.Keys.LdapConfig users := map[string]int{} usernames, err := ur.GetLdapUsernames() @@ -172,9 +173,9 @@ func (la *LdapAuthenticator) Sync() error { defer l.Close() ldapResults, err := l.Search(ldap.NewSearchRequest( - la.config.UserBase, + lc.UserBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, - la.config.UserFilter, + lc.UserFilter, []string{"dn", "uid", "gecos"}, nil)) if err != nil { log.Warn("LDAP search error") @@ -198,7 +199,7 @@ func (la *LdapAuthenticator) Sync() error { } for username, where := range users { - if where == IN_DB && la.config.SyncDelOldUsers { + if where == IN_DB && lc.SyncDelOldUsers { ur.DelUser(username) log.Debugf("sync: remove %v (does not show up in LDAP anymore)", username) } else if where == IN_LDAP { @@ -231,14 +232,15 @@ func (la *LdapAuthenticator) Sync() error { // that so that connections can be reused/cached. func (la *LdapAuthenticator) getLdapConnection(admin bool) (*ldap.Conn, error) { - conn, err := ldap.DialURL(la.config.Url) + lc := config.Keys.LdapConfig + conn, err := ldap.DialURL(lc.Url) if err != nil { log.Warn("LDAP URL dial failed") return nil, err } if admin { - if err := conn.Bind(la.config.SearchDN, la.syncPassword); err != nil { + if err := conn.Bind(lc.SearchDN, la.syncPassword); err != nil { conn.Close() log.Warn("LDAP connection bind failed") return nil, err diff --git a/internal/auth/local.go b/internal/auth/local.go index dd6ec2c..002e676 100644 --- a/internal/auth/local.go +++ b/internal/auth/local.go @@ -19,9 +19,7 @@ type LocalAuthenticator struct { var _ Authenticator = (*LocalAuthenticator)(nil) -func (la *LocalAuthenticator) Init( - _ interface{}) error { - +func (la *LocalAuthenticator) Init() error { return nil } diff --git a/internal/config/config.go b/internal/config/config.go index 02a0ba6..08d01c6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,7 +22,6 @@ var Keys schema.ProgramConfig = schema.ProgramConfig{ Archive: json.RawMessage(`{\"kind\":\"file\",\"path\":\"./var/job-archive\"}`), DisableArchive: false, Validate: false, - LdapConfig: nil, SessionMaxAge: "168h", StopJobsExceedingWalltime: 0, ShortRunningJobsDuration: 5 * 60, From da551a0bb4544b3bc6eab7b784cd4339fa0f3879 Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Fri, 18 Aug 2023 11:00:13 +0200 Subject: [PATCH 22/27] Repair broken error handlng --- internal/auth/auth.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 8c9beef..77f2165 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -122,14 +122,14 @@ func Init() (*Authentication, error) { jwtSessionAuth := &JWTSessionAuthenticator{} if err := jwtSessionAuth.Init(); err != nil { - log.Info("jwtSessionAuth init failed: No JWT login support!") + log.Info("jwtSessionAuth init failed: No JWT login support!") } else { auth.authenticators = append(auth.authenticators, jwtSessionAuth) } jwtCookieSessionAuth := &JWTCookieSessionAuthenticator{} if err := jwtCookieSessionAuth.Init(); err != nil { - log.Info("jwtCookieSessionAuth init failed: No JWT cookie login support!") + log.Info("jwtCookieSessionAuth init failed: No JWT cookie login support!") } else { auth.authenticators = append(auth.authenticators, jwtCookieSessionAuth) } @@ -152,11 +152,11 @@ func (auth *Authentication) Login( onfailure func(rw http.ResponseWriter, r *http.Request, loginErr error)) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - err := errors.New("no authenticator applied") username := r.FormValue("username") var dbUser *schema.User if username != "" { + var err error dbUser, err = repository.GetUserRepository().GetUser(username) if err != nil && err != sql.ErrNoRows { log.Errorf("Error while loading user '%v'", username) @@ -170,7 +170,7 @@ func (auth *Authentication) Login( continue } - user, err = authenticator.Login(user, rw, r) + user, err := authenticator.Login(user, rw, r) if err != nil { log.Warnf("user login failed: %s", err.Error()) onfailure(rw, r, err) @@ -203,7 +203,7 @@ func (auth *Authentication) Login( } log.Debugf("login failed: no authenticator applied") - onfailure(rw, r, err) + onfailure(rw, r, errors.New("no authenticator applied")) }) } From 57bda635069b9b4ff605c305ebc680729034dd5e Mon Sep 17 00:00:00 2001 From: Christoph Kluge Date: Fri, 18 Aug 2023 11:17:31 +0200 Subject: [PATCH 23/27] Cleanup some error strings --- docs/dev-authentication.md | 6 +++--- internal/auth/jwtSession.go | 2 +- internal/auth/ldap.go | 4 ++-- internal/auth/local.go | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/dev-authentication.md b/docs/dev-authentication.md index 9b84c4b..4107d61 100644 --- a/docs/dev-authentication.md +++ b/docs/dev-authentication.md @@ -65,7 +65,7 @@ the user database table: ``` if e := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(r.FormValue("password"))); e != nil { log.Errorf("AUTH/LOCAL > Authentication for user %s failed!", user.Username) - return nil, fmt.Errorf("AUTH/LOCAL > Authentication failed") + return nil, fmt.Errorf("Authentication failed") } ``` @@ -79,8 +79,8 @@ return user != nil && user.AuthSource == AuthViaLDAP Gets the LDAP connection and tries a bind with the provided credentials: ``` if err := l.Bind(userDn, r.FormValue("password")); err != nil { - log.Errorf("AUTH/LOCAL > Authentication for user %s failed: %v", user.Username, err) - return nil, fmt.Errorf("AUTH/LDAP > Authentication failed") + log.Errorf("AUTH/LDAP > Authentication for user %s failed: %v", user.Username, err) + return nil, fmt.Errorf("Authentication failed") } ``` diff --git a/internal/auth/jwtSession.go b/internal/auth/jwtSession.go index f68de90..10c5719 100644 --- a/internal/auth/jwtSession.go +++ b/internal/auth/jwtSession.go @@ -66,7 +66,7 @@ func (ja *JWTSessionAuthenticator) Login( if t.Method == jwt.SigningMethodHS256 || t.Method == jwt.SigningMethodHS512 { return ja.loginTokenKey, nil } - return nil, fmt.Errorf("AUTH/JWT > unkown signing method for login token: %s (known: HS256, HS512, EdDSA)", t.Method.Alg()) + return nil, fmt.Errorf("unkown signing method for login token: %s (known: HS256, HS512, EdDSA)", t.Method.Alg()) }) if err != nil { log.Warn("Error while parsing jwt token") diff --git a/internal/auth/ldap.go b/internal/auth/ldap.go index 529179e..a55f6b7 100644 --- a/internal/auth/ldap.go +++ b/internal/auth/ldap.go @@ -140,9 +140,9 @@ func (la *LdapAuthenticator) Login( userDn := strings.Replace(la.config.UserBind, "{username}", user.Username, -1) if err := l.Bind(userDn, r.FormValue("password")); err != nil { - log.Errorf("AUTH/LOCAL > Authentication for user %s failed: %v", + log.Errorf("AUTH/LDAP > Authentication for user %s failed: %v", user.Username, err) - return nil, fmt.Errorf("AUTH/LDAP > Authentication failed") + return nil, fmt.Errorf("Authentication failed") } return user, nil diff --git a/internal/auth/local.go b/internal/auth/local.go index dd6ec2c..06a9219 100644 --- a/internal/auth/local.go +++ b/internal/auth/local.go @@ -42,7 +42,7 @@ func (la *LocalAuthenticator) Login( if e := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(r.FormValue("password"))); e != nil { log.Errorf("AUTH/LOCAL > Authentication for user %s failed!", user.Username) - return nil, fmt.Errorf("AUTH/LOCAL > Authentication failed") + return nil, fmt.Errorf("Authentication failed") } return user, nil From 3028f60807281e31609ba783fc1484598b1d825e Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Fri, 18 Aug 2023 11:59:16 +0200 Subject: [PATCH 24/27] Reformat and add debug output --- cmd/cc-backend/main.go | 22 ++++++++++++---------- internal/auth/auth.go | 2 ++ 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/cmd/cc-backend/main.go b/cmd/cc-backend/main.go index e165092..e51791f 100644 --- a/cmd/cc-backend/main.go +++ b/cmd/cc-backend/main.go @@ -342,6 +342,7 @@ func main() { // Those should be mounted to this subrouter. If authentication is enabled, a middleware will prevent // any unauthenticated accesses. secured := r.PathPrefix("/").Subrouter() + if !config.Keys.DisableAuthentication { r.Handle("/login", authentication.Login( // On success: @@ -359,16 +360,17 @@ func main() { }) })).Methods(http.MethodPost) - r.Handle("/logout", authentication.Logout(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - rw.Header().Add("Content-Type", "text/html; charset=utf-8") - rw.WriteHeader(http.StatusOK) - web.RenderTemplate(rw, "login.tmpl", &web.Page{ - Title: "Bye - ClusterCockpit", - MsgType: "alert-info", - Message: "Logout successful", - Build: buildInfo, - }) - }))).Methods(http.MethodPost) + r.Handle("/logout", authentication.Logout( + http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + rw.Header().Add("Content-Type", "text/html; charset=utf-8") + rw.WriteHeader(http.StatusOK) + web.RenderTemplate(rw, "login.tmpl", &web.Page{ + Title: "Bye - ClusterCockpit", + MsgType: "alert-info", + Message: "Logout successful", + Build: buildInfo, + }) + }))).Methods(http.MethodPost) secured.Use(func(next http.Handler) http.Handler { return authentication.Auth( diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 77f2165..92b777b 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -168,6 +168,8 @@ func (auth *Authentication) Login( var user *schema.User if user, ok = authenticator.CanLogin(dbUser, username, rw, r); !ok { continue + } else { + log.Debugf("Can login with user %v", user) } user, err := authenticator.Login(user, rw, r) From e99d1a1e90ec29c93796d55e7476b9746a80c57c Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Fri, 18 Aug 2023 14:02:21 +0200 Subject: [PATCH 25/27] Add endpoint for jwt session login --- cmd/cc-backend/main.go | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/cmd/cc-backend/main.go b/cmd/cc-backend/main.go index e51791f..5a50dce 100644 --- a/cmd/cc-backend/main.go +++ b/cmd/cc-backend/main.go @@ -109,6 +109,13 @@ var ( version string ) +// ErrorResponse model +type ErrorResponse struct { + // Statustext of Errorcode + Status string `json:"status"` + Error string `json:"error"` // Error Message +} + func initEnv() { if util.CheckFileExists("var") { fmt.Print("Directory ./var already exists. Exiting!\n") @@ -338,9 +345,6 @@ func main() { web.RenderTemplate(rw, "privacy.tmpl", &web.Page{Title: "Privacy", Build: buildInfo}) }) - // Some routes, such as /login or /query, should only be accessible to a user that is logged in. - // Those should be mounted to this subrouter. If authentication is enabled, a middleware will prevent - // any unauthenticated accesses. secured := r.PathPrefix("/").Subrouter() if !config.Keys.DisableAuthentication { @@ -360,6 +364,20 @@ func main() { }) })).Methods(http.MethodPost) + r.Handle("/jwt-login", authentication.Login( + // On success: + http.RedirectHandler("/", http.StatusTemporaryRedirect), + + // On failure: + func(rw http.ResponseWriter, r *http.Request, err error) { + rw.Header().Add("Content-Type", "application/json") + rw.WriteHeader(http.StatusForbidden) + json.NewEncoder(rw).Encode(ErrorResponse{ + Status: http.StatusText(http.StatusForbidden), + Error: err.Error(), + }) + })).Methods(http.MethodGet) + r.Handle("/logout", authentication.Logout( http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { rw.Header().Add("Content-Type", "text/html; charset=utf-8") From c87db1dfe6ed70e9834c9f7d4a9079d52b1d6d3c Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Fri, 18 Aug 2023 14:03:52 +0200 Subject: [PATCH 26/27] login for all Methods --- cmd/cc-backend/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/cc-backend/main.go b/cmd/cc-backend/main.go index 5a50dce..93786ca 100644 --- a/cmd/cc-backend/main.go +++ b/cmd/cc-backend/main.go @@ -376,7 +376,7 @@ func main() { Status: http.StatusText(http.StatusForbidden), Error: err.Error(), }) - })).Methods(http.MethodGet) + })) r.Handle("/logout", authentication.Logout( http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { From 9fec8a482234642f8aa2a01afb32bcd9165055d7 Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Fri, 18 Aug 2023 14:29:24 +0200 Subject: [PATCH 27/27] Change to html output for jwt-login --- cmd/cc-backend/main.go | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/cmd/cc-backend/main.go b/cmd/cc-backend/main.go index 93786ca..e956503 100644 --- a/cmd/cc-backend/main.go +++ b/cmd/cc-backend/main.go @@ -109,13 +109,6 @@ var ( version string ) -// ErrorResponse model -type ErrorResponse struct { - // Statustext of Errorcode - Status string `json:"status"` - Error string `json:"error"` // Error Message -} - func initEnv() { if util.CheckFileExists("var") { fmt.Print("Directory ./var already exists. Exiting!\n") @@ -370,11 +363,13 @@ func main() { // On failure: func(rw http.ResponseWriter, r *http.Request, err error) { - rw.Header().Add("Content-Type", "application/json") - rw.WriteHeader(http.StatusForbidden) - json.NewEncoder(rw).Encode(ErrorResponse{ - Status: http.StatusText(http.StatusForbidden), - Error: err.Error(), + rw.Header().Add("Content-Type", "text/html; charset=utf-8") + rw.WriteHeader(http.StatusUnauthorized) + web.RenderTemplate(rw, "login.tmpl", &web.Page{ + Title: "Login failed - ClusterCockpit", + MsgType: "alert-warning", + Message: err.Error(), + Build: buildInfo, }) }))