From d929bdc9a11ba093210f3595108cbc654fbba17a Mon Sep 17 00:00:00 2001 From: Pay Giesselmann Date: Tue, 8 Aug 2023 10:17:30 +0200 Subject: [PATCH 1/6] omit metrics with empty series --- internal/metricdata/prometheus.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/metricdata/prometheus.go b/internal/metricdata/prometheus.go index d93a3c5..0d3848f 100644 --- a/internal/metricdata/prometheus.go +++ b/internal/metricdata/prometheus.go @@ -326,7 +326,6 @@ func (pdb *PrometheusDataRepository) LoadData( Timestep: metricConfig.Timestep, Series: make([]schema.Series, 0), } - jobData[metric][scope] = jobMetric } step := int64(metricConfig.Timestep) steps := int64(to.Sub(from).Seconds()) / step @@ -335,6 +334,10 @@ func (pdb *PrometheusDataRepository) LoadData( jobMetric.Series = append(jobMetric.Series, pdb.RowToSeries(from, step, steps, row)) } + // only add metric if at least one host returned data + if !ok && len(jobMetric.Series) > 0{ + jobData[metric][scope] = jobMetric + } // sort by hostname to get uniform coloring sort.Slice(jobMetric.Series, func(i, j int) bool { return (jobMetric.Series[i].Hostname < jobMetric.Series[j].Hostname) From b8273a9b0265e4ff3a0fa65a557215dd161606c1 Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Fri, 11 Aug 2023 10:00:23 +0200 Subject: [PATCH 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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