Merge pull request #198 from ClusterCockpit/189-refactor-authentication-module

refactor auth module
This commit is contained in:
Jan Eitzinger 2023-08-18 14:35:02 +02:00 committed by GitHub
commit 3b9b37a0f3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 1747 additions and 1164 deletions

View File

@ -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. Supports job archive version 1 and database version 6.
This is a minor release of `cc-backend`, the API backend and frontend This is a minor release of `cc-backend`, the API backend and frontend
implementation of ClusterCockpit. 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 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 column `num_hwthreads`. In a future release this column will be renamed to

View File

@ -211,10 +211,7 @@ func main() {
var authentication *auth.Authentication var authentication *auth.Authentication
if !config.Keys.DisableAuthentication { if !config.Keys.DisableAuthentication {
var err error var err error
if authentication, err = auth.Init(db.DB, map[string]interface{}{ if authentication, err = auth.Init(); err != nil {
"ldap": config.Keys.LdapConfig,
"jwt": config.Keys.JwtConfig,
}); err != nil {
log.Fatalf("auth initialization failed: %v", err) log.Fatalf("auth initialization failed: %v", err)
} }
@ -228,14 +225,16 @@ func main() {
log.Fatal("invalid argument format for user creation") log.Fatal("invalid argument format for user creation")
} }
if err := authentication.AddUser(&auth.User{ ur := repository.GetUserRepository()
if err := ur.AddUser(&schema.User{
Username: parts[0], Projects: make([]string, 0), Password: parts[2], Roles: strings.Split(parts[1], ","), Username: parts[0], Projects: make([]string, 0), Password: parts[2], Roles: strings.Split(parts[1], ","),
}); err != nil { }); err != nil {
log.Fatalf("adding '%s' user authentication failed: %v", parts[0], err) log.Fatalf("adding '%s' user authentication failed: %v", parts[0], err)
} }
} }
if flagDelUser != "" { if flagDelUser != "" {
if err := authentication.DelUser(flagDelUser); err != nil { ur := repository.GetUserRepository()
if err := ur.DelUser(flagDelUser); err != nil {
log.Fatalf("deleting user failed: %v", err) log.Fatalf("deleting user failed: %v", err)
} }
} }
@ -252,12 +251,13 @@ func main() {
} }
if flagGenJWT != "" { if flagGenJWT != "" {
user, err := authentication.GetUser(flagGenJWT) ur := repository.GetUserRepository()
user, err := ur.GetUser(flagGenJWT)
if err != nil { if err != nil {
log.Fatalf("could not get user from JWT: %v", err) log.Fatalf("could not get user from JWT: %v", err)
} }
if !user.HasRole(auth.RoleApi) { if !user.HasRole(schema.RoleApi) {
log.Warnf("user '%s' does not have the API role", user.Username) log.Warnf("user '%s' does not have the API role", user.Username)
} }
@ -327,21 +327,19 @@ func main() {
r.HandleFunc("/login", func(rw http.ResponseWriter, r *http.Request) { r.HandleFunc("/login", func(rw http.ResponseWriter, r *http.Request) {
rw.Header().Add("Content-Type", "text/html; charset=utf-8") rw.Header().Add("Content-Type", "text/html; charset=utf-8")
web.RenderTemplate(rw, r, "login.tmpl", &web.Page{Title: "Login", Build: buildInfo}) web.RenderTemplate(rw, "login.tmpl", &web.Page{Title: "Login", Build: buildInfo})
}).Methods(http.MethodGet) }).Methods(http.MethodGet)
r.HandleFunc("/imprint", func(rw http.ResponseWriter, r *http.Request) { r.HandleFunc("/imprint", func(rw http.ResponseWriter, r *http.Request) {
rw.Header().Add("Content-Type", "text/html; charset=utf-8") rw.Header().Add("Content-Type", "text/html; charset=utf-8")
web.RenderTemplate(rw, r, "imprint.tmpl", &web.Page{Title: "Imprint", Build: buildInfo}) web.RenderTemplate(rw, "imprint.tmpl", &web.Page{Title: "Imprint", Build: buildInfo})
}) })
r.HandleFunc("/privacy", func(rw http.ResponseWriter, r *http.Request) { r.HandleFunc("/privacy", func(rw http.ResponseWriter, r *http.Request) {
rw.Header().Add("Content-Type", "text/html; charset=utf-8") rw.Header().Add("Content-Type", "text/html; charset=utf-8")
web.RenderTemplate(rw, r, "privacy.tmpl", &web.Page{Title: "Privacy", Build: buildInfo}) web.RenderTemplate(rw, "privacy.tmpl", &web.Page{Title: "Privacy", Build: buildInfo})
}) })
// Some routes, such as /login or /query, should only be accessible to a user that is logged in.
// Those should be mounted to this subrouter. If authentication is enabled, a middleware will prevent
// any unauthenticated accesses.
secured := r.PathPrefix("/").Subrouter() secured := r.PathPrefix("/").Subrouter()
if !config.Keys.DisableAuthentication { if !config.Keys.DisableAuthentication {
r.Handle("/login", authentication.Login( r.Handle("/login", authentication.Login(
// On success: // On success:
@ -351,7 +349,7 @@ func main() {
func(rw http.ResponseWriter, r *http.Request, err error) { func(rw http.ResponseWriter, r *http.Request, err error) {
rw.Header().Add("Content-Type", "text/html; charset=utf-8") rw.Header().Add("Content-Type", "text/html; charset=utf-8")
rw.WriteHeader(http.StatusUnauthorized) rw.WriteHeader(http.StatusUnauthorized)
web.RenderTemplate(rw, r, "login.tmpl", &web.Page{ web.RenderTemplate(rw, "login.tmpl", &web.Page{
Title: "Login failed - ClusterCockpit", Title: "Login failed - ClusterCockpit",
MsgType: "alert-warning", MsgType: "alert-warning",
Message: err.Error(), Message: err.Error(),
@ -359,16 +357,33 @@ func main() {
}) })
})).Methods(http.MethodPost) })).Methods(http.MethodPost)
r.Handle("/logout", authentication.Logout(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { r.Handle("/jwt-login", authentication.Login(
rw.Header().Add("Content-Type", "text/html; charset=utf-8") // On success:
rw.WriteHeader(http.StatusOK) http.RedirectHandler("/", http.StatusTemporaryRedirect),
web.RenderTemplate(rw, r, "login.tmpl", &web.Page{
Title: "Bye - ClusterCockpit", // On failure:
MsgType: "alert-info", func(rw http.ResponseWriter, r *http.Request, err error) {
Message: "Logout successful", rw.Header().Add("Content-Type", "text/html; charset=utf-8")
Build: buildInfo, rw.WriteHeader(http.StatusUnauthorized)
}) web.RenderTemplate(rw, "login.tmpl", &web.Page{
}))).Methods(http.MethodPost) Title: "Login failed - ClusterCockpit",
MsgType: "alert-warning",
Message: err.Error(),
Build: buildInfo,
})
}))
r.Handle("/logout", authentication.Logout(
http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
rw.Header().Add("Content-Type", "text/html; charset=utf-8")
rw.WriteHeader(http.StatusOK)
web.RenderTemplate(rw, "login.tmpl", &web.Page{
Title: "Bye - ClusterCockpit",
MsgType: "alert-info",
Message: "Logout successful",
Build: buildInfo,
})
}))).Methods(http.MethodPost)
secured.Use(func(next http.Handler) http.Handler { secured.Use(func(next http.Handler) http.Handler {
return authentication.Auth( return authentication.Auth(
@ -378,7 +393,7 @@ func main() {
// On failure: // On failure:
func(rw http.ResponseWriter, r *http.Request, err error) { func(rw http.ResponseWriter, r *http.Request, err error) {
rw.WriteHeader(http.StatusUnauthorized) rw.WriteHeader(http.StatusUnauthorized)
web.RenderTemplate(rw, r, "login.tmpl", &web.Page{ web.RenderTemplate(rw, "login.tmpl", &web.Page{
Title: "Authentication failed - ClusterCockpit", Title: "Authentication failed - ClusterCockpit",
MsgType: "alert-danger", MsgType: "alert-danger",
Message: err.Error(), Message: err.Error(),

View File

@ -4,6 +4,9 @@
"kind": "file", "kind": "file",
"path": "./var/job-archive" "path": "./var/job-archive"
}, },
"jwts": {
"max-age": "2m"
},
"clusters": [ "clusters": [
{ {
"name": "fritz", "name": "fritz",

View File

@ -42,9 +42,9 @@
], ],
"jwts": { "jwts": {
"cookieName": "", "cookieName": "",
"forceJWTValidationViaDatabase": false, "validateUser": false,
"max-age": 0, "max-age": "2m",
"trustedExternalIssuer": "" "trustedIssuer": ""
}, },
"short-running-jobs-duration": 300 "short-running-jobs-duration": 300
} }

View File

@ -1,11 +1,13 @@
## Introduction ## Introduction
ClusterCockpit uses JSON Web Tokens (JWT) for authorization of its APIs. ClusterCockpit uses JSON Web Tokens (JWT) for authorization of its APIs. JSON
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. Web Token (JWT) is an open standard (RFC 7519) that defines a compact and
This information can be verified and trusted because it is digitally signed. self-contained way for securely transmitting information between parties as a
In ClusterCockpit JWTs are signed using a public/private key pair using ECDSA. JSON object. This information can be verified and trusted because it is
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. digitally signed. In ClusterCockpit JWTs are signed using a public/private key
Currently JWT tokens in ClusterCockpit not yet expire. 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 ## JWT Payload
@ -25,8 +27,14 @@ $ ./gen-keypair
2. Add keypair in your `.env` file. A template can be found in `./configs`. 2. Add keypair in your `.env` file. A template can be found in `./configs`.
There are two usage scenarios: 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 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 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. The token is commonly specified in the Authorization HTTP header using the Bearer schema.
@ -46,16 +54,24 @@ $ curl -X GET "<API ENDPOINT>" -H "accept: application/json" -H "Content-Type:
``` ```
## Accept externally generated JWTs provided via cookie ## 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=" 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. 2. `config.json`: Insert a name for the cookie (set by the external service)
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. 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 ```json
"jwts": { "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 ```json
{ {

View File

@ -1,13 +1,15 @@
# Overview # Overview
The implementation of authentication is not easy to understand by just looking The authentication is implemented in `internal/auth/`. In `auth.go`
at the code. The authentication is implemented in `internal/auth/`. In `auth.go`
an interface is defined that any authentication provider must fulfill. It also an interface is defined that any authentication provider must fulfill. It also
acts as a dispatcher to delegate the calls to the available authentication acts as a dispatcher to delegate the calls to the available authentication
providers. providers.
The most important routine are: Two authentication types are available:
* `CanLogin()` Check if the authentication method is supported for login attempt * 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 * `Login()` Handle POST request to login user and start a new session
* `Auth()` Authenticate user and put User Object in context of the request * `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 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 provided in a header or via a special cookie containing the JWT token.
to a secured URL via a special cookie containing the JWT token.
For API routes the access is authenticated on every request using the JWT token For API routes the access is authenticated on every request using the JWT token
and no session is initiated. 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 * 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. user is not found the user object is set to nil.
* Iterates over all authenticators and: * Iterates over all authenticators and:
- Calls the `CanLogin` function which checks if the authentication method is - Calls its `CanLogin` function which checks if the authentication method is
supported for this user and the user object is valid. supported for this user.
- Calls the `Login` function to authenticate the user. On success a valid user - Calls its `Login` function to authenticate the user. On success a valid user
object is returned. object is returned.
- Creates a new session object, stores the user attributes in the session and - Creates a new session object, stores the user attributes in the session and
saves the session. saves the session.
- If the user does not yet exist in the database try to add the user
- Starts the `onSuccess` http handler - Starts the `onSuccess` http handler
## Local authenticator ## Local authenticator
@ -63,7 +65,7 @@ the user database table:
``` ```
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) log.Errorf("AUTH/LOCAL > Authentication for user %s failed!", user.Username)
return nil, fmt.Errorf("AUTH/LOCAL > Authentication failed") return nil, fmt.Errorf("Authentication failed")
} }
``` ```
@ -77,22 +79,18 @@ return user != nil && user.AuthSource == AuthViaLDAP
Gets the LDAP connection and tries a bind with the provided credentials: Gets the LDAP connection and tries a bind with the provided credentials:
``` ```
if err := l.Bind(userDn, r.FormValue("password")); err != nil { if err := l.Bind(userDn, r.FormValue("password")); err != nil {
log.Errorf("AUTH/LOCAL > Authentication for user %s failed: %v", user.Username, err) log.Errorf("AUTH/LDAP > Authentication for user %s failed: %v", user.Username, err)
return nil, fmt.Errorf("AUTH/LDAP > Authentication failed") return nil, fmt.Errorf("Authentication failed")
} }
``` ```
## JWT authenticator ## JWT Session authenticator
Login via JWT token will create a session without password. Login via JWT token will create a session without password.
For login the `X-Auth-Token` header is not supported. For login the `X-Auth-Token` header is not supported. This authenticator is
This authenticator is applied if either user is not nil and auth source is applied if the Authorization header is present:
`AuthViaToken` or the Authorization header is present or the URL query key
login-token is present:
``` ```
return (user != nil && user.AuthSource == AuthViaToken) || return r.Header.Get("Authorization") != ""
r.Header.Get("Authorization") != "" ||
r.URL.Query().Get("login-token") != ""
``` ```
The Login function: 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. - In case user is not yet present add user to user database table with `AuthViaToken` AuthSource.
* Return valid user object * Return valid user object
# Auth ## JWT Cookie Session authenticator
The Auth function (located in `auth.go`): Login via JWT cookie token will create a session without password.
* Returns a new http handler function that is defined right away It is first checked if the required configuration keys are set:
* This handler iterates over all authenticators * `publicKeyCrossLogin`
* Calls `Auth()` on every authenticator * `TrustedExternalIssuer`
* If err is not nil and the user object is valid it puts the user object in the * `CookieName`
request context and starts the onSuccess http handler
* Otherwise it calls the onFailure handler
## 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, if err == nil && jwtCookie.Value != "" {
projects and roles from the session and initialize a user object with those return true
values. }
```
## LDAP The Login function:
* Extracts and parses the token
Calls the `AuthViaSession()` function in `auth.go`. This will extract username, * Checks if signing method is Ed25519/EdDSA
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
* In case publicKeyCrossLogin is configured: * In case publicKeyCrossLogin is configured:
- Check if `iss` issuer claim matched trusted issuer from configuration - Check if `iss` issuer claim matched trusted issuer from configuration
- Return public cross login key - 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 * Check if claims are valid
* Depending on the option `ForceJWTValidationViaDatabase ` the roles are * Depending on the option `ForceJWTValidationViaDatabase ` the roles are
extracted from JWT token or taken from user object fetched from database 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 * Ask browser to delete the JWT cookie
browser to delete the JWT cookie
* Return valid user object * 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

View File

@ -39,6 +39,9 @@ func setup(t *testing.T) *api.RestApi {
"kind": "file", "kind": "file",
"path": "./var/job-archive" "path": "./var/job-archive"
}, },
"jwts": {
"max-age": "2m"
},
"clusters": [ "clusters": [
{ {
"name": "testcluster", "name": "testcluster",

View File

@ -1,4 +1,4 @@
// Copyright (C) 2022 NHR@FAU, University Erlangen-Nuremberg. // Copyright (C) 2023 NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. // All rights reserved.
// Use of this source code is governed by a MIT-style // Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file. // license that can be found in the LICENSE file.
@ -20,11 +20,13 @@ import (
"time" "time"
"github.com/ClusterCockpit/cc-backend/internal/auth" "github.com/ClusterCockpit/cc-backend/internal/auth"
"github.com/ClusterCockpit/cc-backend/internal/config"
"github.com/ClusterCockpit/cc-backend/internal/graph" "github.com/ClusterCockpit/cc-backend/internal/graph"
"github.com/ClusterCockpit/cc-backend/internal/graph/model" "github.com/ClusterCockpit/cc-backend/internal/graph/model"
"github.com/ClusterCockpit/cc-backend/internal/importer" "github.com/ClusterCockpit/cc-backend/internal/importer"
"github.com/ClusterCockpit/cc-backend/internal/metricdata" "github.com/ClusterCockpit/cc-backend/internal/metricdata"
"github.com/ClusterCockpit/cc-backend/internal/repository" "github.com/ClusterCockpit/cc-backend/internal/repository"
"github.com/ClusterCockpit/cc-backend/internal/util"
"github.com/ClusterCockpit/cc-backend/pkg/archive" "github.com/ClusterCockpit/cc-backend/pkg/archive"
"github.com/ClusterCockpit/cc-backend/pkg/log" "github.com/ClusterCockpit/cc-backend/pkg/log"
"github.com/ClusterCockpit/cc-backend/pkg/schema" "github.com/ClusterCockpit/cc-backend/pkg/schema"
@ -75,6 +77,13 @@ func (api *RestApi) MountRoutes(r *mux.Router) {
r.HandleFunc("/jobs/delete_job/", api.deleteJobByRequest).Methods(http.MethodDelete) r.HandleFunc("/jobs/delete_job/", api.deleteJobByRequest).Methods(http.MethodDelete)
r.HandleFunc("/jobs/delete_job/{id}", api.deleteJobById).Methods(http.MethodDelete) r.HandleFunc("/jobs/delete_job/{id}", api.deleteJobById).Methods(http.MethodDelete)
r.HandleFunc("/jobs/delete_job_before/{ts}", api.deleteJobBefore).Methods(http.MethodDelete) r.HandleFunc("/jobs/delete_job_before/{ts}", api.deleteJobBefore).Methods(http.MethodDelete)
// r.HandleFunc("/secured/addProject/{id}/{project}", api.secureUpdateUser).Methods(http.MethodPost)
// r.HandleFunc("/secured/addRole/{id}/{role}", api.secureUpdateUser).Methods(http.MethodPost)
if api.MachineStateDir != "" {
r.HandleFunc("/machine_state/{cluster}/{host}", api.getMachineState).Methods(http.MethodGet)
r.HandleFunc("/machine_state/{cluster}/{host}", api.putMachineState).Methods(http.MethodPut, http.MethodPost)
}
if api.Authentication != nil { if api.Authentication != nil {
r.HandleFunc("/jwt/", api.getJWT).Methods(http.MethodGet) r.HandleFunc("/jwt/", api.getJWT).Methods(http.MethodGet)
@ -85,11 +94,6 @@ func (api *RestApi) MountRoutes(r *mux.Router) {
r.HandleFunc("/user/{id}", api.updateUser).Methods(http.MethodPost) r.HandleFunc("/user/{id}", api.updateUser).Methods(http.MethodPost)
r.HandleFunc("/configuration/", api.updateConfiguration).Methods(http.MethodPost) r.HandleFunc("/configuration/", api.updateConfiguration).Methods(http.MethodPost)
} }
if api.MachineStateDir != "" {
r.HandleFunc("/machine_state/{cluster}/{host}", api.getMachineState).Methods(http.MethodGet)
r.HandleFunc("/machine_state/{cluster}/{host}", api.putMachineState).Methods(http.MethodPut, http.MethodPost)
}
} }
// StartJobApiResponse model // StartJobApiResponse model
@ -103,6 +107,11 @@ type DeleteJobApiResponse struct {
Message string `json:"msg"` Message string `json:"msg"`
} }
// UpdateUserApiResponse model
type UpdateUserApiResponse struct {
Message string `json:"msg"`
}
// StopJobApiRequest model // StopJobApiRequest model
type StopJobApiRequest struct { type StopJobApiRequest struct {
// Stop Time of job as epoch // Stop Time of job as epoch
@ -172,6 +181,36 @@ func decode(r io.Reader, val interface{}) error {
return dec.Decode(val) return dec.Decode(val)
} }
func securedCheck(r *http.Request) error {
user := repository.GetUserFromContext(r.Context())
if user == nil {
return fmt.Errorf("no user in context")
}
if user.AuthType == schema.AuthToken {
// If nothing declared in config: deny all request to this endpoint
if config.Keys.ApiAllowedIPs == nil || len(config.Keys.ApiAllowedIPs) == 0 {
return fmt.Errorf("missing configuration key ApiAllowedIPs")
}
// extract IP address
IPAddress := r.Header.Get("X-Real-Ip")
if IPAddress == "" {
IPAddress = r.Header.Get("X-Forwarded-For")
}
if IPAddress == "" {
IPAddress = r.RemoteAddr
}
// check if IP is allowed
if !util.Contains(config.Keys.ApiAllowedIPs, IPAddress) {
return fmt.Errorf("unknown ip: %v", IPAddress)
}
}
return nil
}
// getJobs godoc // getJobs godoc
// @summary Lists all jobs // @summary Lists all jobs
// @tags query // @tags query
@ -193,8 +232,10 @@ func decode(r io.Reader, val interface{}) error {
// @router /jobs/ [get] // @router /jobs/ [get]
func (api *RestApi) getJobs(rw http.ResponseWriter, r *http.Request) { func (api *RestApi) getJobs(rw http.ResponseWriter, r *http.Request) {
if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleApi) { if user := repository.GetUserFromContext(r.Context()); user != nil &&
handleError(fmt.Errorf("missing role: %v", auth.GetRoleString(auth.RoleApi)), http.StatusForbidden, rw) !user.HasRole(schema.RoleApi) {
handleError(fmt.Errorf("missing role: %v", schema.GetRoleString(schema.RoleApi)), http.StatusForbidden, rw)
return return
} }
@ -335,9 +376,11 @@ func (api *RestApi) getJobs(rw http.ResponseWriter, r *http.Request) {
// @security ApiKeyAuth // @security ApiKeyAuth
// @router /jobs/{id} [post] // @router /jobs/{id} [post]
func (api *RestApi) getJobById(rw http.ResponseWriter, r *http.Request) { func (api *RestApi) getJobById(rw http.ResponseWriter, r *http.Request) {
if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleApi) { if user := repository.GetUserFromContext(r.Context()); user != nil &&
!user.HasRole(schema.RoleApi) {
handleError(fmt.Errorf("missing role: %v", handleError(fmt.Errorf("missing role: %v",
auth.GetRoleString(auth.RoleApi)), http.StatusForbidden, rw) schema.GetRoleString(schema.RoleApi)), http.StatusForbidden, rw)
return return
} }
@ -426,8 +469,10 @@ func (api *RestApi) getJobById(rw http.ResponseWriter, r *http.Request) {
// @security ApiKeyAuth // @security ApiKeyAuth
// @router /jobs/tag_job/{id} [post] // @router /jobs/tag_job/{id} [post]
func (api *RestApi) tagJob(rw http.ResponseWriter, r *http.Request) { func (api *RestApi) tagJob(rw http.ResponseWriter, r *http.Request) {
if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleApi) { if user := repository.GetUserFromContext(r.Context()); user != nil &&
handleError(fmt.Errorf("missing role: %v", auth.GetRoleString(auth.RoleApi)), http.StatusForbidden, rw) !user.HasRole(schema.RoleApi) {
handleError(fmt.Errorf("missing role: %v", schema.GetRoleString(schema.RoleApi)), http.StatusForbidden, rw)
return return
} }
@ -491,8 +536,10 @@ func (api *RestApi) tagJob(rw http.ResponseWriter, r *http.Request) {
// @security ApiKeyAuth // @security ApiKeyAuth
// @router /jobs/start_job/ [post] // @router /jobs/start_job/ [post]
func (api *RestApi) startJob(rw http.ResponseWriter, r *http.Request) { func (api *RestApi) startJob(rw http.ResponseWriter, r *http.Request) {
if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleApi) { if user := repository.GetUserFromContext(r.Context()); user != nil &&
handleError(fmt.Errorf("missing role: %v", auth.GetRoleString(auth.RoleApi)), http.StatusForbidden, rw) !user.HasRole(schema.RoleApi) {
handleError(fmt.Errorf("missing role: %v", schema.GetRoleString(schema.RoleApi)), http.StatusForbidden, rw)
return return
} }
@ -572,8 +619,10 @@ func (api *RestApi) startJob(rw http.ResponseWriter, r *http.Request) {
// @security ApiKeyAuth // @security ApiKeyAuth
// @router /jobs/stop_job/{id} [post] // @router /jobs/stop_job/{id} [post]
func (api *RestApi) stopJobById(rw http.ResponseWriter, r *http.Request) { func (api *RestApi) stopJobById(rw http.ResponseWriter, r *http.Request) {
if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleApi) { if user := repository.GetUserFromContext(r.Context()); user != nil &&
handleError(fmt.Errorf("missing role: %v", auth.GetRoleString(auth.RoleApi)), http.StatusForbidden, rw) !user.HasRole(schema.RoleApi) {
handleError(fmt.Errorf("missing role: %v", schema.GetRoleString(schema.RoleApi)), http.StatusForbidden, rw)
return return
} }
@ -625,8 +674,10 @@ func (api *RestApi) stopJobById(rw http.ResponseWriter, r *http.Request) {
// @security ApiKeyAuth // @security ApiKeyAuth
// @router /jobs/stop_job/ [post] // @router /jobs/stop_job/ [post]
func (api *RestApi) stopJobByRequest(rw http.ResponseWriter, r *http.Request) { func (api *RestApi) stopJobByRequest(rw http.ResponseWriter, r *http.Request) {
if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleApi) { if user := repository.GetUserFromContext(r.Context()); user != nil &&
handleError(fmt.Errorf("missing role: %v", auth.GetRoleString(auth.RoleApi)), http.StatusForbidden, rw) !user.HasRole(schema.RoleApi) {
handleError(fmt.Errorf("missing role: %v", schema.GetRoleString(schema.RoleApi)), http.StatusForbidden, rw)
return return
} }
@ -671,8 +722,8 @@ func (api *RestApi) stopJobByRequest(rw http.ResponseWriter, r *http.Request) {
// @security ApiKeyAuth // @security ApiKeyAuth
// @router /jobs/delete_job/{id} [delete] // @router /jobs/delete_job/{id} [delete]
func (api *RestApi) deleteJobById(rw http.ResponseWriter, r *http.Request) { func (api *RestApi) deleteJobById(rw http.ResponseWriter, r *http.Request) {
if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleApi) { if user := repository.GetUserFromContext(r.Context()); user != nil && !user.HasRole(schema.RoleApi) {
handleError(fmt.Errorf("missing role: %v", auth.GetRoleString(auth.RoleApi)), http.StatusForbidden, rw) handleError(fmt.Errorf("missing role: %v", schema.GetRoleString(schema.RoleApi)), http.StatusForbidden, rw)
return return
} }
@ -719,8 +770,9 @@ func (api *RestApi) deleteJobById(rw http.ResponseWriter, r *http.Request) {
// @security ApiKeyAuth // @security ApiKeyAuth
// @router /jobs/delete_job/ [delete] // @router /jobs/delete_job/ [delete]
func (api *RestApi) deleteJobByRequest(rw http.ResponseWriter, r *http.Request) { func (api *RestApi) deleteJobByRequest(rw http.ResponseWriter, r *http.Request) {
if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleApi) { if user := repository.GetUserFromContext(r.Context()); user != nil &&
handleError(fmt.Errorf("missing role: %v", auth.GetRoleString(auth.RoleApi)), http.StatusForbidden, rw) !user.HasRole(schema.RoleApi) {
handleError(fmt.Errorf("missing role: %v", schema.GetRoleString(schema.RoleApi)), http.StatusForbidden, rw)
return return
} }
@ -775,8 +827,8 @@ func (api *RestApi) deleteJobByRequest(rw http.ResponseWriter, r *http.Request)
// @security ApiKeyAuth // @security ApiKeyAuth
// @router /jobs/delete_job_before/{ts} [delete] // @router /jobs/delete_job_before/{ts} [delete]
func (api *RestApi) deleteJobBefore(rw http.ResponseWriter, r *http.Request) { func (api *RestApi) deleteJobBefore(rw http.ResponseWriter, r *http.Request) {
if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleApi) { if user := repository.GetUserFromContext(r.Context()); user != nil && !user.HasRole(schema.RoleApi) {
handleError(fmt.Errorf("missing role: %v", auth.GetRoleString(auth.RoleApi)), http.StatusForbidden, rw) handleError(fmt.Errorf("missing role: %v", schema.GetRoleString(schema.RoleApi)), http.StatusForbidden, rw)
return return
} }
@ -892,10 +944,15 @@ func (api *RestApi) getJobMetrics(rw http.ResponseWriter, r *http.Request) {
} }
func (api *RestApi) getJWT(rw http.ResponseWriter, r *http.Request) { func (api *RestApi) getJWT(rw http.ResponseWriter, r *http.Request) {
err := securedCheck(r)
if err != nil {
http.Error(rw, err.Error(), http.StatusForbidden)
}
rw.Header().Set("Content-Type", "text/plain") rw.Header().Set("Content-Type", "text/plain")
username := r.FormValue("username") username := r.FormValue("username")
me := auth.GetUser(r.Context()) me := repository.GetUserFromContext(r.Context())
if !me.HasRole(auth.RoleAdmin) { if !me.HasRole(schema.RoleAdmin) {
if username != me.Username { if username != me.Username {
http.Error(rw, "Only admins are allowed to sign JWTs not for themselves", http.Error(rw, "Only admins are allowed to sign JWTs not for themselves",
http.StatusForbidden) http.StatusForbidden)
@ -903,7 +960,7 @@ func (api *RestApi) getJWT(rw http.ResponseWriter, r *http.Request) {
} }
} }
user, err := api.Authentication.GetUser(username) user, err := repository.GetUserRepository().GetUser(username)
if err != nil { if err != nil {
http.Error(rw, err.Error(), http.StatusUnprocessableEntity) http.Error(rw, err.Error(), http.StatusUnprocessableEntity)
return return
@ -920,28 +977,38 @@ func (api *RestApi) getJWT(rw http.ResponseWriter, r *http.Request) {
} }
func (api *RestApi) createUser(rw http.ResponseWriter, r *http.Request) { func (api *RestApi) createUser(rw http.ResponseWriter, r *http.Request) {
err := securedCheck(r)
if err != nil {
http.Error(rw, err.Error(), http.StatusForbidden)
}
rw.Header().Set("Content-Type", "text/plain") rw.Header().Set("Content-Type", "text/plain")
me := auth.GetUser(r.Context()) me := repository.GetUserFromContext(r.Context())
if !me.HasRole(auth.RoleAdmin) { if !me.HasRole(schema.RoleAdmin) {
http.Error(rw, "Only admins are allowed to create new users", http.StatusForbidden) http.Error(rw, "Only admins are allowed to create new users", http.StatusForbidden)
return return
} }
username, password, role, name, email, project := r.FormValue("username"), r.FormValue("password"), r.FormValue("role"), r.FormValue("name"), r.FormValue("email"), r.FormValue("project") username, password, role, name, email, project := r.FormValue("username"),
if len(password) == 0 && role != auth.GetRoleString(auth.RoleApi) { r.FormValue("password"), r.FormValue("role"), r.FormValue("name"),
r.FormValue("email"), r.FormValue("project")
if len(password) == 0 && role != schema.GetRoleString(schema.RoleApi) {
http.Error(rw, "Only API users are allowed to have a blank password (login will be impossible)", http.StatusBadRequest) http.Error(rw, "Only API users are allowed to have a blank password (login will be impossible)", http.StatusBadRequest)
return return
} }
if len(project) != 0 && role != auth.GetRoleString(auth.RoleManager) { if len(project) != 0 && role != schema.GetRoleString(schema.RoleManager) {
http.Error(rw, "only managers require a project (can be changed later)", http.StatusBadRequest) http.Error(rw, "only managers require a project (can be changed later)",
http.StatusBadRequest)
return return
} else if len(project) == 0 && role == auth.GetRoleString(auth.RoleManager) { } else if len(project) == 0 && role == schema.GetRoleString(schema.RoleManager) {
http.Error(rw, "managers require a project to manage (can be changed later)", http.StatusBadRequest) http.Error(rw, "managers require a project to manage (can be changed later)",
http.StatusBadRequest)
return return
} }
if err := api.Authentication.AddUser(&auth.User{ if err := repository.GetUserRepository().AddUser(&schema.User{
Username: username, Username: username,
Name: name, Name: name,
Password: password, Password: password,
@ -956,13 +1023,18 @@ func (api *RestApi) createUser(rw http.ResponseWriter, r *http.Request) {
} }
func (api *RestApi) deleteUser(rw http.ResponseWriter, r *http.Request) { func (api *RestApi) deleteUser(rw http.ResponseWriter, r *http.Request) {
if user := auth.GetUser(r.Context()); !user.HasRole(auth.RoleAdmin) { err := securedCheck(r)
if err != nil {
http.Error(rw, err.Error(), http.StatusForbidden)
}
if user := repository.GetUserFromContext(r.Context()); !user.HasRole(schema.RoleAdmin) {
http.Error(rw, "Only admins are allowed to delete a user", http.StatusForbidden) http.Error(rw, "Only admins are allowed to delete a user", http.StatusForbidden)
return return
} }
username := r.FormValue("username") username := r.FormValue("username")
if err := api.Authentication.DelUser(username); err != nil { if err := repository.GetUserRepository().DelUser(username); err != nil {
http.Error(rw, err.Error(), http.StatusUnprocessableEntity) http.Error(rw, err.Error(), http.StatusUnprocessableEntity)
return return
} }
@ -971,12 +1043,17 @@ func (api *RestApi) deleteUser(rw http.ResponseWriter, r *http.Request) {
} }
func (api *RestApi) getUsers(rw http.ResponseWriter, r *http.Request) { func (api *RestApi) getUsers(rw http.ResponseWriter, r *http.Request) {
if user := auth.GetUser(r.Context()); !user.HasRole(auth.RoleAdmin) { err := securedCheck(r)
if err != nil {
http.Error(rw, err.Error(), http.StatusForbidden)
}
if user := repository.GetUserFromContext(r.Context()); !user.HasRole(schema.RoleAdmin) {
http.Error(rw, "Only admins are allowed to fetch a list of users", http.StatusForbidden) http.Error(rw, "Only admins are allowed to fetch a list of users", http.StatusForbidden)
return return
} }
users, err := api.Authentication.ListUsers(r.URL.Query().Get("not-just-user") == "true") users, err := repository.GetUserRepository().ListUsers(r.URL.Query().Get("not-just-user") == "true")
if err != nil { if err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError) http.Error(rw, err.Error(), http.StatusInternalServerError)
return return
@ -986,13 +1063,18 @@ func (api *RestApi) getUsers(rw http.ResponseWriter, r *http.Request) {
} }
func (api *RestApi) getRoles(rw http.ResponseWriter, r *http.Request) { func (api *RestApi) getRoles(rw http.ResponseWriter, r *http.Request) {
user := auth.GetUser(r.Context()) err := securedCheck(r)
if !user.HasRole(auth.RoleAdmin) { if err != nil {
http.Error(rw, err.Error(), http.StatusForbidden)
}
user := repository.GetUserFromContext(r.Context())
if !user.HasRole(schema.RoleAdmin) {
http.Error(rw, "only admins are allowed to fetch a list of roles", http.StatusForbidden) http.Error(rw, "only admins are allowed to fetch a list of roles", http.StatusForbidden)
return return
} }
roles, err := auth.GetValidRoles(user) roles, err := schema.GetValidRoles(user)
if err != nil { if err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError) http.Error(rw, err.Error(), http.StatusInternalServerError)
return return
@ -1002,7 +1084,12 @@ func (api *RestApi) getRoles(rw http.ResponseWriter, r *http.Request) {
} }
func (api *RestApi) updateUser(rw http.ResponseWriter, r *http.Request) { func (api *RestApi) updateUser(rw http.ResponseWriter, r *http.Request) {
if user := auth.GetUser(r.Context()); !user.HasRole(auth.RoleAdmin) { err := securedCheck(r)
if err != nil {
http.Error(rw, err.Error(), http.StatusForbidden)
}
if user := repository.GetUserFromContext(r.Context()); !user.HasRole(schema.RoleAdmin) {
http.Error(rw, "Only admins are allowed to update a user", http.StatusForbidden) http.Error(rw, "Only admins are allowed to update a user", http.StatusForbidden)
return return
} }
@ -1015,25 +1102,25 @@ func (api *RestApi) updateUser(rw http.ResponseWriter, r *http.Request) {
// TODO: Handle anything but roles... // TODO: Handle anything but roles...
if newrole != "" { if newrole != "" {
if err := api.Authentication.AddRole(r.Context(), mux.Vars(r)["id"], newrole); err != nil { if err := repository.GetUserRepository().AddRole(r.Context(), mux.Vars(r)["id"], newrole); err != nil {
http.Error(rw, err.Error(), http.StatusUnprocessableEntity) http.Error(rw, err.Error(), http.StatusUnprocessableEntity)
return return
} }
rw.Write([]byte("Add Role Success")) rw.Write([]byte("Add Role Success"))
} else if delrole != "" { } else if delrole != "" {
if err := api.Authentication.RemoveRole(r.Context(), mux.Vars(r)["id"], delrole); err != nil { if err := repository.GetUserRepository().RemoveRole(r.Context(), mux.Vars(r)["id"], delrole); err != nil {
http.Error(rw, err.Error(), http.StatusUnprocessableEntity) http.Error(rw, err.Error(), http.StatusUnprocessableEntity)
return return
} }
rw.Write([]byte("Remove Role Success")) rw.Write([]byte("Remove Role Success"))
} else if newproj != "" { } else if newproj != "" {
if err := api.Authentication.AddProject(r.Context(), mux.Vars(r)["id"], newproj); err != nil { if err := repository.GetUserRepository().AddProject(r.Context(), mux.Vars(r)["id"], newproj); err != nil {
http.Error(rw, err.Error(), http.StatusUnprocessableEntity) http.Error(rw, err.Error(), http.StatusUnprocessableEntity)
return return
} }
rw.Write([]byte("Add Project Success")) rw.Write([]byte("Add Project Success"))
} else if delproj != "" { } else if delproj != "" {
if err := api.Authentication.RemoveProject(r.Context(), mux.Vars(r)["id"], delproj); err != nil { if err := repository.GetUserRepository().RemoveProject(r.Context(), mux.Vars(r)["id"], delproj); err != nil {
http.Error(rw, err.Error(), http.StatusUnprocessableEntity) http.Error(rw, err.Error(), http.StatusUnprocessableEntity)
return return
} }
@ -1043,13 +1130,78 @@ func (api *RestApi) updateUser(rw http.ResponseWriter, r *http.Request) {
} }
} }
// func (api *RestApi) secureUpdateUser(rw http.ResponseWriter, r *http.Request) {
// if user := auth.GetUser(r.Context()); user != nil && !user.HasRole(auth.RoleApi) {
// handleError(fmt.Errorf("missing role: %v", auth.GetRoleString(auth.RoleApi)), http.StatusForbidden, rw)
// return
// }
//
// // IP CHECK HERE (WIP)
// // Probably better as private routine
// IPAddress := r.Header.Get("X-Real-Ip")
// if IPAddress == "" {
// IPAddress = r.Header.Get("X-Forwarded-For")
// }
// if IPAddress == "" {
// IPAddress = r.RemoteAddr
// }
//
// // Also This
// ipOk := false
// for _, a := range config.Keys.ApiAllowedAddrs {
// if a == IPAddress {
// ipOk = true
// }
// }
//
// if IPAddress == "" || ipOk == false {
// handleError(fmt.Errorf("unknown ip: %v", IPAddress), http.StatusForbidden, rw)
// return
// }
// // IP CHECK END
//
// // Get Values
// id := mux.Vars(r)["id"]
// newproj := mux.Vars(r)["project"]
// newrole := mux.Vars(r)["role"]
//
// // TODO: Handle anything but roles...
// if newrole != "" {
// if err := api.Authentication.AddRole(r.Context(), id, newrole); err != nil {
// handleError(errors.New(err.Error()), http.StatusUnprocessableEntity, rw)
// return
// }
//
// rw.Header().Add("Content-Type", "application/json")
// rw.WriteHeader(http.StatusOK)
// json.NewEncoder(rw).Encode(UpdateUserApiResponse{
// Message: fmt.Sprintf("Successfully added role %s to %s", newrole, id),
// })
//
// } else if newproj != "" {
// if err := api.Authentication.AddProject(r.Context(), id, newproj); err != nil {
// handleError(errors.New(err.Error()), http.StatusUnprocessableEntity, rw)
// return
// }
//
// rw.Header().Add("Content-Type", "application/json")
// rw.WriteHeader(http.StatusOK)
// json.NewEncoder(rw).Encode(UpdateUserApiResponse{
// Message: fmt.Sprintf("Successfully added project %s to %s", newproj, id),
// })
//
// } else {
// handleError(errors.New("Not Add [role|project]?"), http.StatusBadRequest, rw)
// }
// }
func (api *RestApi) updateConfiguration(rw http.ResponseWriter, r *http.Request) { func (api *RestApi) updateConfiguration(rw http.ResponseWriter, r *http.Request) {
rw.Header().Set("Content-Type", "text/plain") rw.Header().Set("Content-Type", "text/plain")
key, value := r.FormValue("key"), r.FormValue("value") key, value := r.FormValue("key"), r.FormValue("value")
fmt.Printf("REST > KEY: %#v\nVALUE: %#v\n", key, value) fmt.Printf("REST > KEY: %#v\nVALUE: %#v\n", key, value)
if err := repository.GetUserCfgRepo().UpdateConfig(key, value, auth.GetUser(r.Context())); err != nil { if err := repository.GetUserCfgRepo().UpdateConfig(key, value, repository.GetUserFromContext(r.Context())); err != nil {
http.Error(rw, err.Error(), http.StatusUnprocessableEntity) http.Error(rw, err.Error(), http.StatusUnprocessableEntity)
return return
} }

View File

@ -1,4 +1,4 @@
// Copyright (C) 2022 NHR@FAU, University Erlangen-Nuremberg. // Copyright (C) 2023 NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. // All rights reserved.
// Use of this source code is governed by a MIT-style // Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file. // license that can be found in the LICENSE file.
@ -7,224 +7,26 @@ package auth
import ( import (
"context" "context"
"crypto/rand" "crypto/rand"
"database/sql"
"encoding/base64" "encoding/base64"
"errors" "errors"
"fmt"
"net/http" "net/http"
"os" "os"
"strings"
"time" "time"
"github.com/ClusterCockpit/cc-backend/internal/config"
"github.com/ClusterCockpit/cc-backend/internal/repository"
"github.com/ClusterCockpit/cc-backend/pkg/log" "github.com/ClusterCockpit/cc-backend/pkg/log"
"github.com/ClusterCockpit/cc-backend/pkg/schema"
"github.com/gorilla/sessions" "github.com/gorilla/sessions"
"github.com/jmoiron/sqlx"
) )
type AuthSource int
const (
AuthViaLocalPassword AuthSource = iota
AuthViaLDAP
AuthViaToken
)
type User struct {
Username string `json:"username"`
Password string `json:"-"`
Name string `json:"name"`
Roles []string `json:"roles"`
AuthSource AuthSource `json:"via"`
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 {
return true
}
}
return false
}
func GetUser(ctx context.Context) *User {
x := ctx.Value(ContextUserKey)
if x == nil {
return nil
}
return x.(*User)
}
type Authenticator interface { type Authenticator interface {
Init(auth *Authentication, config interface{}) error CanLogin(user *schema.User, username string, rw http.ResponseWriter, r *http.Request) (*schema.User, bool)
CanLogin(user *User, rw http.ResponseWriter, r *http.Request) bool Login(user *schema.User, rw http.ResponseWriter, r *http.Request) (*schema.User, error)
Login(user *User, rw http.ResponseWriter, r *http.Request) (*User, error)
Auth(rw http.ResponseWriter, r *http.Request) (*User, error)
} }
type ContextKey string
const ContextUserKey ContextKey = "user"
type Authentication struct { type Authentication struct {
db *sqlx.DB
sessionStore *sessions.CookieStore sessionStore *sessions.CookieStore
SessionMaxAge time.Duration SessionMaxAge time.Duration
@ -234,10 +36,52 @@ type Authentication struct {
LocalAuth *LocalAuthenticator LocalAuth *LocalAuthenticator
} }
func Init(db *sqlx.DB, func (auth *Authentication) AuthViaSession(
configs map[string]interface{}) (*Authentication, error) { rw http.ResponseWriter,
r *http.Request) (*schema.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 &schema.User{
Username: username,
Projects: projects,
Roles: roles,
AuthType: schema.AuthSession,
AuthSource: -1,
}, nil
}
func Init() (*Authentication, error) {
auth := &Authentication{} auth := &Authentication{}
auth.db = db
sessKey := os.Getenv("SESSION_KEY") sessKey := os.Getenv("SESSION_KEY")
if sessKey == "" { if sessKey == "" {
@ -257,78 +101,78 @@ func Init(db *sqlx.DB,
auth.sessionStore = sessions.NewCookieStore(bytes) auth.sessionStore = sessions.NewCookieStore(bytes)
} }
if config.Keys.LdapConfig != nil {
ldapAuth := &LdapAuthenticator{}
if err := ldapAuth.Init(); err != nil {
log.Warn("Error while initializing authentication -> ldapAuth init failed")
} else {
auth.LdapAuth = ldapAuth
auth.authenticators = append(auth.authenticators, auth.LdapAuth)
}
} else {
log.Info("Missing LDAP configuration: No LDAP support!")
}
if config.Keys.JwtConfig != nil {
auth.JwtAuth = &JWTAuthenticator{}
if err := auth.JwtAuth.Init(); err != nil {
log.Error("Error while initializing authentication -> jwtAuth init failed")
return nil, err
}
jwtSessionAuth := &JWTSessionAuthenticator{}
if err := jwtSessionAuth.Init(); err != nil {
log.Info("jwtSessionAuth init failed: No JWT login support!")
} else {
auth.authenticators = append(auth.authenticators, jwtSessionAuth)
}
jwtCookieSessionAuth := &JWTCookieSessionAuthenticator{}
if err := jwtCookieSessionAuth.Init(); err != nil {
log.Info("jwtCookieSessionAuth init failed: No JWT cookie login support!")
} else {
auth.authenticators = append(auth.authenticators, jwtCookieSessionAuth)
}
} else {
log.Info("Missing JWT configuration: No JWT token support!")
}
auth.LocalAuth = &LocalAuthenticator{} auth.LocalAuth = &LocalAuthenticator{}
if err := auth.LocalAuth.Init(auth, nil); err != nil { if err := auth.LocalAuth.Init(); err != nil {
log.Error("Error while initializing authentication -> localAuth init failed") log.Error("Error while initializing authentication -> localAuth init failed")
return nil, err return nil, err
} }
auth.authenticators = append(auth.authenticators, auth.LocalAuth) 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{}
if err := auth.LdapAuth.Init(auth, config); err != nil {
log.Error("Error while initializing authentication -> ldapAuth init failed")
return nil, err
}
auth.authenticators = append(auth.authenticators, auth.LdapAuth)
}
return auth, nil 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( func (auth *Authentication) Login(
onsuccess http.Handler, onsuccess http.Handler,
onfailure func(rw http.ResponseWriter, r *http.Request, loginErr error)) http.Handler { onfailure func(rw http.ResponseWriter, r *http.Request, loginErr error)) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
err := errors.New("no authenticator applied")
username := r.FormValue("username") username := r.FormValue("username")
user := (*User)(nil) var dbUser *schema.User
if username != "" { if username != "" {
user, _ = auth.GetUser(username) var err error
dbUser, err = repository.GetUserRepository().GetUser(username)
if err != nil && err != sql.ErrNoRows {
log.Errorf("Error while loading user '%v'", username)
}
} }
for _, authenticator := range auth.authenticators { for _, authenticator := range auth.authenticators {
if !authenticator.CanLogin(user, rw, r) { var ok bool
var user *schema.User
if user, ok = authenticator.CanLogin(dbUser, username, rw, r); !ok {
continue continue
} else {
log.Debugf("Can login with user %v", user)
} }
user, err = authenticator.Login(user, rw, r) user, err := authenticator.Login(user, rw, r)
if err != nil { if err != nil {
log.Warnf("user login failed: %s", err.Error()) log.Warnf("user login failed: %s", err.Error())
onfailure(rw, r, err) onfailure(rw, r, err)
@ -355,49 +199,50 @@ func (auth *Authentication) Login(
} }
log.Infof("login successfull: user: %#v (roles: %v, projects: %v)", user.Username, user.Roles, user.Projects) log.Infof("login successfull: user: %#v (roles: %v, projects: %v)", user.Username, user.Roles, user.Projects)
ctx := context.WithValue(r.Context(), ContextUserKey, user) ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
onsuccess.ServeHTTP(rw, r.WithContext(ctx)) onsuccess.ServeHTTP(rw, r.WithContext(ctx))
return return
} }
log.Debugf("login failed: no authenticator applied") log.Debugf("login failed: no authenticator applied")
onfailure(rw, r, err) onfailure(rw, r, errors.New("no authenticator applied"))
}) })
} }
// 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( func (auth *Authentication) Auth(
onsuccess http.Handler, onsuccess http.Handler,
onfailure func(rw http.ResponseWriter, r *http.Request, authErr error)) http.Handler { onfailure func(rw http.ResponseWriter, r *http.Request, authErr error)) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { 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 { if err != nil {
log.Infof("authentication failed: %s", err.Error()) log.Infof("authentication failed: %s", err.Error())
http.Error(rw, err.Error(), http.StatusUnauthorized) http.Error(rw, err.Error(), http.StatusUnauthorized)
return return
} }
if user == nil { }
continue
}
ctx := context.WithValue(r.Context(), ContextUserKey, user) if user != nil {
ctx := context.WithValue(r.Context(), repository.ContextUserKey, user)
onsuccess.ServeHTTP(rw, r.WithContext(ctx)) onsuccess.ServeHTTP(rw, r.WithContext(ctx))
return return
} }
log.Debugf("authentication failed: %s", "no authenticator applied") log.Debug("authentication failed")
// http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) onfailure(rw, r, errors.New("unauthorized (please login first)"))
onfailure(rw, r, errors.New("unauthorized (login first or use a token)"))
}) })
} }
// Clears the session cookie
func (auth *Authentication) Logout(onsuccess http.Handler) http.Handler { func (auth *Authentication) Logout(onsuccess http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
session, err := auth.sessionStore.Get(r, "session") session, err := auth.sessionStore.Get(r, "session")
if err != nil { if err != nil {

View File

@ -6,39 +6,26 @@ package auth
import ( import (
"crypto/ed25519" "crypto/ed25519"
"database/sql"
"encoding/base64" "encoding/base64"
"errors" "errors"
"fmt"
"net/http" "net/http"
"os" "os"
"strings" "strings"
"time" "time"
"github.com/ClusterCockpit/cc-backend/internal/config"
"github.com/ClusterCockpit/cc-backend/internal/repository"
"github.com/ClusterCockpit/cc-backend/pkg/log" "github.com/ClusterCockpit/cc-backend/pkg/log"
"github.com/ClusterCockpit/cc-backend/pkg/schema" "github.com/ClusterCockpit/cc-backend/pkg/schema"
"github.com/golang-jwt/jwt/v4" "github.com/golang-jwt/jwt/v4"
) )
type JWTAuthenticator struct { type JWTAuthenticator struct {
auth *Authentication publicKey ed25519.PublicKey
privateKey ed25519.PrivateKey
publicKey ed25519.PublicKey
privateKey ed25519.PrivateKey
publicKeyCrossLogin ed25519.PublicKey // For accepting externally generated JWTs
loginTokenKey []byte // HS256 key
config *schema.JWTAuthConfig
} }
var _ Authenticator = (*JWTAuthenticator)(nil) func (ja *JWTAuthenticator) Init() error {
func (ja *JWTAuthenticator) Init(auth *Authentication, conf interface{}) error {
ja.auth = auth
ja.config = conf.(*schema.JWTAuthConfig)
pubKey, privKey := os.Getenv("JWT_PUBLIC_KEY"), os.Getenv("JWT_PRIVATE_KEY") pubKey, privKey := os.Getenv("JWT_PUBLIC_KEY"), os.Getenv("JWT_PRIVATE_KEY")
if pubKey == "" || privKey == "" { if pubKey == "" || privKey == "" {
log.Warn("environment variables 'JWT_PUBLIC_KEY' or 'JWT_PRIVATE_KEY' not set (token based authentication will not work)") log.Warn("environment variables 'JWT_PUBLIC_KEY' or 'JWT_PRIVATE_KEY' not set (token based authentication will not work)")
@ -57,130 +44,12 @@ func (ja *JWTAuthenticator) Init(auth *Authentication, conf interface{}) error {
ja.privateKey = ed25519.PrivateKey(bytes) 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 return nil
} }
func (ja *JWTAuthenticator) CanLogin( func (ja *JWTAuthenticator) AuthViaJWT(
user *User,
rw http.ResponseWriter, rw http.ResponseWriter,
r *http.Request) bool { r *http.Request) (*schema.User, error) {
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(
rw http.ResponseWriter,
r *http.Request) (*User, error) {
rawtoken := r.Header.Get("X-Auth-Token") rawtoken := r.Header.Get("X-Auth-Token")
if rawtoken == "" { if rawtoken == "" {
@ -188,59 +57,22 @@ func (ja *JWTAuthenticator) Auth(
rawtoken = strings.TrimPrefix(rawtoken, "Bearer ") rawtoken = strings.TrimPrefix(rawtoken, "Bearer ")
} }
// If no auth header was found, check for a certain cookie containing a JWT // there is no token
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 == "" { 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) { token, err := jwt.Parse(rawtoken, func(t *jwt.Token) (interface{}, error) {
if t.Method != jwt.SigningMethodEdDSA { if t.Method != jwt.SigningMethodEdDSA {
return nil, errors.New("only Ed25519/EdDSA supported") 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 return ja.publicKey, nil
}) })
if err != nil { if err != nil {
log.Warn("Error while parsing token") log.Warn("Error while parsing JWT token")
return nil, err return nil, err
} }
// Check token validity
if err := token.Claims.Valid(); err != nil { if err := token.Claims.Valid(); err != nil {
log.Warn("jwt token claims are not valid") log.Warn("jwt token claims are not valid")
return nil, err return nil, err
@ -253,15 +85,15 @@ func (ja *JWTAuthenticator) Auth(
var roles []string var roles []string
// Validate user + roles from JWT against database? // Validate user + roles from JWT against database?
if ja.config != nil && ja.config.ForceJWTValidationViaDatabase { if config.Keys.JwtConfig.ValidateUser {
user, err := ja.auth.GetUser(sub) ur := repository.GetUserRepository()
user, err := ur.GetUser(sub)
// Deny any logins for unknown usernames // Deny any logins for unknown usernames
if err != nil { if err != nil {
log.Warn("Could not find user from JWT in internal database.") log.Warn("Could not find user from JWT in internal database.")
return nil, errors.New("unknown user") return nil, errors.New("unknown user")
} }
// Take user roles from database instead of trusting the JWT // Take user roles from database instead of trusting the JWT
roles = user.Roles roles = user.Roles
} else { } else {
@ -275,47 +107,16 @@ func (ja *JWTAuthenticator) Auth(
} }
} }
if cookieFound { return &schema.User{
// 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, Username: sub,
Roles: roles, Roles: roles,
AuthSource: AuthViaToken, AuthType: schema.AuthToken,
AuthSource: -1,
}, nil }, nil
} }
// Generate a new JWT that can be used for authentication // Generate a new JWT that can be used for authentication
func (ja *JWTAuthenticator) ProvideJWT(user *User) (string, error) { func (ja *JWTAuthenticator) ProvideJWT(user *schema.User) (string, error) {
if ja.privateKey == nil { if ja.privateKey == nil {
return "", errors.New("environment variable 'JWT_PRIVATE_KEY' not set") return "", errors.New("environment variable 'JWT_PRIVATE_KEY' not set")
@ -327,8 +128,12 @@ func (ja *JWTAuthenticator) ProvideJWT(user *User) (string, error) {
"roles": user.Roles, "roles": user.Roles,
"iat": now.Unix(), "iat": now.Unix(),
} }
if ja.config != nil && ja.config.MaxAge != 0 { if config.Keys.JwtConfig.MaxAge != "" {
claims["exp"] = now.Add(time.Duration(ja.config.MaxAge)).Unix() d, err := time.ParseDuration(config.Keys.JwtConfig.MaxAge)
if err != nil {
return "", errors.New("cannot parse max-age config key")
}
claims["exp"] = now.Add(d).Unix()
} }
return jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims).SignedString(ja.privateKey) return jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims).SignedString(ja.privateKey)

View File

@ -0,0 +1,219 @@
// 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"
"fmt"
"net/http"
"os"
"github.com/ClusterCockpit/cc-backend/internal/config"
"github.com/ClusterCockpit/cc-backend/internal/repository"
"github.com/ClusterCockpit/cc-backend/pkg/log"
"github.com/ClusterCockpit/cc-backend/pkg/schema"
"github.com/golang-jwt/jwt/v4"
)
type JWTCookieSessionAuthenticator struct {
publicKey ed25519.PublicKey
privateKey ed25519.PrivateKey
publicKeyCrossLogin ed25519.PublicKey // For accepting externally generated JWTs
}
var _ Authenticator = (*JWTCookieSessionAuthenticator)(nil)
func (ja *JWTCookieSessionAuthenticator) Init() error {
pubKey, privKey := os.Getenv("JWT_PUBLIC_KEY"), os.Getenv("JWT_PRIVATE_KEY")
if pubKey == "" || privKey == "" {
log.Warn("environment variables 'JWT_PUBLIC_KEY' or 'JWT_PRIVATE_KEY' not set (token based authentication will not work)")
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)")
}
jc := config.Keys.JwtConfig
// Warn if other necessary settings are not configured
if jc != nil {
if jc.CookieName == "" {
log.Info("cookieName for JWTs not configured (cross login via JWT cookie will fail)")
return errors.New("cookieName for JWTs not configured (cross login via JWT cookie will fail)")
}
if !jc.ValidateUser {
log.Info("forceJWTValidationViaDatabase not set to true: CC will accept users and roles defined in JWTs regardless of its own database!")
}
if jc.TrustedIssuer == "" {
log.Info("trustedExternalIssuer for JWTs not configured (cross login via JWT cookie will fail)")
return errors.New("trustedExternalIssuer for JWTs not configured (cross login via JWT cookie will fail)")
}
} else {
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)")
}
log.Info("JWT Cookie Session authenticator successfully registered")
return nil
}
func (ja *JWTCookieSessionAuthenticator) CanLogin(
user *schema.User,
username string,
rw http.ResponseWriter,
r *http.Request) (*schema.User, bool) {
jc := config.Keys.JwtConfig
cookieName := ""
if jc.CookieName != "" {
cookieName = jc.CookieName
}
// Try to read the JWT cookie
if cookieName != "" {
jwtCookie, err := r.Cookie(cookieName)
if err == nil && jwtCookie.Value != "" {
return user, true
}
}
return nil, false
}
func (ja *JWTCookieSessionAuthenticator) Login(
user *schema.User,
rw http.ResponseWriter,
r *http.Request) (*schema.User, error) {
jc := config.Keys.JwtConfig
jwtCookie, err := r.Cookie(jc.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 == jc.TrustedIssuer {
// 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("JWT cookie session: 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 wrap, ok := claims["name"].(map[string]interface{}); ok {
if vals, ok := wrap["values"].([]interface{}); ok {
if len(vals) != 0 {
name = fmt.Sprintf("%v", vals[0])
for i := 1; i < len(vals); i++ {
name += fmt.Sprintf(" %v", vals[i])
}
}
}
}
var roles []string
if jc.ValidateUser {
// Deny any logins for unknown usernames
if user == nil {
log.Warn("Could not find user from JWT in internal database.")
return nil, errors.New("unknown user")
}
// Take user roles from database instead of trusting the JWT
roles = user.Roles
} else {
// Extract roles from JWT (if present)
if rawroles, ok := claims["roles"].([]interface{}); ok {
for _, rr := range rawroles {
if r, ok := rr.(string); ok {
roles = append(roles, r)
}
}
}
}
// (Ask browser to) Delete JWT cookie
deletedCookie := &http.Cookie{
Name: jc.CookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
}
http.SetCookie(rw, deletedCookie)
if user == nil {
projects := make([]string, 0)
user = &schema.User{
Username: sub,
Name: name,
Roles: roles,
Projects: projects,
AuthType: schema.AuthSession,
AuthSource: schema.AuthViaToken,
}
if jc.SyncUserOnLogin {
if err := repository.GetUserRepository().AddUser(user); err != nil {
log.Errorf("Error while adding user '%s' to DB", user.Username)
}
}
}
return user, nil
}

150
internal/auth/jwtSession.go Normal file
View File

@ -0,0 +1,150 @@
// 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"
"errors"
"fmt"
"net/http"
"os"
"strings"
"github.com/ClusterCockpit/cc-backend/internal/config"
"github.com/ClusterCockpit/cc-backend/internal/repository"
"github.com/ClusterCockpit/cc-backend/pkg/log"
"github.com/ClusterCockpit/cc-backend/pkg/schema"
"github.com/golang-jwt/jwt/v4"
)
type JWTSessionAuthenticator struct {
loginTokenKey []byte // HS256 key
}
var _ Authenticator = (*JWTSessionAuthenticator)(nil)
func (ja *JWTSessionAuthenticator) Init() error {
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
}
log.Info("JWT Session authenticator successfully registered")
return nil
}
func (ja *JWTSessionAuthenticator) CanLogin(
user *schema.User,
username string,
rw http.ResponseWriter,
r *http.Request) (*schema.User, bool) {
return user, r.Header.Get("Authorization") != "" ||
r.URL.Query().Get("login-token") != ""
}
func (ja *JWTSessionAuthenticator) Login(
user *schema.User,
rw http.ResponseWriter,
r *http.Request) (*schema.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("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 wrap, ok := claims["name"].(map[string]interface{}); ok {
if vals, ok := wrap["values"].([]interface{}); ok {
if len(vals) != 0 {
name = fmt.Sprintf("%v", vals[0])
for i := 1; i < len(vals); i++ {
name += fmt.Sprintf(" %v", vals[i])
}
}
}
}
var roles []string
if config.Keys.JwtConfig.ValidateUser {
// Deny any logins for unknown usernames
if user == nil {
log.Warn("Could not find user from JWT in internal database.")
return nil, errors.New("unknown user")
}
// Take user roles from database instead of trusting the JWT
roles = user.Roles
} else {
// Extract roles from JWT (if present)
if rawroles, ok := claims["roles"].([]interface{}); ok {
for _, rr := range rawroles {
if r, ok := rr.(string); ok {
if schema.IsValidRole(r) {
roles = append(roles, r)
}
}
}
}
}
projects := make([]string, 0)
// Java/Grails Issued Token
// if rawprojs, ok := claims["projects"].([]interface{}); ok {
// for _, pp := range rawprojs {
// if p, ok := pp.(string); ok {
// projects = append(projects, p)
// }
// }
// } else if rawprojs, ok := claims["projects"]; ok {
// for _, p := range rawprojs.([]string) {
// projects = append(projects, p)
// }
// }
if user == nil {
user = &schema.User{
Username: sub,
Name: name,
Roles: roles,
Projects: projects,
AuthType: schema.AuthSession,
AuthSource: schema.AuthViaToken,
}
if config.Keys.JwtConfig.SyncUserOnLogin {
if err := repository.GetUserRepository().AddUser(user); err != nil {
log.Errorf("Error while adding user '%s' to DB", user.Username)
}
}
}
return user, nil
}

View File

@ -1,4 +1,4 @@
// Copyright (C) 2022 NHR@FAU, University Erlangen-Nuremberg. // Copyright (C) 2023 NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. // All rights reserved.
// Use of this source code is governed by a MIT-style // Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file. // license that can be found in the LICENSE file.
@ -12,35 +12,30 @@ import (
"strings" "strings"
"time" "time"
"github.com/ClusterCockpit/cc-backend/internal/config"
"github.com/ClusterCockpit/cc-backend/internal/repository"
"github.com/ClusterCockpit/cc-backend/pkg/log" "github.com/ClusterCockpit/cc-backend/pkg/log"
"github.com/ClusterCockpit/cc-backend/pkg/schema" "github.com/ClusterCockpit/cc-backend/pkg/schema"
"github.com/go-ldap/ldap/v3" "github.com/go-ldap/ldap/v3"
) )
type LdapAuthenticator struct { type LdapAuthenticator struct {
auth *Authentication
config *schema.LdapConfig
syncPassword string syncPassword string
} }
var _ Authenticator = (*LdapAuthenticator)(nil) var _ Authenticator = (*LdapAuthenticator)(nil)
func (la *LdapAuthenticator) Init( func (la *LdapAuthenticator) Init() error {
auth *Authentication,
conf interface{}) error {
la.auth = auth
la.config = conf.(*schema.LdapConfig)
la.syncPassword = os.Getenv("LDAP_ADMIN_PASSWORD") la.syncPassword = os.Getenv("LDAP_ADMIN_PASSWORD")
if la.syncPassword == "" { if la.syncPassword == "" {
log.Warn("environment variable 'LDAP_ADMIN_PASSWORD' not set (ldap sync will not work)") log.Warn("environment variable 'LDAP_ADMIN_PASSWORD' not set (ldap sync will not work)")
} }
if la.config != nil && la.config.SyncInterval != "" { if config.Keys.LdapConfig.SyncInterval != "" {
interval, err := time.ParseDuration(la.config.SyncInterval) interval, err := time.ParseDuration(config.Keys.LdapConfig.SyncInterval)
if err != nil { if err != nil {
log.Warnf("Could not parse duration for sync interval: %v", la.config.SyncInterval) log.Warnf("Could not parse duration for sync interval: %v",
config.Keys.LdapConfig.SyncInterval)
return err return err
} }
@ -59,23 +54,82 @@ func (la *LdapAuthenticator) Init(
log.Print("sync done") log.Print("sync done")
} }
}() }()
} else {
log.Info("LDAP configuration key sync_interval invalid")
} }
return nil return nil
} }
func (la *LdapAuthenticator) CanLogin( func (la *LdapAuthenticator) CanLogin(
user *User, user *schema.User,
username string,
rw http.ResponseWriter, rw http.ResponseWriter,
r *http.Request) bool { r *http.Request) (*schema.User, bool) {
return user != nil && user.AuthSource == AuthViaLDAP lc := config.Keys.LdapConfig
if user != nil {
if user.AuthSource == schema.AuthViaLDAP {
return user, true
}
} else {
if lc.SyncUserOnLogin {
l, err := la.getLdapConnection(true)
if err != nil {
log.Error("LDAP connection error")
}
defer l.Close()
// Search for the given username
searchRequest := ldap.NewSearchRequest(
lc.UserBase,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
fmt.Sprintf("(&%s(uid=%s))", lc.UserFilter, username),
[]string{"dn", "uid", "gecos"}, nil)
sr, err := l.Search(searchRequest)
if err != nil {
log.Warn(err)
return nil, false
}
if len(sr.Entries) != 1 {
log.Warn("LDAP: User does not exist or too many entries returned")
return nil, false
}
entry := sr.Entries[0]
name := entry.GetAttributeValue("gecos")
var roles []string
roles = append(roles, schema.GetRoleString(schema.RoleUser))
projects := make([]string, 0)
user = &schema.User{
Username: username,
Name: name,
Roles: roles,
Projects: projects,
AuthType: schema.AuthSession,
AuthSource: schema.AuthViaLDAP,
}
if err := repository.GetUserRepository().AddUser(user); err != nil {
log.Errorf("User '%s' LDAP: Insert into DB failed", username)
return nil, false
}
return user, true
}
}
return nil, false
} }
func (la *LdapAuthenticator) Login( func (la *LdapAuthenticator) Login(
user *User, user *schema.User,
rw http.ResponseWriter, rw http.ResponseWriter,
r *http.Request) (*User, error) { r *http.Request) (*schema.User, error) {
l, err := la.getLdapConnection(false) l, err := la.getLdapConnection(false)
if err != nil { if err != nil {
@ -84,42 +138,30 @@ func (la *LdapAuthenticator) Login(
} }
defer l.Close() defer l.Close()
userDn := strings.Replace(la.config.UserBind, "{username}", user.Username, -1) userDn := strings.Replace(config.Keys.LdapConfig.UserBind, "{username}", user.Username, -1)
if err := l.Bind(userDn, r.FormValue("password")); err != nil { if err := l.Bind(userDn, r.FormValue("password")); err != nil {
log.Errorf("AUTH/LOCAL > Authentication for user %s failed: %v", user.Username, err) log.Errorf("AUTH/LDAP > Authentication for user %s failed: %v",
return nil, fmt.Errorf("AUTH/LDAP > Authentication failed") user.Username, err)
return nil, fmt.Errorf("Authentication failed")
} }
return user, nil 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 { func (la *LdapAuthenticator) Sync() error {
const IN_DB int = 1 const IN_DB int = 1
const IN_LDAP int = 2 const IN_LDAP int = 2
const IN_BOTH int = 3 const IN_BOTH int = 3
ur := repository.GetUserRepository()
lc := config.Keys.LdapConfig
users := map[string]int{} users := map[string]int{}
rows, err := la.auth.db.Query(`SELECT username FROM user WHERE user.ldap = 1`) usernames, err := ur.GetLdapUsernames()
if err != nil { if err != nil {
log.Warn("Error while querying LDAP users")
return err return err
} }
for rows.Next() { for _, username := range usernames {
var username string
if err := rows.Scan(&username); err != nil {
log.Warnf("Error while scanning for user '%s'", username)
return err
}
users[username] = IN_DB users[username] = IN_DB
} }
@ -131,8 +173,10 @@ func (la *LdapAuthenticator) Sync() error {
defer l.Close() defer l.Close()
ldapResults, err := l.Search(ldap.NewSearchRequest( ldapResults, err := l.Search(ldap.NewSearchRequest(
la.config.UserBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, lc.UserBase,
la.config.UserFilter, []string{"dn", "uid", "gecos"}, nil)) ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
lc.UserFilter,
[]string{"dn", "uid", "gecos"}, nil))
if err != nil { if err != nil {
log.Warn("LDAP search error") log.Warn("LDAP search error")
return err return err
@ -155,18 +199,27 @@ func (la *LdapAuthenticator) Sync() error {
} }
for username, where := range users { for username, where := range users {
if where == IN_DB && la.config.SyncDelOldUsers { if where == IN_DB && lc.SyncDelOldUsers {
ur.DelUser(username)
log.Debugf("sync: remove %v (does not show up in LDAP anymore)", username) log.Debugf("sync: remove %v (does not show up in LDAP anymore)", username)
if _, err := la.auth.db.Exec(`DELETE FROM user WHERE user.username = ?`, username); err != nil {
log.Errorf("User '%s' not in LDAP anymore: Delete from DB failed", username)
return err
}
} else if where == IN_LDAP { } else if where == IN_LDAP {
name := newnames[username] name := newnames[username]
var roles []string
roles = append(roles, schema.GetRoleString(schema.RoleUser))
projects := make([]string, 0)
user := &schema.User{
Username: username,
Name: name,
Roles: roles,
Projects: projects,
AuthSource: schema.AuthViaLDAP,
}
log.Debugf("sync: add %v (name: %v, roles: [user], ldap: true)", username, name) log.Debugf("sync: add %v (name: %v, roles: [user], ldap: true)", username, name)
if _, err := la.auth.db.Exec(`INSERT INTO user (username, ldap, name, roles) VALUES (?, ?, ?, ?)`, if err := ur.AddUser(user); err != nil {
username, 1, name, "[\""+GetRoleString(RoleUser)+"\"]"); err != nil { log.Errorf("User '%s' LDAP: Insert into DB failed", username)
log.Errorf("User '%s' new in LDAP: Insert into DB failed", username)
return err return err
} }
} }
@ -179,14 +232,15 @@ func (la *LdapAuthenticator) Sync() error {
// that so that connections can be reused/cached. // that so that connections can be reused/cached.
func (la *LdapAuthenticator) getLdapConnection(admin bool) (*ldap.Conn, error) { func (la *LdapAuthenticator) getLdapConnection(admin bool) (*ldap.Conn, error) {
conn, err := ldap.DialURL(la.config.Url) lc := config.Keys.LdapConfig
conn, err := ldap.DialURL(lc.Url)
if err != nil { if err != nil {
log.Warn("LDAP URL dial failed") log.Warn("LDAP URL dial failed")
return nil, err return nil, err
} }
if admin { if admin {
if err := conn.Bind(la.config.SearchDN, la.syncPassword); err != nil { if err := conn.Bind(lc.SearchDN, la.syncPassword); err != nil {
conn.Close() conn.Close()
log.Warn("LDAP connection bind failed") log.Warn("LDAP connection bind failed")
return nil, err return nil, err

View File

@ -9,6 +9,7 @@ import (
"net/http" "net/http"
"github.com/ClusterCockpit/cc-backend/pkg/log" "github.com/ClusterCockpit/cc-backend/pkg/log"
"github.com/ClusterCockpit/cc-backend/pkg/schema"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@ -18,38 +19,29 @@ type LocalAuthenticator struct {
var _ Authenticator = (*LocalAuthenticator)(nil) var _ Authenticator = (*LocalAuthenticator)(nil)
func (la *LocalAuthenticator) Init( func (la *LocalAuthenticator) Init() error {
auth *Authentication,
_ interface{}) error {
la.auth = auth
return nil return nil
} }
func (la *LocalAuthenticator) CanLogin( func (la *LocalAuthenticator) CanLogin(
user *User, user *schema.User,
username string,
rw http.ResponseWriter, rw http.ResponseWriter,
r *http.Request) bool { r *http.Request) (*schema.User, bool) {
return user != nil && user.AuthSource == AuthViaLocalPassword return user, user != nil && user.AuthSource == schema.AuthViaLocalPassword
} }
func (la *LocalAuthenticator) Login( func (la *LocalAuthenticator) Login(
user *User, user *schema.User,
rw http.ResponseWriter, rw http.ResponseWriter,
r *http.Request) (*User, error) { r *http.Request) (*schema.User, error) {
if e := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(r.FormValue("password"))); e != nil { if e := bcrypt.CompareHashAndPassword([]byte(user.Password),
[]byte(r.FormValue("password"))); e != nil {
log.Errorf("AUTH/LOCAL > Authentication for user %s failed!", user.Username) log.Errorf("AUTH/LOCAL > Authentication for user %s failed!", user.Username)
return nil, fmt.Errorf("AUTH/LOCAL > Authentication failed") return nil, fmt.Errorf("Authentication failed")
} }
return user, nil return user, nil
} }
func (la *LocalAuthenticator) Auth(
rw http.ResponseWriter,
r *http.Request) (*User, error) {
return la.auth.AuthViaSession(rw, r)
}

View File

@ -1,289 +0,0 @@
// Copyright (C) 2022 NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package auth
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/ClusterCockpit/cc-backend/internal/graph/model"
"github.com/ClusterCockpit/cc-backend/pkg/log"
sq "github.com/Masterminds/squirrel"
"github.com/jmoiron/sqlx"
"golang.org/x/crypto/bcrypt"
)
func (auth *Authentication) GetUser(username string) (*User, error) {
user := &User{Username: username}
var hashedPassword, name, rawRoles, email, rawProjects sql.NullString
if err := sq.Select("password", "ldap", "name", "roles", "email", "projects").From("user").
Where("user.username = ?", username).RunWith(auth.db).
QueryRow().Scan(&hashedPassword, &user.AuthSource, &name, &rawRoles, &email, &rawProjects); err != nil {
log.Warnf("Error while querying user '%v' from database", username)
return nil, err
}
user.Password = hashedPassword.String
user.Name = name.String
user.Email = email.String
if rawRoles.Valid {
if err := json.Unmarshal([]byte(rawRoles.String), &user.Roles); err != nil {
log.Warn("Error while unmarshaling raw roles from DB")
return nil, err
}
}
if rawProjects.Valid {
if err := json.Unmarshal([]byte(rawProjects.String), &user.Projects); err != nil {
return nil, err
}
}
return user, nil
}
func (auth *Authentication) AddUser(user *User) error {
rolesJson, _ := json.Marshal(user.Roles)
projectsJson, _ := json.Marshal(user.Projects)
cols := []string{"username", "roles", "projects"}
vals := []interface{}{user.Username, string(rolesJson), string(projectsJson)}
if user.Name != "" {
cols = append(cols, "name")
vals = append(vals, user.Name)
}
if user.Email != "" {
cols = append(cols, "email")
vals = append(vals, user.Email)
}
if user.Password != "" {
password, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
if err != nil {
log.Error("Error while encrypting new user password")
return err
}
cols = append(cols, "password")
vals = append(vals, string(password))
}
if _, err := sq.Insert("user").Columns(cols...).Values(vals...).RunWith(auth.db).Exec(); err != nil {
log.Errorf("Error while inserting new user '%v' into DB", user.Username)
return err
}
log.Infof("new user %#v created (roles: %s, auth-source: %d, projects: %s)", user.Username, rolesJson, user.AuthSource, projectsJson)
return nil
}
func (auth *Authentication) DelUser(username string) error {
_, err := auth.db.Exec(`DELETE FROM user WHERE user.username = ?`, username)
log.Errorf("Error while deleting user '%s' from DB", username)
return err
}
func (auth *Authentication) ListUsers(specialsOnly bool) ([]*User, error) {
q := sq.Select("username", "name", "email", "roles", "projects").From("user")
if specialsOnly {
q = q.Where("(roles != '[\"user\"]' AND roles != '[]')")
}
rows, err := q.RunWith(auth.db).Query()
if err != nil {
log.Warn("Error while querying user list")
return nil, err
}
users := make([]*User, 0)
defer rows.Close()
for rows.Next() {
rawroles := ""
rawprojects := ""
user := &User{}
var name, email sql.NullString
if err := rows.Scan(&user.Username, &name, &email, &rawroles, &rawprojects); err != nil {
log.Warn("Error while scanning user list")
return nil, err
}
if err := json.Unmarshal([]byte(rawroles), &user.Roles); err != nil {
log.Warn("Error while unmarshaling raw role list")
return nil, err
}
if err := json.Unmarshal([]byte(rawprojects), &user.Projects); err != nil {
return nil, err
}
user.Name = name.String
user.Email = email.String
users = append(users, user)
}
return users, nil
}
func (auth *Authentication) AddRole(
ctx context.Context,
username string,
queryrole string) error {
newRole := strings.ToLower(queryrole)
user, err := auth.GetUser(username)
if err != nil {
log.Warnf("Could not load user '%s'", username)
return err
}
exists, valid := user.HasValidRole(newRole)
if !valid {
return fmt.Errorf("Supplied role is no valid option : %v", newRole)
}
if exists {
return fmt.Errorf("User %v already has role %v", username, newRole)
}
roles, _ := json.Marshal(append(user.Roles, newRole))
if _, err := sq.Update("user").Set("roles", roles).Where("user.username = ?", username).RunWith(auth.db).Exec(); err != nil {
log.Errorf("Error while adding new role for user '%s'", user.Username)
return err
}
return nil
}
func (auth *Authentication) RemoveRole(ctx context.Context, username string, queryrole string) error {
oldRole := strings.ToLower(queryrole)
user, err := auth.GetUser(username)
if err != nil {
log.Warnf("Could not load user '%s'", username)
return err
}
exists, valid := user.HasValidRole(oldRole)
if !valid {
return fmt.Errorf("Supplied role is no valid option : %v", oldRole)
}
if !exists {
return fmt.Errorf("Role already deleted for user '%v': %v", username, oldRole)
}
if oldRole == GetRoleString(RoleManager) && len(user.Projects) != 0 {
return fmt.Errorf("Cannot remove role 'manager' while user %s still has assigned project(s) : %v", username, user.Projects)
}
var newroles []string
for _, r := range user.Roles {
if r != oldRole {
newroles = append(newroles, r) // Append all roles not matching requested to be deleted role
}
}
var mroles, _ = json.Marshal(newroles)
if _, err := sq.Update("user").Set("roles", mroles).Where("user.username = ?", username).RunWith(auth.db).Exec(); err != nil {
log.Errorf("Error while removing role for user '%s'", user.Username)
return err
}
return nil
}
func (auth *Authentication) AddProject(
ctx context.Context,
username string,
project string) error {
user, err := auth.GetUser(username)
if err != nil {
return err
}
if !user.HasRole(RoleManager) {
return fmt.Errorf("user '%s' is not a manager!", username)
}
if user.HasProject(project) {
return fmt.Errorf("user '%s' already manages project '%s'", username, project)
}
projects, _ := json.Marshal(append(user.Projects, project))
if _, err := sq.Update("user").Set("projects", projects).Where("user.username = ?", username).RunWith(auth.db).Exec(); err != nil {
return err
}
return nil
}
func (auth *Authentication) RemoveProject(ctx context.Context, username string, project string) error {
user, err := auth.GetUser(username)
if err != nil {
return err
}
if !user.HasRole(RoleManager) {
return fmt.Errorf("user '%#v' is not a manager!", username)
}
if !user.HasProject(project) {
return fmt.Errorf("user '%#v': Cannot remove project '%#v' - Does not match!", username, project)
}
var exists bool
var newprojects []string
for _, p := range user.Projects {
if p != project {
newprojects = append(newprojects, p) // Append all projects not matching requested to be deleted project
} else {
exists = true
}
}
if exists == true {
var result interface{}
if len(newprojects) == 0 {
result = "[]"
} else {
result, _ = json.Marshal(newprojects)
}
if _, err := sq.Update("user").Set("projects", result).Where("user.username = ?", username).RunWith(auth.db).Exec(); err != nil {
return err
}
return nil
} else {
return fmt.Errorf("user %s already does not manage project %s", username, project)
}
}
func FetchUser(ctx context.Context, db *sqlx.DB, username string) (*model.User, error) {
me := GetUser(ctx)
if me != nil && me.Username != username && me.HasNotRoles([]Role{RoleAdmin, RoleSupport, RoleManager}) {
return nil, errors.New("forbidden")
}
user := &model.User{Username: username}
var name, email sql.NullString
if err := sq.Select("name", "email").From("user").Where("user.username = ?", username).
RunWith(db).QueryRow().Scan(&name, &email); err != nil {
if err == sql.ErrNoRows {
/* This warning will be logged *often* for non-local users, i.e. users mentioned only in job-table or archive, */
/* since FetchUser will be called to retrieve full name and mail for every job in query/list */
// log.Warnf("User '%s' Not found in DB", username)
return nil, nil
}
log.Warnf("Error while fetching user '%s'", username)
return nil, err
}
user.Name = name.String
user.Email = email.String
return user, nil
}

View File

@ -22,7 +22,6 @@ var Keys schema.ProgramConfig = schema.ProgramConfig{
Archive: json.RawMessage(`{\"kind\":\"file\",\"path\":\"./var/job-archive\"}`), Archive: json.RawMessage(`{\"kind\":\"file\",\"path\":\"./var/job-archive\"}`),
DisableArchive: false, DisableArchive: false,
Validate: false, Validate: false,
LdapConfig: nil,
SessionMaxAge: "168h", SessionMaxAge: "168h",
StopJobsExceedingWalltime: 0, StopJobsExceedingWalltime: 0,
ShortRunningJobsDuration: 5 * 60, ShortRunningJobsDuration: 5 * 60,

View File

@ -11,7 +11,6 @@ import (
"strconv" "strconv"
"time" "time"
"github.com/ClusterCockpit/cc-backend/internal/auth"
"github.com/ClusterCockpit/cc-backend/internal/graph/generated" "github.com/ClusterCockpit/cc-backend/internal/graph/generated"
"github.com/ClusterCockpit/cc-backend/internal/graph/model" "github.com/ClusterCockpit/cc-backend/internal/graph/model"
"github.com/ClusterCockpit/cc-backend/internal/metricdata" "github.com/ClusterCockpit/cc-backend/internal/metricdata"
@ -51,7 +50,7 @@ func (r *jobResolver) MetaData(ctx context.Context, obj *schema.Job) (interface{
// UserData is the resolver for the userData field. // UserData is the resolver for the userData field.
func (r *jobResolver) UserData(ctx context.Context, obj *schema.Job) (*model.User, error) { func (r *jobResolver) UserData(ctx context.Context, obj *schema.Job) (*model.User, error) {
return auth.FetchUser(ctx, r.DB, obj.User) return repository.GetUserRepository().FetchUserInCtx(ctx, obj.User)
} }
// CreateTag is the resolver for the createTag field. // CreateTag is the resolver for the createTag field.
@ -122,7 +121,7 @@ func (r *mutationResolver) RemoveTagsFromJob(ctx context.Context, job string, ta
// UpdateConfiguration is the resolver for the updateConfiguration field. // UpdateConfiguration is the resolver for the updateConfiguration field.
func (r *mutationResolver) UpdateConfiguration(ctx context.Context, name string, value string) (*string, error) { func (r *mutationResolver) UpdateConfiguration(ctx context.Context, name string, value string) (*string, error) {
if err := repository.GetUserCfgRepo().UpdateConfig(name, value, auth.GetUser(ctx)); err != nil { if err := repository.GetUserCfgRepo().UpdateConfig(name, value, repository.GetUserFromContext(ctx)); err != nil {
log.Warn("Error while updating user config") log.Warn("Error while updating user config")
return nil, err return nil, err
} }
@ -142,7 +141,7 @@ func (r *queryResolver) Tags(ctx context.Context) ([]*schema.Tag, error) {
// User is the resolver for the user field. // User is the resolver for the user field.
func (r *queryResolver) User(ctx context.Context, username string) (*model.User, error) { func (r *queryResolver) User(ctx context.Context, username string) (*model.User, error) {
return auth.FetchUser(ctx, r.DB, username) return repository.GetUserRepository().FetchUserInCtx(ctx, username)
} }
// AllocatedNodes is the resolver for the allocatedNodes field. // AllocatedNodes is the resolver for the allocatedNodes field.
@ -178,7 +177,9 @@ func (r *queryResolver) Job(ctx context.Context, id string) (*schema.Job, error)
return nil, err return nil, err
} }
if user := auth.GetUser(ctx); user != nil && job.User != user.Username && user.HasNotRoles([]auth.Role{auth.RoleAdmin, auth.RoleSupport, auth.RoleManager}) { if user := repository.GetUserFromContext(ctx); user != nil &&
job.User != user.Username &&
user.HasNotRoles([]schema.Role{schema.RoleAdmin, schema.RoleSupport, schema.RoleManager}) {
return nil, errors.New("you are not allowed to see this job") return nil, errors.New("you are not allowed to see this job")
} }
@ -318,8 +319,8 @@ func (r *queryResolver) RooflineHeatmap(ctx context.Context, filter []*model.Job
// NodeMetrics is the resolver for the nodeMetrics field. // NodeMetrics is the resolver for the nodeMetrics field.
func (r *queryResolver) NodeMetrics(ctx context.Context, cluster string, nodes []string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time) ([]*model.NodeMetrics, error) { func (r *queryResolver) NodeMetrics(ctx context.Context, cluster string, nodes []string, scopes []schema.MetricScope, metrics []string, from time.Time, to time.Time) ([]*model.NodeMetrics, error) {
user := auth.GetUser(ctx) user := repository.GetUserFromContext(ctx)
if user != nil && !user.HasRole(auth.RoleAdmin) { if user != nil && !user.HasRole(schema.RoleAdmin) {
return nil, errors.New("you need to be an administrator for this query") return nil, errors.New("you need to be an administrator for this query")
} }

View File

@ -42,6 +42,9 @@ func setup(t *testing.T) *repository.JobRepository {
"kind": "file", "kind": "file",
"path": "./var/job-archive" "path": "./var/job-archive"
}, },
"jwts": {
"max-age": "2m"
},
"clusters": [ "clusters": [
{ {
"name": "testcluster", "name": "testcluster",

View File

@ -14,7 +14,6 @@ import (
"sync" "sync"
"time" "time"
"github.com/ClusterCockpit/cc-backend/internal/auth"
"github.com/ClusterCockpit/cc-backend/internal/graph/model" "github.com/ClusterCockpit/cc-backend/internal/graph/model"
"github.com/ClusterCockpit/cc-backend/internal/metricdata" "github.com/ClusterCockpit/cc-backend/internal/metricdata"
"github.com/ClusterCockpit/cc-backend/pkg/log" "github.com/ClusterCockpit/cc-backend/pkg/log"
@ -615,7 +614,7 @@ func (r *JobRepository) WaitForArchiving() {
r.archivePending.Wait() r.archivePending.Wait()
} }
func (r *JobRepository) FindUserOrProjectOrJobname(user *auth.User, searchterm string) (jobid string, username string, project string, jobname string) { func (r *JobRepository) FindUserOrProjectOrJobname(user *schema.User, searchterm string) (jobid string, username string, project string, jobname string) {
if _, err := strconv.Atoi(searchterm); err == nil { // Return empty on successful conversion: parent method will redirect for integer jobId if _, err := strconv.Atoi(searchterm); err == nil { // Return empty on successful conversion: parent method will redirect for integer jobId
return searchterm, "", "", "" return searchterm, "", "", ""
} else { // Has to have letters and logged-in user for other guesses } else { // Has to have letters and logged-in user for other guesses
@ -644,14 +643,14 @@ func (r *JobRepository) FindUserOrProjectOrJobname(user *auth.User, searchterm s
var ErrNotFound = errors.New("no such jobname, project or user") var ErrNotFound = errors.New("no such jobname, project or user")
var ErrForbidden = errors.New("not authorized") var ErrForbidden = errors.New("not authorized")
func (r *JobRepository) FindColumnValue(user *auth.User, searchterm string, table string, selectColumn string, whereColumn string, isLike bool) (result string, err error) { func (r *JobRepository) FindColumnValue(user *schema.User, searchterm string, table string, selectColumn string, whereColumn string, isLike bool) (result string, err error) {
compareStr := " = ?" compareStr := " = ?"
query := searchterm query := searchterm
if isLike { if isLike {
compareStr = " LIKE ?" compareStr = " LIKE ?"
query = "%" + searchterm + "%" query = "%" + searchterm + "%"
} }
if user.HasAnyRole([]auth.Role{auth.RoleAdmin, auth.RoleSupport, auth.RoleManager}) { if user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport, schema.RoleManager}) {
theQuery := sq.Select(table+"."+selectColumn).Distinct().From(table). theQuery := sq.Select(table+"."+selectColumn).Distinct().From(table).
Where(table+"."+whereColumn+compareStr, query) Where(table+"."+whereColumn+compareStr, query)
@ -676,9 +675,9 @@ func (r *JobRepository) FindColumnValue(user *auth.User, searchterm string, tabl
} }
} }
func (r *JobRepository) FindColumnValues(user *auth.User, query string, table string, selectColumn string, whereColumn string) (results []string, err error) { func (r *JobRepository) FindColumnValues(user *schema.User, query string, table string, selectColumn string, whereColumn string) (results []string, err error) {
emptyResult := make([]string, 0) emptyResult := make([]string, 0)
if user.HasAnyRole([]auth.Role{auth.RoleAdmin, auth.RoleSupport, auth.RoleManager}) { if user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport, schema.RoleManager}) {
rows, err := sq.Select(table+"."+selectColumn).Distinct().From(table). rows, err := sq.Select(table+"."+selectColumn).Distinct().From(table).
Where(table+"."+whereColumn+" LIKE ?", fmt.Sprint("%", query, "%")). Where(table+"."+whereColumn+" LIKE ?", fmt.Sprint("%", query, "%")).
RunWith(r.stmtCache).Query() RunWith(r.stmtCache).Query()

View File

@ -12,7 +12,6 @@ import (
"strings" "strings"
"time" "time"
"github.com/ClusterCockpit/cc-backend/internal/auth"
"github.com/ClusterCockpit/cc-backend/internal/graph/model" "github.com/ClusterCockpit/cc-backend/internal/graph/model"
"github.com/ClusterCockpit/cc-backend/pkg/log" "github.com/ClusterCockpit/cc-backend/pkg/log"
"github.com/ClusterCockpit/cc-backend/pkg/schema" "github.com/ClusterCockpit/cc-backend/pkg/schema"
@ -130,20 +129,20 @@ func (r *JobRepository) CountJobs(
} }
func SecurityCheck(ctx context.Context, query sq.SelectBuilder) (sq.SelectBuilder, error) { func SecurityCheck(ctx context.Context, query sq.SelectBuilder) (sq.SelectBuilder, error) {
user := auth.GetUser(ctx) user := GetUserFromContext(ctx)
if user == nil { if user == nil {
var qnil sq.SelectBuilder var qnil sq.SelectBuilder
return qnil, fmt.Errorf("user context is nil!") return qnil, fmt.Errorf("user context is nil!")
} else if user.HasAnyRole([]auth.Role{auth.RoleAdmin, auth.RoleSupport, auth.RoleApi}) { // Admin & Co. : All jobs } else if user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport, schema.RoleApi}) { // Admin & Co. : All jobs
return query, nil return query, nil
} else if user.HasRole(auth.RoleManager) { // Manager : Add filter for managed projects' jobs only + personal jobs } else if user.HasRole(schema.RoleManager) { // Manager : Add filter for managed projects' jobs only + personal jobs
if len(user.Projects) != 0 { if len(user.Projects) != 0 {
return query.Where(sq.Or{sq.Eq{"job.project": user.Projects}, sq.Eq{"job.user": user.Username}}), nil return query.Where(sq.Or{sq.Eq{"job.project": user.Projects}, sq.Eq{"job.user": user.Username}}), nil
} else { } else {
log.Debugf("Manager-User '%s' has no defined projects to lookup! Query only personal jobs ...", user.Username) log.Debugf("Manager-User '%s' has no defined projects to lookup! Query only personal jobs ...", user.Username)
return query.Where("job.user = ?", user.Username), nil return query.Where("job.user = ?", user.Username), nil
} }
} else if user.HasRole(auth.RoleUser) { // User : Only personal jobs } else if user.HasRole(schema.RoleUser) { // User : Only personal jobs
return query.Where("job.user = ?", user.Username), nil return query.Where("job.user = ?", user.Username), nil
} else { } else {
// Shortterm compatibility: Return User-Query if no roles: // Shortterm compatibility: Return User-Query if no roles:

View File

@ -10,7 +10,6 @@ import (
"fmt" "fmt"
"time" "time"
"github.com/ClusterCockpit/cc-backend/internal/auth"
"github.com/ClusterCockpit/cc-backend/internal/config" "github.com/ClusterCockpit/cc-backend/internal/config"
"github.com/ClusterCockpit/cc-backend/internal/graph/model" "github.com/ClusterCockpit/cc-backend/internal/graph/model"
"github.com/ClusterCockpit/cc-backend/pkg/log" "github.com/ClusterCockpit/cc-backend/pkg/log"
@ -86,7 +85,7 @@ func (r *JobRepository) buildStatsQuery(
} }
func (r *JobRepository) getUserName(ctx context.Context, id string) string { func (r *JobRepository) getUserName(ctx context.Context, id string) string {
user := auth.GetUser(ctx) user := GetUserFromContext(ctx)
name, _ := r.FindColumnValue(user, id, "user", "name", "username", false) name, _ := r.FindColumnValue(user, id, "user", "name", "username", false)
if name != "" { if name != "" {
return name return name

View File

@ -7,7 +7,6 @@ package repository
import ( import (
"strings" "strings"
"github.com/ClusterCockpit/cc-backend/internal/auth"
"github.com/ClusterCockpit/cc-backend/pkg/archive" "github.com/ClusterCockpit/cc-backend/pkg/archive"
"github.com/ClusterCockpit/cc-backend/pkg/log" "github.com/ClusterCockpit/cc-backend/pkg/log"
"github.com/ClusterCockpit/cc-backend/pkg/schema" "github.com/ClusterCockpit/cc-backend/pkg/schema"
@ -68,7 +67,7 @@ func (r *JobRepository) CreateTag(tagType string, tagName string) (tagId int64,
return res.LastInsertId() return res.LastInsertId()
} }
func (r *JobRepository) CountTags(user *auth.User) (tags []schema.Tag, counts map[string]int, err error) { func (r *JobRepository) CountTags(user *schema.User) (tags []schema.Tag, counts map[string]int, err error) {
tags = make([]schema.Tag, 0, 100) tags = make([]schema.Tag, 0, 100)
xrows, err := r.DB.Queryx("SELECT id, tag_type, tag_name FROM tag") xrows, err := r.DB.Queryx("SELECT id, tag_type, tag_name FROM tag")
if err != nil { if err != nil {
@ -88,10 +87,10 @@ func (r *JobRepository) CountTags(user *auth.User) (tags []schema.Tag, counts ma
LeftJoin("jobtag jt ON t.id = jt.tag_id"). LeftJoin("jobtag jt ON t.id = jt.tag_id").
GroupBy("t.tag_name") GroupBy("t.tag_name")
if user != nil && user.HasAnyRole([]auth.Role{auth.RoleAdmin, auth.RoleSupport}) { // ADMIN || SUPPORT: Count all jobs if user != nil && user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport}) { // ADMIN || SUPPORT: Count all jobs
log.Debug("CountTags: User Admin or Support -> Count all Jobs for Tags") log.Debug("CountTags: User Admin or Support -> Count all Jobs for Tags")
// Unchanged: Needs to be own case still, due to UserRole/NoRole compatibility handling in else case // Unchanged: Needs to be own case still, due to UserRole/NoRole compatibility handling in else case
} else if user != nil && user.HasRole(auth.RoleManager) { // MANAGER: Count own jobs plus project's jobs } else if user != nil && user.HasRole(schema.RoleManager) { // MANAGER: Count own jobs plus project's jobs
// Build ("project1", "project2", ...) list of variable length directly in SQL string // Build ("project1", "project2", ...) list of variable length directly in SQL string
q = q.Where("jt.job_id IN (SELECT id FROM job WHERE job.user = ? OR job.project IN (\""+strings.Join(user.Projects, "\",\"")+"\"))", user.Username) q = q.Where("jt.job_id IN (SELECT id FROM job WHERE job.user = ? OR job.project IN (\""+strings.Join(user.Projects, "\",\"")+"\"))", user.Username)
} else if user != nil { // USER OR NO ROLE (Compatibility): Only count own jobs } else if user != nil { // USER OR NO ROLE (Compatibility): Only count own jobs

Binary file not shown.

View File

@ -1,137 +1,351 @@
// Copyright (C) 2022 NHR@FAU, University Erlangen-Nuremberg. // Copyright (C) 2023 NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. // All rights reserved.
// Use of this source code is governed by a MIT-style // Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file. // license that can be found in the LICENSE file.
package repository package repository
import ( import (
"context"
"database/sql"
"encoding/json" "encoding/json"
"errors"
"fmt"
"strings"
"sync" "sync"
"time"
"github.com/ClusterCockpit/cc-backend/internal/auth" "github.com/ClusterCockpit/cc-backend/internal/graph/model"
"github.com/ClusterCockpit/cc-backend/internal/config"
"github.com/ClusterCockpit/cc-backend/pkg/log" "github.com/ClusterCockpit/cc-backend/pkg/log"
"github.com/ClusterCockpit/cc-backend/pkg/lrucache" "github.com/ClusterCockpit/cc-backend/pkg/schema"
sq "github.com/Masterminds/squirrel"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"golang.org/x/crypto/bcrypt"
) )
var ( var (
userCfgRepoOnce sync.Once userRepoOnce sync.Once
userCfgRepoInstance *UserCfgRepo userRepoInstance *UserRepository
) )
type UserCfgRepo struct { type UserRepository struct {
DB *sqlx.DB DB *sqlx.DB
Lookup *sqlx.Stmt driver string
lock sync.RWMutex
uiDefaults map[string]interface{}
cache *lrucache.Cache
} }
func GetUserCfgRepo() *UserCfgRepo { func GetUserRepository() *UserRepository {
userCfgRepoOnce.Do(func() { userRepoOnce.Do(func() {
db := GetConnection() db := GetConnection()
lookupConfigStmt, err := db.DB.Preparex(`SELECT confkey, value FROM configuration WHERE configuration.username = ?`) userRepoInstance = &UserRepository{
if err != nil { DB: db.DB,
log.Fatalf("db.DB.Preparex() error: %v", err) driver: db.Driver,
}
userCfgRepoInstance = &UserCfgRepo{
DB: db.DB,
Lookup: lookupConfigStmt,
uiDefaults: config.Keys.UiDefaults,
cache: lrucache.New(1024),
} }
}) })
return userRepoInstance
return userCfgRepoInstance
} }
// Return the personalised UI config for the currently authenticated func (r *UserRepository) GetUser(username string) (*schema.User, error) {
// user or return the plain default config. user := &schema.User{Username: username}
func (uCfg *UserCfgRepo) GetUIConfig(user *auth.User) (map[string]interface{}, error) { var hashedPassword, name, rawRoles, email, rawProjects sql.NullString
if user == nil { if err := sq.Select("password", "ldap", "name", "roles", "email", "projects").From("user").
uCfg.lock.RLock() Where("user.username = ?", username).RunWith(r.DB).
copy := make(map[string]interface{}, len(uCfg.uiDefaults)) QueryRow().Scan(&hashedPassword, &user.AuthSource, &name, &rawRoles, &email, &rawProjects); err != nil {
for k, v := range uCfg.uiDefaults { log.Warnf("Error while querying user '%v' from database", username)
copy[k] = v
}
uCfg.lock.RUnlock()
return copy, nil
}
data := uCfg.cache.Get(user.Username, func() (interface{}, time.Duration, int) {
uiconfig := make(map[string]interface{}, len(uCfg.uiDefaults))
for k, v := range uCfg.uiDefaults {
uiconfig[k] = v
}
rows, err := uCfg.Lookup.Query(user.Username)
if err != nil {
log.Warnf("Error while looking up user uiconfig for user '%v'", user.Username)
return err, 0, 0
}
size := 0
defer rows.Close()
for rows.Next() {
var key, rawval string
if err := rows.Scan(&key, &rawval); err != nil {
log.Warn("Error while scanning user uiconfig values")
return err, 0, 0
}
var val interface{}
if err := json.Unmarshal([]byte(rawval), &val); err != nil {
log.Warn("Error while unmarshaling raw user uiconfig json")
return err, 0, 0
}
size += len(key)
size += len(rawval)
uiconfig[key] = val
}
// Add global ShortRunningJobsDuration setting as plot_list_hideShortRunningJobs
uiconfig["plot_list_hideShortRunningJobs"] = config.Keys.ShortRunningJobsDuration
return uiconfig, 24 * time.Hour, size
})
if err, ok := data.(error); ok {
log.Error("Error in returned dataset")
return nil, err return nil, err
} }
return data.(map[string]interface{}), nil user.Password = hashedPassword.String
} user.Name = name.String
user.Email = email.String
// If the context does not have a user, update the global ui configuration if rawRoles.Valid {
// without persisting it! If there is a (authenticated) user, update only his if err := json.Unmarshal([]byte(rawRoles.String), &user.Roles); err != nil {
// configuration. log.Warn("Error while unmarshaling raw roles from DB")
func (uCfg *UserCfgRepo) UpdateConfig( return nil, err
key, value string, }
user *auth.User) error { }
if rawProjects.Valid {
if user == nil { if err := json.Unmarshal([]byte(rawProjects.String), &user.Projects); err != nil {
var val interface{} return nil, err
if err := json.Unmarshal([]byte(value), &val); err != nil {
log.Warn("Error while unmarshaling raw user config json")
return err
} }
uCfg.lock.Lock()
defer uCfg.lock.Unlock()
uCfg.uiDefaults[key] = val
return nil
} }
if _, err := uCfg.DB.Exec(`REPLACE INTO configuration (username, confkey, value) VALUES (?, ?, ?)`, user.Username, key, value); err != nil { return user, nil
log.Warnf("Error while replacing user config in DB for user '%v'", user.Username) }
func (r *UserRepository) GetLdapUsernames() ([]string, error) {
var users []string
rows, err := r.DB.Query(`SELECT username FROM user WHERE user.ldap = 1`)
if err != nil {
log.Warn("Error while querying usernames")
return nil, err
}
for rows.Next() {
var username string
if err := rows.Scan(&username); err != nil {
log.Warnf("Error while scanning for user '%s'", username)
return nil, err
}
users = append(users, username)
}
return users, nil
}
func (r *UserRepository) AddUser(user *schema.User) error {
rolesJson, _ := json.Marshal(user.Roles)
projectsJson, _ := json.Marshal(user.Projects)
cols := []string{"username", "roles", "projects"}
vals := []interface{}{user.Username, string(rolesJson), string(projectsJson)}
if user.Name != "" {
cols = append(cols, "name")
vals = append(vals, user.Name)
}
if user.Email != "" {
cols = append(cols, "email")
vals = append(vals, user.Email)
}
if user.Password != "" {
password, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
if err != nil {
log.Error("Error while encrypting new user password")
return err
}
cols = append(cols, "password")
vals = append(vals, string(password))
}
if user.AuthSource != -1 {
cols = append(cols, "ldap")
vals = append(vals, int(user.AuthSource))
}
if _, err := sq.Insert("user").Columns(cols...).Values(vals...).RunWith(r.DB).Exec(); err != nil {
log.Errorf("Error while inserting new user '%v' into DB", user.Username)
return err return err
} }
uCfg.cache.Del(user.Username) log.Infof("new user %#v created (roles: %s, auth-source: %d, projects: %s)", user.Username, rolesJson, user.AuthSource, projectsJson)
return nil return nil
} }
func (r *UserRepository) DelUser(username string) error {
_, err := r.DB.Exec(`DELETE FROM user WHERE user.username = ?`, username)
log.Errorf("Error while deleting user '%s' from DB", username)
return err
}
func (r *UserRepository) ListUsers(specialsOnly bool) ([]*schema.User, error) {
q := sq.Select("username", "name", "email", "roles", "projects").From("user")
if specialsOnly {
q = q.Where("(roles != '[\"user\"]' AND roles != '[]')")
}
rows, err := q.RunWith(r.DB).Query()
if err != nil {
log.Warn("Error while querying user list")
return nil, err
}
users := make([]*schema.User, 0)
defer rows.Close()
for rows.Next() {
rawroles := ""
rawprojects := ""
user := &schema.User{}
var name, email sql.NullString
if err := rows.Scan(&user.Username, &name, &email, &rawroles, &rawprojects); err != nil {
log.Warn("Error while scanning user list")
return nil, err
}
if err := json.Unmarshal([]byte(rawroles), &user.Roles); err != nil {
log.Warn("Error while unmarshaling raw role list")
return nil, err
}
if err := json.Unmarshal([]byte(rawprojects), &user.Projects); err != nil {
return nil, err
}
user.Name = name.String
user.Email = email.String
users = append(users, user)
}
return users, nil
}
func (r *UserRepository) AddRole(
ctx context.Context,
username string,
queryrole string) error {
newRole := strings.ToLower(queryrole)
user, err := r.GetUser(username)
if err != nil {
log.Warnf("Could not load user '%s'", username)
return err
}
exists, valid := user.HasValidRole(newRole)
if !valid {
return fmt.Errorf("Supplied role is no valid option : %v", newRole)
}
if exists {
return fmt.Errorf("User %v already has role %v", username, newRole)
}
roles, _ := json.Marshal(append(user.Roles, newRole))
if _, err := sq.Update("user").Set("roles", roles).Where("user.username = ?", username).RunWith(r.DB).Exec(); err != nil {
log.Errorf("Error while adding new role for user '%s'", user.Username)
return err
}
return nil
}
func (r *UserRepository) RemoveRole(ctx context.Context, username string, queryrole string) error {
oldRole := strings.ToLower(queryrole)
user, err := r.GetUser(username)
if err != nil {
log.Warnf("Could not load user '%s'", username)
return err
}
exists, valid := user.HasValidRole(oldRole)
if !valid {
return fmt.Errorf("Supplied role is no valid option : %v", oldRole)
}
if !exists {
return fmt.Errorf("Role already deleted for user '%v': %v", username, oldRole)
}
if oldRole == schema.GetRoleString(schema.RoleManager) && len(user.Projects) != 0 {
return fmt.Errorf("Cannot remove role 'manager' while user %s still has assigned project(s) : %v", username, user.Projects)
}
var newroles []string
for _, r := range user.Roles {
if r != oldRole {
newroles = append(newroles, r) // Append all roles not matching requested to be deleted role
}
}
var mroles, _ = json.Marshal(newroles)
if _, err := sq.Update("user").Set("roles", mroles).Where("user.username = ?", username).RunWith(r.DB).Exec(); err != nil {
log.Errorf("Error while removing role for user '%s'", user.Username)
return err
}
return nil
}
func (r *UserRepository) AddProject(
ctx context.Context,
username string,
project string) error {
user, err := r.GetUser(username)
if err != nil {
return err
}
if !user.HasRole(schema.RoleManager) {
return fmt.Errorf("user '%s' is not a manager!", username)
}
if user.HasProject(project) {
return fmt.Errorf("user '%s' already manages project '%s'", username, project)
}
projects, _ := json.Marshal(append(user.Projects, project))
if _, err := sq.Update("user").Set("projects", projects).Where("user.username = ?", username).RunWith(r.DB).Exec(); err != nil {
return err
}
return nil
}
func (r *UserRepository) RemoveProject(ctx context.Context, username string, project string) error {
user, err := r.GetUser(username)
if err != nil {
return err
}
if !user.HasRole(schema.RoleManager) {
return fmt.Errorf("user '%#v' is not a manager!", username)
}
if !user.HasProject(project) {
return fmt.Errorf("user '%#v': Cannot remove project '%#v' - Does not match!", username, project)
}
var exists bool
var newprojects []string
for _, p := range user.Projects {
if p != project {
newprojects = append(newprojects, p) // Append all projects not matching requested to be deleted project
} else {
exists = true
}
}
if exists == true {
var result interface{}
if len(newprojects) == 0 {
result = "[]"
} else {
result, _ = json.Marshal(newprojects)
}
if _, err := sq.Update("user").Set("projects", result).Where("user.username = ?", username).RunWith(r.DB).Exec(); err != nil {
return err
}
return nil
} else {
return fmt.Errorf("user %s already does not manage project %s", username, project)
}
}
type ContextKey string
const ContextUserKey ContextKey = "user"
func GetUserFromContext(ctx context.Context) *schema.User {
x := ctx.Value(ContextUserKey)
if x == nil {
return nil
}
return x.(*schema.User)
}
func (r *UserRepository) FetchUserInCtx(ctx context.Context, username string) (*model.User, error) {
me := GetUserFromContext(ctx)
if me != nil && me.Username != username &&
me.HasNotRoles([]schema.Role{schema.RoleAdmin, schema.RoleSupport, schema.RoleManager}) {
return nil, errors.New("forbidden")
}
user := &model.User{Username: username}
var name, email sql.NullString
if err := sq.Select("name", "email").From("user").Where("user.username = ?", username).
RunWith(r.DB).QueryRow().Scan(&name, &email); err != nil {
if err == sql.ErrNoRows {
/* This warning will be logged *often* for non-local users, i.e. users mentioned only in job-table or archive, */
/* since FetchUser will be called to retrieve full name and mail for every job in query/list */
// log.Warnf("User '%s' Not found in DB", username)
return nil, nil
}
log.Warnf("Error while fetching user '%s'", username)
return nil, err
}
user.Name = name.String
user.Email = email.String
return user, nil
}

View File

@ -0,0 +1,137 @@
// Copyright (C) 2022 NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package repository
import (
"encoding/json"
"sync"
"time"
"github.com/ClusterCockpit/cc-backend/internal/config"
"github.com/ClusterCockpit/cc-backend/pkg/log"
"github.com/ClusterCockpit/cc-backend/pkg/lrucache"
"github.com/ClusterCockpit/cc-backend/pkg/schema"
"github.com/jmoiron/sqlx"
)
var (
userCfgRepoOnce sync.Once
userCfgRepoInstance *UserCfgRepo
)
type UserCfgRepo struct {
DB *sqlx.DB
Lookup *sqlx.Stmt
lock sync.RWMutex
uiDefaults map[string]interface{}
cache *lrucache.Cache
}
func GetUserCfgRepo() *UserCfgRepo {
userCfgRepoOnce.Do(func() {
db := GetConnection()
lookupConfigStmt, err := db.DB.Preparex(`SELECT confkey, value FROM configuration WHERE configuration.username = ?`)
if err != nil {
log.Fatalf("db.DB.Preparex() error: %v", err)
}
userCfgRepoInstance = &UserCfgRepo{
DB: db.DB,
Lookup: lookupConfigStmt,
uiDefaults: config.Keys.UiDefaults,
cache: lrucache.New(1024),
}
})
return userCfgRepoInstance
}
// Return the personalised UI config for the currently authenticated
// user or return the plain default config.
func (uCfg *UserCfgRepo) GetUIConfig(user *schema.User) (map[string]interface{}, error) {
if user == nil {
uCfg.lock.RLock()
copy := make(map[string]interface{}, len(uCfg.uiDefaults))
for k, v := range uCfg.uiDefaults {
copy[k] = v
}
uCfg.lock.RUnlock()
return copy, nil
}
data := uCfg.cache.Get(user.Username, func() (interface{}, time.Duration, int) {
uiconfig := make(map[string]interface{}, len(uCfg.uiDefaults))
for k, v := range uCfg.uiDefaults {
uiconfig[k] = v
}
rows, err := uCfg.Lookup.Query(user.Username)
if err != nil {
log.Warnf("Error while looking up user uiconfig for user '%v'", user.Username)
return err, 0, 0
}
size := 0
defer rows.Close()
for rows.Next() {
var key, rawval string
if err := rows.Scan(&key, &rawval); err != nil {
log.Warn("Error while scanning user uiconfig values")
return err, 0, 0
}
var val interface{}
if err := json.Unmarshal([]byte(rawval), &val); err != nil {
log.Warn("Error while unmarshaling raw user uiconfig json")
return err, 0, 0
}
size += len(key)
size += len(rawval)
uiconfig[key] = val
}
// Add global ShortRunningJobsDuration setting as plot_list_hideShortRunningJobs
uiconfig["plot_list_hideShortRunningJobs"] = config.Keys.ShortRunningJobsDuration
return uiconfig, 24 * time.Hour, size
})
if err, ok := data.(error); ok {
log.Error("Error in returned dataset")
return nil, err
}
return data.(map[string]interface{}), nil
}
// If the context does not have a user, update the global ui configuration
// without persisting it! If there is a (authenticated) user, update only his
// configuration.
func (uCfg *UserCfgRepo) UpdateConfig(
key, value string,
user *schema.User) error {
if user == nil {
var val interface{}
if err := json.Unmarshal([]byte(value), &val); err != nil {
log.Warn("Error while unmarshaling raw user config json")
return err
}
uCfg.lock.Lock()
defer uCfg.lock.Unlock()
uCfg.uiDefaults[key] = val
return nil
}
if _, err := uCfg.DB.Exec(`REPLACE INTO configuration (username, confkey, value) VALUES (?, ?, ?)`, user.Username, key, value); err != nil {
log.Warnf("Error while replacing user config in DB for user '%v'", user.Username)
return err
}
uCfg.cache.Del(user.Username)
return nil
}

View File

@ -9,9 +9,9 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/ClusterCockpit/cc-backend/internal/auth"
"github.com/ClusterCockpit/cc-backend/internal/config" "github.com/ClusterCockpit/cc-backend/internal/config"
"github.com/ClusterCockpit/cc-backend/pkg/log" "github.com/ClusterCockpit/cc-backend/pkg/log"
"github.com/ClusterCockpit/cc-backend/pkg/schema"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
) )
@ -22,6 +22,9 @@ func setupUserTest(t *testing.T) *UserCfgRepo {
"kind": "file", "kind": "file",
"path": "./var/job-archive" "path": "./var/job-archive"
}, },
"jwts": {
"max-age": "2m"
},
"clusters": [ "clusters": [
{ {
"name": "testcluster", "name": "testcluster",
@ -53,7 +56,7 @@ func setupUserTest(t *testing.T) *UserCfgRepo {
func TestGetUIConfig(t *testing.T) { func TestGetUIConfig(t *testing.T) {
r := setupUserTest(t) r := setupUserTest(t)
u := auth.User{Username: "demo"} u := schema.User{Username: "demo"}
cfg, err := r.GetUIConfig(&u) cfg, err := r.GetUIConfig(&u)
if err != nil { if err != nil {

View File

@ -13,11 +13,11 @@ import (
"strings" "strings"
"time" "time"
"github.com/ClusterCockpit/cc-backend/internal/auth"
"github.com/ClusterCockpit/cc-backend/internal/graph/model" "github.com/ClusterCockpit/cc-backend/internal/graph/model"
"github.com/ClusterCockpit/cc-backend/internal/repository" "github.com/ClusterCockpit/cc-backend/internal/repository"
"github.com/ClusterCockpit/cc-backend/internal/util" "github.com/ClusterCockpit/cc-backend/internal/util"
"github.com/ClusterCockpit/cc-backend/pkg/log" "github.com/ClusterCockpit/cc-backend/pkg/log"
"github.com/ClusterCockpit/cc-backend/pkg/schema"
"github.com/ClusterCockpit/cc-backend/web" "github.com/ClusterCockpit/cc-backend/web"
"github.com/gorilla/mux" "github.com/gorilla/mux"
) )
@ -81,12 +81,11 @@ func setupJobRoute(i InfoType, r *http.Request) InfoType {
} }
func setupUserRoute(i InfoType, r *http.Request) InfoType { func setupUserRoute(i InfoType, r *http.Request) InfoType {
jobRepo := repository.GetJobRepository()
username := mux.Vars(r)["id"] username := mux.Vars(r)["id"]
i["id"] = username i["id"] = username
i["username"] = username i["username"] = username
// TODO: If forbidden (== err exists), redirect to error page // TODO: If forbidden (== err exists), redirect to error page
if user, _ := auth.FetchUser(r.Context(), jobRepo.DB, username); user != nil { if user, _ := repository.GetUserRepository().FetchUserInCtx(r.Context(), username); user != nil {
i["name"] = user.Name i["name"] = user.Name
i["email"] = user.Email i["email"] = user.Email
} }
@ -125,7 +124,7 @@ func setupAnalysisRoute(i InfoType, r *http.Request) InfoType {
func setupTaglistRoute(i InfoType, r *http.Request) InfoType { func setupTaglistRoute(i InfoType, r *http.Request) InfoType {
jobRepo := repository.GetJobRepository() jobRepo := repository.GetJobRepository()
user := auth.GetUser(r.Context()) user := repository.GetUserFromContext(r.Context())
tags, counts, err := jobRepo.CountTags(user) tags, counts, err := jobRepo.CountTags(user)
tagMap := make(map[string][]map[string]interface{}) tagMap := make(map[string][]map[string]interface{})
@ -255,7 +254,7 @@ func SetupRoutes(router *mux.Router, buildInfo web.Build) {
for _, route := range routes { for _, route := range routes {
route := route route := route
router.HandleFunc(route.Route, func(rw http.ResponseWriter, r *http.Request) { router.HandleFunc(route.Route, func(rw http.ResponseWriter, r *http.Request) {
conf, err := userCfgRepo.GetUIConfig(auth.GetUser(r.Context())) conf, err := userCfgRepo.GetUIConfig(repository.GetUserFromContext(r.Context()))
if err != nil { if err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError) http.Error(rw, err.Error(), http.StatusInternalServerError)
return return
@ -268,9 +267,9 @@ func SetupRoutes(router *mux.Router, buildInfo web.Build) {
} }
// Get User -> What if NIL? // Get User -> What if NIL?
user := auth.GetUser(r.Context()) user := repository.GetUserFromContext(r.Context())
// Get Roles // Get Roles
availableRoles, _ := auth.GetValidRolesMap(user) availableRoles, _ := schema.GetValidRolesMap(user)
page := web.Page{ page := web.Page{
Title: title, Title: title,
@ -285,14 +284,14 @@ func SetupRoutes(router *mux.Router, buildInfo web.Build) {
page.FilterPresets = buildFilterPresets(r.URL.Query()) page.FilterPresets = buildFilterPresets(r.URL.Query())
} }
web.RenderTemplate(rw, r, route.Template, &page) web.RenderTemplate(rw, route.Template, &page)
}) })
} }
} }
func HandleSearchBar(rw http.ResponseWriter, r *http.Request, buildInfo web.Build) { func HandleSearchBar(rw http.ResponseWriter, r *http.Request, buildInfo web.Build) {
user := auth.GetUser(r.Context()) user := repository.GetUserFromContext(r.Context())
availableRoles, _ := auth.GetValidRolesMap(user) availableRoles, _ := schema.GetValidRolesMap(user)
if search := r.URL.Query().Get("searchId"); search != "" { if search := r.URL.Query().Get("searchId"); search != "" {
repo := repository.GetJobRepository() repo := repository.GetJobRepository()
@ -309,10 +308,10 @@ func HandleSearchBar(rw http.ResponseWriter, r *http.Request, buildInfo web.Buil
case "arrayJobId": case "arrayJobId":
http.Redirect(rw, r, "/monitoring/jobs/?arrayJobId="+url.QueryEscape(strings.Trim(splitSearch[1], " ")), http.StatusFound) // All Users: Redirect to Tablequery http.Redirect(rw, r, "/monitoring/jobs/?arrayJobId="+url.QueryEscape(strings.Trim(splitSearch[1], " ")), http.StatusFound) // All Users: Redirect to Tablequery
case "username": case "username":
if user.HasAnyRole([]auth.Role{auth.RoleAdmin, auth.RoleSupport, auth.RoleManager}) { if user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport, schema.RoleManager}) {
http.Redirect(rw, r, "/monitoring/users/?user="+url.QueryEscape(strings.Trim(splitSearch[1], " ")), http.StatusFound) http.Redirect(rw, r, "/monitoring/users/?user="+url.QueryEscape(strings.Trim(splitSearch[1], " ")), http.StatusFound)
} else { } else {
web.RenderTemplate(rw, r, "message.tmpl", &web.Page{Title: "Error", MsgType: "alert-danger", Message: "Missing Access Rights", User: *user, Roles: availableRoles, Build: buildInfo}) web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Error", MsgType: "alert-danger", Message: "Missing Access Rights", User: *user, Roles: availableRoles, Build: buildInfo})
} }
case "name": case "name":
usernames, _ := repo.FindColumnValues(user, strings.Trim(splitSearch[1], " "), "user", "username", "name") usernames, _ := repo.FindColumnValues(user, strings.Trim(splitSearch[1], " "), "user", "username", "name")
@ -320,14 +319,14 @@ func HandleSearchBar(rw http.ResponseWriter, r *http.Request, buildInfo web.Buil
joinedNames := strings.Join(usernames, "&user=") joinedNames := strings.Join(usernames, "&user=")
http.Redirect(rw, r, "/monitoring/users/?user="+joinedNames, http.StatusFound) http.Redirect(rw, r, "/monitoring/users/?user="+joinedNames, http.StatusFound)
} else { } else {
if user.HasAnyRole([]auth.Role{auth.RoleAdmin, auth.RoleSupport, auth.RoleManager}) { if user.HasAnyRole([]schema.Role{schema.RoleAdmin, schema.RoleSupport, schema.RoleManager}) {
http.Redirect(rw, r, "/monitoring/users/?user=NoUserNameFound", http.StatusPermanentRedirect) http.Redirect(rw, r, "/monitoring/users/?user=NoUserNameFound", http.StatusPermanentRedirect)
} else { } else {
web.RenderTemplate(rw, r, "message.tmpl", &web.Page{Title: "Error", MsgType: "alert-danger", Message: "Missing Access Rights", User: *user, Roles: availableRoles, Build: buildInfo}) web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Error", MsgType: "alert-danger", Message: "Missing Access Rights", User: *user, Roles: availableRoles, Build: buildInfo})
} }
} }
default: default:
web.RenderTemplate(rw, r, "message.tmpl", &web.Page{Title: "Warning", MsgType: "alert-warning", Message: fmt.Sprintf("Unknown search type: %s", strings.Trim(splitSearch[0], " ")), User: *user, Roles: availableRoles, Build: buildInfo}) web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Warning", MsgType: "alert-warning", Message: fmt.Sprintf("Unknown search type: %s", strings.Trim(splitSearch[0], " ")), User: *user, Roles: availableRoles, Build: buildInfo})
} }
} else if len(splitSearch) == 1 { } else if len(splitSearch) == 1 {
@ -342,13 +341,13 @@ func HandleSearchBar(rw http.ResponseWriter, r *http.Request, buildInfo web.Buil
} else if jobname != "" { } else if jobname != "" {
http.Redirect(rw, r, "/monitoring/jobs/?jobName="+url.QueryEscape(jobname), http.StatusFound) // JobName (contains) http.Redirect(rw, r, "/monitoring/jobs/?jobName="+url.QueryEscape(jobname), http.StatusFound) // JobName (contains)
} else { } else {
web.RenderTemplate(rw, r, "message.tmpl", &web.Page{Title: "Info", MsgType: "alert-info", Message: "Search without result", User: *user, Roles: availableRoles, Build: buildInfo}) web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Info", MsgType: "alert-info", Message: "Search without result", User: *user, Roles: availableRoles, Build: buildInfo})
} }
} else { } else {
web.RenderTemplate(rw, r, "message.tmpl", &web.Page{Title: "Error", MsgType: "alert-danger", Message: "Searchbar query parameters malformed", User: *user, Roles: availableRoles, Build: buildInfo}) web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Error", MsgType: "alert-danger", Message: "Searchbar query parameters malformed", User: *user, Roles: availableRoles, Build: buildInfo})
} }
} else { } else {
web.RenderTemplate(rw, r, "message.tmpl", &web.Page{Title: "Warning", MsgType: "alert-warning", Message: "Empty search", User: *user, Roles: availableRoles, Build: buildInfo}) web.RenderTemplate(rw, "message.tmpl", &web.Page{Title: "Warning", MsgType: "alert-warning", Message: "Empty search", User: *user, Roles: availableRoles, Build: buildInfo})
} }
} }

14
internal/util/array.go Normal file
View File

@ -0,0 +1,14 @@
// Copyright (C) 2023 NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package util
func Contains[T comparable](items []T, item T) bool {
for _, v := range items {
if v == item {
return true
}
}
return false
}

View File

@ -17,22 +17,28 @@ type LdapConfig struct {
UserFilter string `json:"user_filter"` UserFilter string `json:"user_filter"`
SyncInterval string `json:"sync_interval"` // Parsed using time.ParseDuration. SyncInterval string `json:"sync_interval"` // Parsed using time.ParseDuration.
SyncDelOldUsers bool `json:"sync_del_old_users"` SyncDelOldUsers bool `json:"sync_del_old_users"`
// Should an non-existent user be added to the DB if user exists in ldap directory
SyncUserOnLogin bool `json:"syncUserOnLogin"`
} }
type JWTAuthConfig 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(). // as a string parsable by time.ParseDuration().
MaxAge int64 `json:"max-age"` MaxAge string `json:"max-age"`
// Specifies which cookie should be checked for a JWT token (if no authorization header is present) // Specifies which cookie should be checked for a JWT token (if no authorization header is present)
CookieName string `json:"cookieName"` CookieName string `json:"cookieName"`
// Deny login for users not in database (but defined in JWT). // Deny login for users not in database (but defined in JWT).
// Ignore user roles defined in JWTs ('roles' claim), get them from db. // Ignore user roles defined in JWTs ('roles' claim), get them from db.
ForceJWTValidationViaDatabase bool `json:"forceJWTValidationViaDatabase"` ValidateUser bool `json:"validateUser"`
// Specifies which issuer should be accepted when validating external JWTs ('iss' claim) // Specifies which issuer should be accepted when validating external JWTs ('iss' claim)
TrustedExternalIssuer string `json:"trustedExternalIssuer"` TrustedIssuer string `json:"trustedIssuer"`
// Should an non-existent user be added to the DB based on the information in the token
SyncUserOnLogin bool `json:"syncUserOnLogin"`
} }
type IntRange struct { type IntRange struct {
@ -69,6 +75,9 @@ type ProgramConfig struct {
// Address where the http (or https) server will listen on (for example: 'localhost:80'). // Address where the http (or https) server will listen on (for example: 'localhost:80').
Addr string `json:"addr"` Addr string `json:"addr"`
// Addresses from which the /api/secured/* API endpoints can be reached
ApiAllowedIPs []string `json:"apiAllowedIPs"`
// Drop root permissions once .env was read and the port was taken. // Drop root permissions once .env was read and the port was taken.
User string `json:"user"` User string `json:"user"`
Group string `json:"group"` Group string `json:"group"`
@ -102,7 +111,7 @@ type ProgramConfig struct {
LdapConfig *LdapConfig `json:"ldap"` LdapConfig *LdapConfig `json:"ldap"`
JwtConfig *JWTAuthConfig `json:"jwts"` 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"` SessionMaxAge string `json:"session-max-age"`
// If both those options are not empty, use HTTPS using those certificates. // If both those options are not empty, use HTTPS using those certificates.
@ -113,7 +122,7 @@ type ProgramConfig struct {
// redirect every request incoming at port 80 to that url. // redirect every request incoming at port 80 to that url.
RedirectHttpTo string `json:"redirect-http-to"` 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. // be provided! Most options here can be overwritten by the user.
UiDefaults map[string]interface{} `json:"ui-defaults"` UiDefaults map[string]interface{} `json:"ui-defaults"`

View File

@ -107,10 +107,6 @@
"description": "Specifies for how long a session shall be valid as a string parsable by time.ParseDuration(). If 0 or empty, the session/token does not expire!", "description": "Specifies for how long a session shall be valid as a string parsable by time.ParseDuration(). If 0 or empty, the session/token does not expire!",
"type": "string" "type": "string"
}, },
"jwt-max-age": {
"description": "Specifies for how long a JWT token shall be valid as a string parsable by time.ParseDuration(). If 0 or empty, the session/token does not expire!",
"type": "string"
},
"https-cert-file": { "https-cert-file": {
"description": "Filepath to SSL certificate. If also https-key-file is set use HTTPS using those certificates.", "description": "Filepath to SSL certificate. If also https-key-file is set use HTTPS using those certificates.",
"type": "string" "type": "string"
@ -131,9 +127,34 @@
"description": "Do not show running jobs shorter than X seconds.", "description": "Do not show running jobs shorter than X seconds.",
"type": "integer" "type": "integer"
}, },
"": { "jwts": {
"description": "", "description": "For JWT token authentication.",
"type": "string" "type": "object",
"properties": {
"max-age": {
"description": "Configure how long a token is valid. As string parsable by time.ParseDuration()",
"type": "string"
},
"cookieName": {
"description": "Cookie that should be checked for a JWT token.",
"type": "string"
},
"validateUser": {
"description": "Deny login for users not in database (but defined in JWT). Overwrite roles in JWT with database roles.",
"type": "boolean"
},
"trustedIssuer": {
"description": "Issuer that should be accepted when validating external JWTs ",
"type": "string"
},
"syncUserOnLogin": {
"description": "Add non-existent user to DB at login attempt with values provided in JWT.",
"type": "boolean"
}
},
"required": [
"max-age"
]
}, },
"ldap": { "ldap": {
"description": "For LDAP Authentication and user synchronisation.", "description": "For LDAP Authentication and user synchronisation.",
@ -166,6 +187,10 @@
"sync_del_old_users": { "sync_del_old_users": {
"description": "Delete obsolete users in database.", "description": "Delete obsolete users in database.",
"type": "boolean" "type": "boolean"
},
"syncUserOnLogin": {
"description": "Add non-existent user to DB at login attempt if user exists in Ldap directory",
"type": "boolean"
} }
}, },
"required": [ "required": [
@ -398,6 +423,7 @@
} }
}, },
"required": [ "required": [
"jwts",
"clusters" "clusters"
] ]
} }

201
pkg/schema/user.go Normal file
View File

@ -0,0 +1,201 @@
// 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 schema
import (
"fmt"
"strings"
)
type Role int
const (
RoleAnonymous Role = iota
RoleApi
RoleUser
RoleManager
RoleSupport
RoleAdmin
RoleError
)
type AuthSource int
const (
AuthViaLocalPassword AuthSource = iota
AuthViaLDAP
AuthViaToken
AuthViaAll
)
type AuthType int
const (
AuthToken AuthType = iota
AuthSession
)
type User struct {
Username string `json:"username"`
Password string `json:"-"`
Name string `json:"name"`
Roles []string `json:"roles"`
AuthType AuthType `json:"authType"`
AuthSource AuthSource `json:"authSource"`
Email string `json:"email"`
Projects []string `json:"projects"`
}
func (u *User) HasProject(project string) bool {
for _, p := range u.Projects {
if p == project {
return true
}
}
return false
}
func GetRoleString(roleInt Role) string {
return [6]string{"anonymous", "api", "user", "manager", "support", "admin"}[roleInt]
}
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
}
}

View File

@ -2,7 +2,7 @@
// All rights reserved. // All rights reserved.
// Use of this source code is governed by a MIT-style // Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file. // license that can be found in the LICENSE file.
package auth package schema
import ( import (
"testing" "testing"

View File

@ -11,6 +11,9 @@ import (
func TestValidateConfig(t *testing.T) { func TestValidateConfig(t *testing.T) {
json := []byte(`{ json := []byte(`{
"jwts": {
"max-age": "2m"
},
"clusters": [ "clusters": [
{ {
"name": "testcluster", "name": "testcluster",
@ -21,9 +24,7 @@ func TestValidateConfig(t *testing.T) {
"numNodes": { "from": 1, "to": 64 }, "numNodes": { "from": 1, "to": 64 },
"duration": { "from": 0, "to": 86400 }, "duration": { "from": 0, "to": 86400 },
"startTime": { "from": "2022-01-01T00:00:00Z", "to": null } "startTime": { "from": "2022-01-01T00:00:00Z", "to": null }
} }}]
}
]
}`) }`)
if err := Validate(Config, bytes.NewReader(json)); err != nil { if err := Validate(Config, bytes.NewReader(json)); err != nil {

View File

@ -11,7 +11,6 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/ClusterCockpit/cc-backend/internal/auth"
"github.com/ClusterCockpit/cc-backend/internal/config" "github.com/ClusterCockpit/cc-backend/internal/config"
"github.com/ClusterCockpit/cc-backend/internal/util" "github.com/ClusterCockpit/cc-backend/internal/util"
"github.com/ClusterCockpit/cc-backend/pkg/log" "github.com/ClusterCockpit/cc-backend/pkg/log"
@ -92,8 +91,8 @@ type Page struct {
Title string // Page title Title string // Page title
MsgType string // For generic use in message boxes MsgType string // For generic use in message boxes
Message string // For generic use in message boxes Message string // For generic use in message boxes
User auth.User // Information about the currently logged in user (Full User Info) User schema.User // Information about the currently logged in user (Full User Info)
Roles map[string]auth.Role // Available roles for frontend render checks Roles map[string]schema.Role // Available roles for frontend render checks
Build Build // Latest information about the application Build Build // Latest information about the application
Clusters []schema.ClusterConfig // List of all clusters for use in the Header Clusters []schema.ClusterConfig // List of all clusters for use in the Header
FilterPresets map[string]interface{} // For pages with the Filter component, this can be used to set initial filters. FilterPresets map[string]interface{} // For pages with the Filter component, this can be used to set initial filters.
@ -101,7 +100,7 @@ type Page struct {
Config map[string]interface{} // UI settings for the currently logged in user (e.g. line width, ...) Config map[string]interface{} // UI settings for the currently logged in user (e.g. line width, ...)
} }
func RenderTemplate(rw http.ResponseWriter, r *http.Request, file string, page *Page) { func RenderTemplate(rw http.ResponseWriter, file string, page *Page) {
t, ok := templates[file] t, ok := templates[file]
if !ok { if !ok {
log.Errorf("WEB/WEB > template '%s' not found", file) log.Errorf("WEB/WEB > template '%s' not found", file)