From b8273a9b0265e4ff3a0fa65a557215dd161606c1 Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Fri, 11 Aug 2023 10:00:23 +0200 Subject: [PATCH] refactor auth module Restructure module Separate JWT auth variants Cleanup code Fixes #189 --- docs/dev-authentication.md | 53 +++-- internal/auth/auth.go | 308 +++++++++------------------- internal/auth/jwt.go | 212 +------------------ internal/auth/jwtCookieSession.go | 224 ++++++++++++++++++++ internal/auth/jwtSession.go | 103 ++++++++++ internal/auth/ldap.go | 9 +- internal/auth/local.go | 7 - internal/auth/roles.go | 165 +++++++++++++++ internal/repository/testdata/job.db | Bin 110592 -> 114688 bytes 9 files changed, 630 insertions(+), 451 deletions(-) create mode 100644 internal/auth/jwtCookieSession.go create mode 100644 internal/auth/jwtSession.go create mode 100644 internal/auth/roles.go diff --git a/docs/dev-authentication.md b/docs/dev-authentication.md index 4237a6a..d40bdb0 100644 --- a/docs/dev-authentication.md +++ b/docs/dev-authentication.md @@ -1,13 +1,15 @@ # Overview -The implementation of authentication is not easy to understand by just looking -at the code. The authentication is implemented in `internal/auth/`. In `auth.go` +The authentication is implemented in `internal/auth/`. In `auth.go` an interface is defined that any authentication provider must fulfill. It also acts as a dispatcher to delegate the calls to the available authentication providers. -The most important routine are: -* `CanLogin()` Check if the authentication method is supported for login attempt +Two authentication types are available: +* JWT authentication for the REST API that does not create a session cookie +* Session based authentication using a session cookie + +The most important routines in auth are: * `Login()` Handle POST request to login user and start a new session * `Auth()` Authenticate user and put User Object in context of the request @@ -30,10 +32,9 @@ secured.Use(func(next http.Handler) http.Handler { }) ``` -For non API routes a JWT token can be used to initiate an authenticated user +A JWT token can be used to initiate an authenticated user session. This can either happen by calling the login route with a token -provided in a header or query URL or via the `Auth()` method on first access -to a secured URL via a special cookie containing the JWT token. +provided in a header or via a special cookie containing the JWT token. For API routes the access is authenticated on every request using the JWT token and no session is initiated. @@ -43,12 +44,13 @@ The Login function (located in `auth.go`): * Extracts the user name and gets the user from the user database table. In case the user is not found the user object is set to nil. * Iterates over all authenticators and: - - Calls the `CanLogin` function which checks if the authentication method is - supported for this user and the user object is valid. - - Calls the `Login` function to authenticate the user. On success a valid user + - Calls its `CanLogin` function which checks if the authentication method is + supported for this user. + - Calls its `Login` function to authenticate the user. On success a valid user object is returned. - Creates a new session object, stores the user attributes in the session and saves the session. + - If the user does not yet exist in the database try to add the user - Starts the `onSuccess` http handler ## Local authenticator @@ -82,17 +84,13 @@ if err := l.Bind(userDn, r.FormValue("password")); err != nil { } ``` -## JWT authenticator +## JWT Session authenticator Login via JWT token will create a session without password. -For login the `X-Auth-Token` header is not supported. -This authenticator is applied if either user is not nil and auth source is -`AuthViaToken` or the Authorization header is present or the URL query key -login-token is present: +For login the `X-Auth-Token` header is not supported. This authenticator is +applied if the Authorization header is present: ``` -return (user != nil && user.AuthSource == AuthViaToken) || - r.Header.Get("Authorization") != "" || - r.URL.Query().Get("login-token") != "" + return r.Header.Get("Authorization") != "" ``` The Login function: @@ -108,6 +106,25 @@ The Login function: - In case user is not yet present add user to user database table with `AuthViaToken` AuthSource. * Return valid user object +## JWT Cookie Session authenticator + +Login via JWT cookie token will create a session without password. +It is first checked if the required configuration keys are set: +* `publicKeyCrossLogin` +* `TrustedExternalIssuer` +* `CookieName` + + This authenticator is applied if the configured cookie is present: +``` + jwtCookie, err := r.Cookie(cookieName) + + if err == nil && jwtCookie.Value != "" { + return true + } +``` + +The Login function: + # Auth The Auth function (located in `auth.go`): diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 9ac75be..8c1dca7 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -1,4 +1,4 @@ -// Copyright (C) 2022 NHR@FAU, University Erlangen-Nuremberg. +// Copyright (C) 2023 NHR@FAU, University Erlangen-Nuremberg. // All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. @@ -7,12 +7,11 @@ package auth import ( "context" "crypto/rand" + "database/sql" "encoding/base64" "errors" - "fmt" "net/http" "os" - "strings" "time" "github.com/ClusterCockpit/cc-backend/pkg/log" @@ -28,172 +27,25 @@ const ( AuthViaToken ) +type AuthType int + +const ( + AuthToken AuthType = iota + AuthSession +) + type User struct { Username string `json:"username"` Password string `json:"-"` Name string `json:"name"` Roles []string `json:"roles"` - AuthSource AuthSource `json:"via"` + AuthType AuthType `json:"authType"` + AuthSource AuthSource `json:"authSource"` Email string `json:"email"` Projects []string `json:"projects"` Expiration time.Time } -type Role int - -const ( - RoleAnonymous Role = iota - RoleApi - RoleUser - RoleManager - RoleSupport - RoleAdmin - RoleError -) - -func GetRoleString(roleInt Role) string { - return [6]string{"anonymous", "api", "user", "manager", "support", "admin"}[roleInt] -} - -func getRoleEnum(roleStr string) Role { - switch strings.ToLower(roleStr) { - case "admin": - return RoleAdmin - case "support": - return RoleSupport - case "manager": - return RoleManager - case "user": - return RoleUser - case "api": - return RoleApi - case "anonymous": - return RoleAnonymous - default: - return RoleError - } -} - -func isValidRole(role string) bool { - return getRoleEnum(role) != RoleError -} - -func (u *User) HasValidRole(role string) (hasRole bool, isValid bool) { - if isValidRole(role) { - for _, r := range u.Roles { - if r == role { - return true, true - } - } - return false, true - } - return false, false -} - -func (u *User) HasRole(role Role) bool { - for _, r := range u.Roles { - if r == GetRoleString(role) { - return true - } - } - return false -} - -// Role-Arrays are short: performance not impacted by nested loop -func (u *User) HasAnyRole(queryroles []Role) bool { - for _, ur := range u.Roles { - for _, qr := range queryroles { - if ur == GetRoleString(qr) { - return true - } - } - } - return false -} - -// Role-Arrays are short: performance not impacted by nested loop -func (u *User) HasAllRoles(queryroles []Role) bool { - target := len(queryroles) - matches := 0 - for _, ur := range u.Roles { - for _, qr := range queryroles { - if ur == GetRoleString(qr) { - matches += 1 - break - } - } - } - - if matches == target { - return true - } else { - return false - } -} - -// Role-Arrays are short: performance not impacted by nested loop -func (u *User) HasNotRoles(queryroles []Role) bool { - matches := 0 - for _, ur := range u.Roles { - for _, qr := range queryroles { - if ur == GetRoleString(qr) { - matches += 1 - break - } - } - } - - if matches == 0 { - return true - } else { - return false - } -} - -// Called by API endpoint '/roles/' from frontend: Only required for admin config -> Check Admin Role -func GetValidRoles(user *User) ([]string, error) { - var vals []string - if user.HasRole(RoleAdmin) { - for i := RoleApi; i < RoleError; i++ { - vals = append(vals, GetRoleString(i)) - } - return vals, nil - } - - return vals, fmt.Errorf("%s: only admins are allowed to fetch a list of roles", user.Username) -} - -// Called by routerConfig web.page setup in backend: Only requires known user -func GetValidRolesMap(user *User) (map[string]Role, error) { - named := make(map[string]Role) - if user.HasNotRoles([]Role{RoleAnonymous}) { - for i := RoleApi; i < RoleError; i++ { - named[GetRoleString(i)] = i - } - return named, nil - } - return named, fmt.Errorf("only known users are allowed to fetch a list of roles") -} - -// Find highest role -func (u *User) GetAuthLevel() Role { - if u.HasRole(RoleAdmin) { - return RoleAdmin - } else if u.HasRole(RoleSupport) { - return RoleSupport - } else if u.HasRole(RoleManager) { - return RoleManager - } else if u.HasRole(RoleUser) { - return RoleUser - } else if u.HasRole(RoleApi) { - return RoleApi - } else if u.HasRole(RoleAnonymous) { - return RoleAnonymous - } else { - return RoleError - } -} - func (u *User) HasProject(project string) bool { for _, p := range u.Projects { if p == project { @@ -216,7 +68,6 @@ type Authenticator interface { Init(auth *Authentication, config interface{}) error CanLogin(user *User, rw http.ResponseWriter, r *http.Request) bool Login(user *User, rw http.ResponseWriter, r *http.Request) (*User, error) - Auth(rw http.ResponseWriter, r *http.Request) (*User, error) } type ContextKey string @@ -234,6 +85,47 @@ type Authentication struct { LocalAuth *LocalAuthenticator } +func (auth *Authentication) AuthViaSession( + rw http.ResponseWriter, + r *http.Request) (*User, error) { + session, err := auth.sessionStore.Get(r, "session") + if err != nil { + log.Error("Error while getting session store") + return nil, err + } + + if session.IsNew { + return nil, nil + } + + var username string + var projects, roles []string + + if val, ok := session.Values["username"]; ok { + username, _ = val.(string) + } else { + return nil, errors.New("No key username in session") + } + if val, ok := session.Values["projects"]; ok { + projects, _ = val.([]string) + } else { + return nil, errors.New("No key projects in session") + } + if val, ok := session.Values["projects"]; ok { + roles, _ = val.([]string) + } else { + return nil, errors.New("No key roles in session") + } + + return &User{ + Username: username, + Projects: projects, + Roles: roles, + AuthType: AuthSession, + AuthSource: -1, + }, nil +} + func Init(db *sqlx.DB, configs map[string]interface{}) (*Authentication, error) { auth := &Authentication{} @@ -257,19 +149,11 @@ func Init(db *sqlx.DB, auth.sessionStore = sessions.NewCookieStore(bytes) } - auth.LocalAuth = &LocalAuthenticator{} - if err := auth.LocalAuth.Init(auth, nil); err != nil { - log.Error("Error while initializing authentication -> localAuth init failed") - return nil, err - } - auth.authenticators = append(auth.authenticators, auth.LocalAuth) - auth.JwtAuth = &JWTAuthenticator{} if err := auth.JwtAuth.Init(auth, configs["jwt"]); err != nil { log.Error("Error while initializing authentication -> jwtAuth init failed") return nil, err } - auth.authenticators = append(auth.authenticators, auth.JwtAuth) if config, ok := configs["ldap"]; ok { auth.LdapAuth = &LdapAuthenticator{} @@ -280,36 +164,30 @@ func Init(db *sqlx.DB, auth.authenticators = append(auth.authenticators, auth.LdapAuth) } + jwtSessionAuth := &JWTSessionAuthenticator{} + if err := jwtSessionAuth.Init(auth, configs["jwt"]); err != nil { + log.Warn("Error while initializing authentication -> jwtSessionAuth init failed") + } else { + auth.authenticators = append(auth.authenticators, jwtSessionAuth) + } + + jwtCookieSessionAuth := &JWTCookieSessionAuthenticator{} + if err := jwtSessionAuth.Init(auth, configs["jwt"]); err != nil { + log.Warn("Error while initializing authentication -> jwtCookieSessionAuth init failed") + } else { + auth.authenticators = append(auth.authenticators, jwtCookieSessionAuth) + } + + auth.LocalAuth = &LocalAuthenticator{} + if err := auth.LocalAuth.Init(auth, nil); err != nil { + log.Error("Error while initializing authentication -> localAuth init failed") + return nil, err + } + auth.authenticators = append(auth.authenticators, auth.LocalAuth) + return auth, nil } -func (auth *Authentication) AuthViaSession( - rw http.ResponseWriter, - r *http.Request) (*User, error) { - - session, err := auth.sessionStore.Get(r, "session") - if err != nil { - log.Error("Error while getting session store") - return nil, err - } - - if session.IsNew { - return nil, nil - } - - // TODO Check if keys are present in session? - username, _ := session.Values["username"].(string) - projects, _ := session.Values["projects"].([]string) - roles, _ := session.Values["roles"].([]string) - return &User{ - Username: username, - Projects: projects, - Roles: roles, - AuthSource: -1, - }, nil -} - -// Handle a POST request that should log the user in, starting a new session. func (auth *Authentication) Login( onsuccess http.Handler, onfailure func(rw http.ResponseWriter, r *http.Request, loginErr error)) http.Handler { @@ -317,18 +195,21 @@ func (auth *Authentication) Login( return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { err := errors.New("no authenticator applied") username := r.FormValue("username") - user := (*User)(nil) + dbUser := (*User)(nil) if username != "" { - user, _ = auth.GetUser(username) + dbUser, err = auth.GetUser(username) + if err != nil && err != sql.ErrNoRows { + log.Errorf("Error while loading user '%v'", username) + } } for _, authenticator := range auth.authenticators { - if !authenticator.CanLogin(user, rw, r) { + if !authenticator.CanLogin(dbUser, rw, r) { continue } - user, err = authenticator.Login(user, rw, r) + user, err := authenticator.Login(dbUser, rw, r) if err != nil { log.Warnf("user login failed: %s", err.Error()) onfailure(rw, r, err) @@ -354,6 +235,14 @@ func (auth *Authentication) Login( return } + if dbUser == nil { + if err := auth.AddUser(user); err != nil { + // TODO Add AuthSource + log.Errorf("Error while adding user '%v' to auth from XX", + user.Username) + } + } + log.Infof("login successfull: user: %#v (roles: %v, projects: %v)", user.Username, user.Roles, user.Projects) ctx := context.WithValue(r.Context(), ContextUserKey, user) onsuccess.ServeHTTP(rw, r.WithContext(ctx)) @@ -365,39 +254,34 @@ func (auth *Authentication) Login( }) } -// Authenticate the user and put a User object in the -// context of the request. If authentication fails, -// do not continue but send client to the login screen. func (auth *Authentication) Auth( onsuccess http.Handler, onfailure func(rw http.ResponseWriter, r *http.Request, authErr error)) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - for _, authenticator := range auth.authenticators { - user, err := authenticator.Auth(rw, r) + + user, err := auth.JwtAuth.AuthViaJWT(rw, r) + if user == nil { + user, err = auth.AuthViaSession(rw, r) if err != nil { log.Infof("authentication failed: %s", err.Error()) http.Error(rw, err.Error(), http.StatusUnauthorized) return } - if user == nil { - continue - } + } + if user != nil { ctx := context.WithValue(r.Context(), ContextUserKey, user) onsuccess.ServeHTTP(rw, r.WithContext(ctx)) return } - log.Debugf("authentication failed: %s", "no authenticator applied") - // http.Error(rw, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) - onfailure(rw, r, errors.New("unauthorized (login first or use a token)")) + log.Debug("authentication failed: no authenticator applied") + onfailure(rw, r, errors.New("unauthorized (please login first)")) }) } -// Clears the session cookie func (auth *Authentication) Logout(onsuccess http.Handler) http.Handler { - return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { session, err := auth.sessionStore.Get(r, "session") if err != nil { diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go index 8df7017..75a69b8 100644 --- a/internal/auth/jwt.go +++ b/internal/auth/jwt.go @@ -6,10 +6,8 @@ package auth import ( "crypto/ed25519" - "database/sql" "encoding/base64" "errors" - "fmt" "net/http" "os" "strings" @@ -23,17 +21,11 @@ import ( type JWTAuthenticator struct { auth *Authentication - publicKey ed25519.PublicKey - privateKey ed25519.PrivateKey - publicKeyCrossLogin ed25519.PublicKey // For accepting externally generated JWTs - - loginTokenKey []byte // HS256 key - - config *schema.JWTAuthConfig + publicKey ed25519.PublicKey + privateKey ed25519.PrivateKey + config *schema.JWTAuthConfig } -var _ Authenticator = (*JWTAuthenticator)(nil) - func (ja *JWTAuthenticator) Init(auth *Authentication, conf interface{}) error { ja.auth = auth @@ -57,128 +49,10 @@ func (ja *JWTAuthenticator) Init(auth *Authentication, conf interface{}) error { ja.privateKey = ed25519.PrivateKey(bytes) } - if pubKey = os.Getenv("CROSS_LOGIN_JWT_HS512_KEY"); pubKey != "" { - bytes, err := base64.StdEncoding.DecodeString(pubKey) - if err != nil { - log.Warn("Could not decode cross login JWT HS512 key") - return err - } - ja.loginTokenKey = bytes - } - - // Look for external public keys - pubKeyCrossLogin, keyFound := os.LookupEnv("CROSS_LOGIN_JWT_PUBLIC_KEY") - if keyFound && pubKeyCrossLogin != "" { - bytes, err := base64.StdEncoding.DecodeString(pubKeyCrossLogin) - if err != nil { - log.Warn("Could not decode cross login JWT public key") - return err - } - ja.publicKeyCrossLogin = ed25519.PublicKey(bytes) - - // Warn if other necessary settings are not configured - if ja.config != nil { - if ja.config.CookieName == "" { - log.Warn("cookieName for JWTs not configured (cross login via JWT cookie will fail)") - } - if !ja.config.ForceJWTValidationViaDatabase { - log.Warn("forceJWTValidationViaDatabase not set to true: CC will accept users and roles defined in JWTs regardless of its own database!") - } - if ja.config.TrustedExternalIssuer == "" { - log.Warn("trustedExternalIssuer for JWTs not configured (cross login via JWT cookie will fail)") - } - } else { - log.Warn("cookieName and trustedExternalIssuer for JWTs not configured (cross login via JWT cookie will fail)") - } - } else { - ja.publicKeyCrossLogin = nil - log.Debug("environment variable 'CROSS_LOGIN_JWT_PUBLIC_KEY' not set (cross login token based authentication will not work)") - } - return nil } -func (ja *JWTAuthenticator) CanLogin( - user *User, - rw http.ResponseWriter, - r *http.Request) bool { - - return (user != nil && user.AuthSource == AuthViaToken) || - r.Header.Get("Authorization") != "" || - r.URL.Query().Get("login-token") != "" -} - -func (ja *JWTAuthenticator) Login( - user *User, - rw http.ResponseWriter, - r *http.Request) (*User, error) { - - rawtoken := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") - if rawtoken == "" { - rawtoken = r.URL.Query().Get("login-token") - } - - token, err := jwt.Parse(rawtoken, func(t *jwt.Token) (interface{}, error) { - if t.Method == jwt.SigningMethodEdDSA { - return ja.publicKey, nil - } - if t.Method == jwt.SigningMethodHS256 || t.Method == jwt.SigningMethodHS512 { - return ja.loginTokenKey, nil - } - return nil, fmt.Errorf("AUTH/JWT > unkown signing method for login token: %s (known: HS256, HS512, EdDSA)", t.Method.Alg()) - }) - if err != nil { - log.Warn("Error while parsing jwt token") - return nil, err - } - - if err = token.Claims.Valid(); err != nil { - log.Warn("jwt token claims are not valid") - return nil, err - } - - claims := token.Claims.(jwt.MapClaims) - sub, _ := claims["sub"].(string) - exp, _ := claims["exp"].(float64) - var roles []string - if rawroles, ok := claims["roles"].([]interface{}); ok { - for _, rr := range rawroles { - if r, ok := rr.(string); ok { - if isValidRole(r) { - roles = append(roles, r) - } - } - } - } - if rawrole, ok := claims["roles"].(string); ok { - if isValidRole(rawrole) { - roles = append(roles, rawrole) - } - } - - if user == nil { - user, err = ja.auth.GetUser(sub) - if err != nil && err != sql.ErrNoRows { - log.Errorf("Error while loading user '%v'", sub) - return nil, err - } else if user == nil { - user = &User{ - Username: sub, - Roles: roles, - AuthSource: AuthViaToken, - } - if err := ja.auth.AddUser(user); err != nil { - log.Errorf("Error while adding user '%v' to auth from token", user.Username) - return nil, err - } - } - } - - user.Expiration = time.Unix(int64(exp), 0) - return user, nil -} - -func (ja *JWTAuthenticator) Auth( +func (ja *JWTAuthenticator) AuthViaJWT( rw http.ResponseWriter, r *http.Request) (*User, error) { @@ -188,59 +62,17 @@ func (ja *JWTAuthenticator) Auth( rawtoken = strings.TrimPrefix(rawtoken, "Bearer ") } - // If no auth header was found, check for a certain cookie containing a JWT - cookieName := "" - cookieFound := false - if ja.config != nil && ja.config.CookieName != "" { - cookieName = ja.config.CookieName - } - - // Try to read the JWT cookie - if rawtoken == "" && cookieName != "" { - jwtCookie, err := r.Cookie(cookieName) - - if err == nil && jwtCookie.Value != "" { - rawtoken = jwtCookie.Value - cookieFound = true - } - } - - // Because a user can also log in via a token, the - // session cookie must be checked here as well: - if rawtoken == "" { - return ja.auth.AuthViaSession(rw, r) - } - - // Try to parse JWT token, err := jwt.Parse(rawtoken, func(t *jwt.Token) (interface{}, error) { if t.Method != jwt.SigningMethodEdDSA { return nil, errors.New("only Ed25519/EdDSA supported") } - // Is there more than one public key? - if ja.publicKeyCrossLogin != nil && - ja.config != nil && - ja.config.TrustedExternalIssuer != "" { - - // Determine whether to use the external public key - unvalidatedIssuer, success := t.Claims.(jwt.MapClaims)["iss"].(string) - if success && unvalidatedIssuer == ja.config.TrustedExternalIssuer { - // The (unvalidated) issuer seems to be the expected one, - // use public cross login key from config - return ja.publicKeyCrossLogin, nil - } - } - - // No cross login key configured or issuer not expected - // Try own key return ja.publicKey, nil }) if err != nil { - log.Warn("Error while parsing token") + log.Warn("Error while parsing JWT token") return nil, err } - - // Check token validity if err := token.Claims.Valid(); err != nil { log.Warn("jwt token claims are not valid") return nil, err @@ -261,7 +93,6 @@ func (ja *JWTAuthenticator) Auth( log.Warn("Could not find user from JWT in internal database.") return nil, errors.New("unknown user") } - // Take user roles from database instead of trusting the JWT roles = user.Roles } else { @@ -275,41 +106,10 @@ func (ja *JWTAuthenticator) Auth( } } - if cookieFound { - // Create a session so that we no longer need the JTW Cookie - session, err := ja.auth.sessionStore.New(r, "session") - if err != nil { - log.Errorf("session creation failed: %s", err.Error()) - http.Error(rw, err.Error(), http.StatusInternalServerError) - return nil, err - } - - if ja.auth.SessionMaxAge != 0 { - session.Options.MaxAge = int(ja.auth.SessionMaxAge.Seconds()) - } - session.Values["username"] = sub - session.Values["roles"] = roles - - if err := ja.auth.sessionStore.Save(r, rw, session); err != nil { - log.Warnf("session save failed: %s", err.Error()) - http.Error(rw, err.Error(), http.StatusInternalServerError) - return nil, err - } - - // (Ask browser to) Delete JWT cookie - deletedCookie := &http.Cookie{ - Name: cookieName, - Value: "", - Path: "/", - MaxAge: -1, - HttpOnly: true, - } - http.SetCookie(rw, deletedCookie) - } - return &User{ Username: sub, Roles: roles, + AuthType: AuthSession, AuthSource: AuthViaToken, }, nil } diff --git a/internal/auth/jwtCookieSession.go b/internal/auth/jwtCookieSession.go new file mode 100644 index 0000000..1ae3a5b --- /dev/null +++ b/internal/auth/jwtCookieSession.go @@ -0,0 +1,224 @@ +// Copyright (C) 2023 NHR@FAU, University Erlangen-Nuremberg. +// All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. +package auth + +import ( + "crypto/ed25519" + "encoding/base64" + "errors" + "net/http" + "os" + "time" + + "github.com/ClusterCockpit/cc-backend/pkg/log" + "github.com/ClusterCockpit/cc-backend/pkg/schema" + "github.com/golang-jwt/jwt/v4" +) + +type JWTCookieSessionAuthenticator struct { + auth *Authentication + + publicKey ed25519.PublicKey + privateKey ed25519.PrivateKey + publicKeyCrossLogin ed25519.PublicKey // For accepting externally generated JWTs + + loginTokenKey []byte // HS256 key + + config *schema.JWTAuthConfig +} + +var _ Authenticator = (*JWTCookieSessionAuthenticator)(nil) + +func (ja *JWTCookieSessionAuthenticator) Init(auth *Authentication, conf interface{}) error { + + ja.auth = auth + ja.config = conf.(*schema.JWTAuthConfig) + + pubKey, privKey := os.Getenv("JWT_PUBLIC_KEY"), os.Getenv("JWT_PRIVATE_KEY") + if pubKey == "" || privKey == "" { + log.Warn("environment variables 'JWT_PUBLIC_KEY' or 'JWT_PRIVATE_KEY' not set (token based authentication will not work)") + return errors.New("environment variables 'JWT_PUBLIC_KEY' or 'JWT_PRIVATE_KEY' not set (token based authentication will not work)") + } else { + bytes, err := base64.StdEncoding.DecodeString(pubKey) + if err != nil { + log.Warn("Could not decode JWT public key") + return err + } + ja.publicKey = ed25519.PublicKey(bytes) + bytes, err = base64.StdEncoding.DecodeString(privKey) + if err != nil { + log.Warn("Could not decode JWT private key") + return err + } + ja.privateKey = ed25519.PrivateKey(bytes) + } + + if pubKey = os.Getenv("CROSS_LOGIN_JWT_HS512_KEY"); pubKey != "" { + bytes, err := base64.StdEncoding.DecodeString(pubKey) + if err != nil { + log.Warn("Could not decode cross login JWT HS512 key") + return err + } + ja.loginTokenKey = bytes + } + + // Look for external public keys + pubKeyCrossLogin, keyFound := os.LookupEnv("CROSS_LOGIN_JWT_PUBLIC_KEY") + if keyFound && pubKeyCrossLogin != "" { + bytes, err := base64.StdEncoding.DecodeString(pubKeyCrossLogin) + if err != nil { + log.Warn("Could not decode cross login JWT public key") + return err + } + ja.publicKeyCrossLogin = ed25519.PublicKey(bytes) + } else { + ja.publicKeyCrossLogin = nil + log.Debug("environment variable 'CROSS_LOGIN_JWT_PUBLIC_KEY' not set (cross login token based authentication will not work)") + return errors.New("environment variable 'CROSS_LOGIN_JWT_PUBLIC_KEY' not set (cross login token based authentication will not work)") + } + + // Warn if other necessary settings are not configured + if ja.config != nil { + if ja.config.CookieName == "" { + log.Warn("cookieName for JWTs not configured (cross login via JWT cookie will fail)") + return errors.New("cookieName for JWTs not configured (cross login via JWT cookie will fail)") + } + if !ja.config.ForceJWTValidationViaDatabase { + log.Warn("forceJWTValidationViaDatabase not set to true: CC will accept users and roles defined in JWTs regardless of its own database!") + } + if ja.config.TrustedExternalIssuer == "" { + log.Warn("trustedExternalIssuer for JWTs not configured (cross login via JWT cookie will fail)") + return errors.New("trustedExternalIssuer for JWTs not configured (cross login via JWT cookie will fail)") + } + } else { + log.Warn("config for JWTs not configured (cross login via JWT cookie will fail)") + return errors.New("config for JWTs not configured (cross login via JWT cookie will fail)") + } + + return nil +} + +func (ja *JWTCookieSessionAuthenticator) CanLogin( + user *User, + rw http.ResponseWriter, + r *http.Request) bool { + + if ja.publicKeyCrossLogin == nil || + ja.config == nil || + ja.config.TrustedExternalIssuer == "" { + + return false + } + + cookieName := "" + if ja.config != nil && ja.config.CookieName != "" { + cookieName = ja.config.CookieName + } + + // Try to read the JWT cookie + if cookieName != "" { + jwtCookie, err := r.Cookie(cookieName) + + if err == nil && jwtCookie.Value != "" { + return true + } + } + + return false +} + +func (ja *JWTCookieSessionAuthenticator) Login( + user *User, + rw http.ResponseWriter, + r *http.Request) (*User, error) { + + jwtCookie, err := r.Cookie(ja.config.CookieName) + var rawtoken string + + if err == nil && jwtCookie.Value != "" { + rawtoken = jwtCookie.Value + } + + token, err := jwt.Parse(rawtoken, func(t *jwt.Token) (interface{}, error) { + if t.Method != jwt.SigningMethodEdDSA { + return nil, errors.New("only Ed25519/EdDSA supported") + } + + unvalidatedIssuer, success := t.Claims.(jwt.MapClaims)["iss"].(string) + if success && unvalidatedIssuer == ja.config.TrustedExternalIssuer { + // The (unvalidated) issuer seems to be the expected one, + // use public cross login key from config + return ja.publicKeyCrossLogin, nil + } + + // No cross login key configured or issuer not expected + // Try own key + return ja.publicKey, nil + }) + if err != nil { + log.Warn("Error while parsing token") + return nil, err + } + + // Check token validity and extract paypload + if err := token.Claims.Valid(); err != nil { + log.Warn("jwt token claims are not valid") + return nil, err + } + + claims := token.Claims.(jwt.MapClaims) + sub, _ := claims["sub"].(string) + exp, _ := claims["exp"].(float64) + + var name string + if val, ok := claims["name"]; ok { + name, _ = val.(string) + } + + var roles []string + + if ja.config.ForceJWTValidationViaDatabase { + // Deny any logins for unknown usernames + if user == nil { + log.Warn("Could not find user from JWT in internal database.") + return nil, errors.New("unknown user") + } + + // Take user roles from database instead of trusting the JWT + roles = user.Roles + } else { + // Extract roles from JWT (if present) + if rawroles, ok := claims["roles"].([]interface{}); ok { + for _, rr := range rawroles { + if r, ok := rr.(string); ok { + roles = append(roles, r) + } + } + } + } + + // (Ask browser to) Delete JWT cookie + deletedCookie := &http.Cookie{ + Name: ja.config.CookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + } + http.SetCookie(rw, deletedCookie) + + if user == nil { + user = &User{ + Username: sub, + Name: name, + Roles: roles, + AuthType: AuthSession, + AuthSource: AuthViaToken, + } + } + + user.Expiration = time.Unix(int64(exp), 0) + return user, nil +} diff --git a/internal/auth/jwtSession.go b/internal/auth/jwtSession.go new file mode 100644 index 0000000..7c4f6f0 --- /dev/null +++ b/internal/auth/jwtSession.go @@ -0,0 +1,103 @@ +// Copyright (C) 2022 NHR@FAU, University Erlangen-Nuremberg. +// All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. +package auth + +import ( + "encoding/base64" + "fmt" + "net/http" + "os" + "strings" + "time" + + "github.com/ClusterCockpit/cc-backend/pkg/log" + "github.com/golang-jwt/jwt/v4" +) + +type JWTSessionAuthenticator struct { + auth *Authentication + + loginTokenKey []byte // HS256 key +} + +var _ Authenticator = (*JWTSessionAuthenticator)(nil) + +func (ja *JWTSessionAuthenticator) Init(auth *Authentication, conf interface{}) error { + + ja.auth = auth + + if pubKey := os.Getenv("CROSS_LOGIN_JWT_HS512_KEY"); pubKey != "" { + bytes, err := base64.StdEncoding.DecodeString(pubKey) + if err != nil { + log.Warn("Could not decode cross login JWT HS512 key") + return err + } + ja.loginTokenKey = bytes + } + + return nil +} + +func (ja *JWTSessionAuthenticator) CanLogin( + user *User, + rw http.ResponseWriter, + r *http.Request) bool { + + return r.Header.Get("Authorization") != "" +} + +func (ja *JWTSessionAuthenticator) Login( + user *User, + rw http.ResponseWriter, + r *http.Request) (*User, error) { + + rawtoken := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + token, err := jwt.Parse(rawtoken, func(t *jwt.Token) (interface{}, error) { + if t.Method == jwt.SigningMethodHS256 || t.Method == jwt.SigningMethodHS512 { + return ja.loginTokenKey, nil + } + return nil, fmt.Errorf("AUTH/JWT > unkown signing method for login token: %s (known: HS256, HS512, EdDSA)", t.Method.Alg()) + }) + if err != nil { + log.Warn("Error while parsing jwt token") + return nil, err + } + + if err = token.Claims.Valid(); err != nil { + log.Warn("jwt token claims are not valid") + return nil, err + } + + claims := token.Claims.(jwt.MapClaims) + sub, _ := claims["sub"].(string) + exp, _ := claims["exp"].(float64) + + var name string + if val, ok := claims["name"]; ok { + name, _ = val.(string) + } + + var roles []string + if rawroles, ok := claims["roles"]; ok { + for _, r := range rawroles.([]string) { + if isValidRole(r) { + roles = append(roles, r) + } + } + } + + if user == nil { + user = &User{ + Username: sub, + Name: name, + Roles: roles, + AuthType: AuthSession, + AuthSource: AuthViaToken, + } + } + + user.Expiration = time.Unix(int64(exp), 0) + return user, nil +} diff --git a/internal/auth/ldap.go b/internal/auth/ldap.go index 81b0d93..fc9753d 100644 --- a/internal/auth/ldap.go +++ b/internal/auth/ldap.go @@ -1,4 +1,4 @@ -// Copyright (C) 2022 NHR@FAU, University Erlangen-Nuremberg. +// Copyright (C) 2023 NHR@FAU, University Erlangen-Nuremberg. // All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. @@ -93,13 +93,6 @@ func (la *LdapAuthenticator) Login( return user, nil } -func (la *LdapAuthenticator) Auth( - rw http.ResponseWriter, - r *http.Request) (*User, error) { - - return la.auth.AuthViaSession(rw, r) -} - func (la *LdapAuthenticator) Sync() error { const IN_DB int = 1 diff --git a/internal/auth/local.go b/internal/auth/local.go index 29996a4..8854aff 100644 --- a/internal/auth/local.go +++ b/internal/auth/local.go @@ -46,10 +46,3 @@ func (la *LocalAuthenticator) Login( return user, nil } - -func (la *LocalAuthenticator) Auth( - rw http.ResponseWriter, - r *http.Request) (*User, error) { - - return la.auth.AuthViaSession(rw, r) -} diff --git a/internal/auth/roles.go b/internal/auth/roles.go new file mode 100644 index 0000000..e352df1 --- /dev/null +++ b/internal/auth/roles.go @@ -0,0 +1,165 @@ +// Copyright (C) 2023 NHR@FAU, University Erlangen-Nuremberg. +// All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. +package auth + +import ( + "fmt" + "strings" +) + +type Role int + +const ( + RoleAnonymous Role = iota + RoleApi + RoleUser + RoleManager + RoleSupport + RoleAdmin + RoleError +) + +func GetRoleString(roleInt Role) string { + return [6]string{"anonymous", "api", "user", "manager", "support", "admin"}[roleInt] +} + +func getRoleEnum(roleStr string) Role { + switch strings.ToLower(roleStr) { + case "admin": + return RoleAdmin + case "support": + return RoleSupport + case "manager": + return RoleManager + case "user": + return RoleUser + case "api": + return RoleApi + case "anonymous": + return RoleAnonymous + default: + return RoleError + } +} + +func isValidRole(role string) bool { + return getRoleEnum(role) != RoleError +} + +func (u *User) HasValidRole(role string) (hasRole bool, isValid bool) { + if isValidRole(role) { + for _, r := range u.Roles { + if r == role { + return true, true + } + } + return false, true + } + return false, false +} + +func (u *User) HasRole(role Role) bool { + for _, r := range u.Roles { + if r == GetRoleString(role) { + return true + } + } + return false +} + +// Role-Arrays are short: performance not impacted by nested loop +func (u *User) HasAnyRole(queryroles []Role) bool { + for _, ur := range u.Roles { + for _, qr := range queryroles { + if ur == GetRoleString(qr) { + return true + } + } + } + return false +} + +// Role-Arrays are short: performance not impacted by nested loop +func (u *User) HasAllRoles(queryroles []Role) bool { + target := len(queryroles) + matches := 0 + for _, ur := range u.Roles { + for _, qr := range queryroles { + if ur == GetRoleString(qr) { + matches += 1 + break + } + } + } + + if matches == target { + return true + } else { + return false + } +} + +// Role-Arrays are short: performance not impacted by nested loop +func (u *User) HasNotRoles(queryroles []Role) bool { + matches := 0 + for _, ur := range u.Roles { + for _, qr := range queryroles { + if ur == GetRoleString(qr) { + matches += 1 + break + } + } + } + + if matches == 0 { + return true + } else { + return false + } +} + +// Called by API endpoint '/roles/' from frontend: Only required for admin config -> Check Admin Role +func GetValidRoles(user *User) ([]string, error) { + var vals []string + if user.HasRole(RoleAdmin) { + for i := RoleApi; i < RoleError; i++ { + vals = append(vals, GetRoleString(i)) + } + return vals, nil + } + + return vals, fmt.Errorf("%s: only admins are allowed to fetch a list of roles", user.Username) +} + +// Called by routerConfig web.page setup in backend: Only requires known user +func GetValidRolesMap(user *User) (map[string]Role, error) { + named := make(map[string]Role) + if user.HasNotRoles([]Role{RoleAnonymous}) { + for i := RoleApi; i < RoleError; i++ { + named[GetRoleString(i)] = i + } + return named, nil + } + return named, fmt.Errorf("only known users are allowed to fetch a list of roles") +} + +// Find highest role +func (u *User) GetAuthLevel() Role { + if u.HasRole(RoleAdmin) { + return RoleAdmin + } else if u.HasRole(RoleSupport) { + return RoleSupport + } else if u.HasRole(RoleManager) { + return RoleManager + } else if u.HasRole(RoleUser) { + return RoleUser + } else if u.HasRole(RoleApi) { + return RoleApi + } else if u.HasRole(RoleAnonymous) { + return RoleAnonymous + } else { + return RoleError + } +} diff --git a/internal/repository/testdata/job.db b/internal/repository/testdata/job.db index 9b37f8418f890cccde8d9ad665b8b75878a72e65..4b00aa55f041b70f717177bc7baef9eb69d1a226 100644 GIT binary patch delta 850 zcmZp8z}C>fK0#Vggn@xU28g+Um z5Cs=kH^)$)5C!MZpdeSjkoXW!U)SIeN8fZ**a45ISJr6rj;Nr`zW<(VlZ8Ab+1#<~U;x`u`d2If|#mR3dvivk=b2C%a- z0*&L`EZFd#e_{aZq6HI7c!6QX$*9f1Kb2pNDF70%&_LBCDquw>cUX9_0ZsA7AI?A( z0~^jPyuD1XH#al7bFl!^lI!Gi+~yz#*Je&0V@6!ze`}iqBa6afmIj6GEDel*^yPSg z0m#jLlZpQ(e*?cI-*dh;J`>(+yp6n~JR;nnC|SqN4-5%LhRnQ_)QaN597~zWXSgl6 zB^qm_*~Qh>8Jo=~^Yh3nh&uGyACh8cQ3Nz@%%kY9k8S5GNr}C@u#qpW*+VJx8 zuyUW`vf$!p$zpM4e#N|)IgsfC(>x{z#@CGXlLZA@CjaJ6-!8hB@io(CUA9XcT#XfM z?BbG=jLqJYO&R?suVY*^`9GgJSEDNnyST70V^iVeD5j3dJj`a()$AE>Z4&s;&&aY# z0ZbaO39!6n;D5VWP~b7YuplD~gQ$FINoGz`VqQvlW=cuMq5y|&1&k~Ln^_k8={Ep6 z76h1pSOSQ}p%}y#1Y!Xo<_BUvAO?AiWxK%w#tZ!092i*?)C8H9WA!EHHqLxbS&mB_ ztxVULmIED9%fzND#3t@2J^lWEMj89O)be=4to)>SAVaKMN041yTAFd1eo10dPAXIw zN{KoLxjKfpDug&X`M4@T1viJX)v`)@3$Tf+3Nz+r=B1=ofMk*?`CqJ9Gx-et<^!|N}qKpF5m+oWKVb%=boBnbiqulf*K*8xU z`x)gJd8b?NXOx_5!YVpFb3da5BhTdZtP;~h_A?4iU$dW4ck?A~U1kzv3!Ff>*cLJH mALTRV