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 diff --git a/docs/JWT-Handling.md b/docs/JWT-Handling.md index 8b03246..8b8d000 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,14 @@ $ ./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. 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 + 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 +54,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 +81,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/docs/dev-authentication.md b/docs/dev-authentication.md index 4237a6a..9b84c4b 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,41 +106,26 @@ The Login function: - In case user is not yet present add user to user database table with `AuthViaToken` AuthSource. * Return valid user object -# Auth +## JWT Cookie Session authenticator -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 +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` -## Local + This authenticator is applied if the configured cookie is present: +``` + jwtCookie, err := r.Cookie(cookieName) -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. + if err == nil && jwtCookie.Value != "" { + return true + } +``` -## 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 +The Login function: +* 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 @@ -150,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 9ac75be..8149bc1 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,170 +27,22 @@ 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 { @@ -214,9 +65,8 @@ 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) - Auth(rw http.ResponseWriter, r *http.Request) (*User, error) } type ContextKey string @@ -234,6 +84,50 @@ 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") + // } + // + username, _ := session.Values["username"].(string) + projects, _ := session.Values["projects"].([]string) + roles, _ := session.Values["roles"].([]string) + 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 +151,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 +166,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 +197,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, username, 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 +237,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 +256,40 @@ 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 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 { 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") + 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..6a77fc4 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,22 @@ 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: + // there is no token if rawtoken == "" { - return ja.auth.AuthViaSession(rw, r) + return nil, nil } - // 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 +98,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,42 +111,11 @@ 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, - AuthSource: AuthViaToken, + AuthType: AuthToken, + AuthSource: -1, }, nil } diff --git a/internal/auth/jwtCookieSession.go b/internal/auth/jwtCookieSession.go new file mode 100644 index 0000000..8f31335 --- /dev/null +++ b/internal/auth/jwtCookieSession.go @@ -0,0 +1,204 @@ +// 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" + + "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 + + 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) + } + + // 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, + username string, + rw http.ResponseWriter, + r *http.Request) bool { + + 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) + + 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, + } + } + + return user, nil +} diff --git a/internal/auth/jwtSession.go b/internal/auth/jwtSession.go new file mode 100644 index 0000000..e0f2f8b --- /dev/null +++ b/internal/auth/jwtSession.go @@ -0,0 +1,105 @@ +// 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" + + "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, + username string, + rw http.ResponseWriter, + r *http.Request) bool { + + return r.Header.Get("Authorization") != "" || r.URL.Query().Get("login-token") != "" +} + +func (ja *JWTSessionAuthenticator) 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.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) + + 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, + } + } + + return user, nil +} diff --git a/internal/auth/ldap.go b/internal/auth/ldap.go index 81b0d93..17b5c0c 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. @@ -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( @@ -93,13 +134,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 @@ -131,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 29996a4..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 { @@ -39,17 +40,11 @@ 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") } 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/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) diff --git a/internal/repository/testdata/job.db b/internal/repository/testdata/job.db index 9b37f84..4b00aa5 100644 Binary files a/internal/repository/testdata/job.db and b/internal/repository/testdata/job.db differ diff --git a/pkg/schema/config.go b/pkg/schema/config.go index 30515dd..b59feb3 100644 --- a/pkg/schema/config.go +++ b/pkg/schema/config.go @@ -17,10 +17,11 @@ 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 { - // 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"` @@ -105,7 +106,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. @@ -116,7 +117,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"`