From df9fd77d06f2cda5c1353d614341659682abca30 Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Wed, 5 Jul 2023 09:50:44 +0200 Subject: [PATCH 1/5] Refactor auth and add docs Cleanup and reformat --- docs/dev-authentication.md | 156 +++++++++++++++++++++++++++++++++++++ internal/auth/auth.go | 14 ++-- internal/auth/jwt.go | 19 ++--- 3 files changed, 171 insertions(+), 18 deletions(-) create mode 100644 docs/dev-authentication.md diff --git a/docs/dev-authentication.md b/docs/dev-authentication.md new file mode 100644 index 0000000..5514c96 --- /dev/null +++ b/docs/dev-authentication.md @@ -0,0 +1,156 @@ +# 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` +an interface is defined that any authentication provider must fullfil. 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 +* `Login()` Handle POST request to login user and start a new session +* `Auth()` Authenticate user and put User Object in context of the request + +The http router calls auth in the following cases: +* `r.Handle("/login", authentication.Login( ... )).Methods(http.MethodPost)`: + The POST request on the `/login` route will call the Login callback. +* Any route in the secured subrouter will always call Auth(), on success it will + call the next handler in the chain, on falure it will render the login + template. +``` +secured.Use(func(next http.Handler) http.Handler { + return authentication.Auth( + // On success; + next, + + // On failure: + func(rw http.ResponseWriter, r *http.Request, err error) { + // Render login form + }) +}) +``` + +For non API routes a JWT token can be used to initate an authenticated user +session. This can either happen by calling the login/ route with a token +provided in a header or the query URL or via the `Auth()` method on first access +to a secured URL via aspecial cookie containing the JWT token. +For API routes the access is authenticated on every request using the JWT token +and no session is initiated. + +# Login + +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 obejct 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 + object is returned. + - Creates a new session object, stores the user attributes in the session and + saves the session. + - Calls the `onSuccess` http handler + +## Local authenticator + +This authenticator is applied if +``` +return user != nil && user.AuthSource == AuthViaLocalPassword +``` + +Compares the password provided by the login form to the password hash stored in +the user database table: +``` +if e := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(r.FormValue("password"))); e != nil { + log.Errorf("AUTH/LOCAL > Authentication for user %s failed!", user.Username) + return nil, fmt.Errorf("AUTH/LOCAL > Authentication failed") +} +``` + +## LDAP authenticator + +This authenticator is applied if +``` +return user != nil && user.AuthSource == AuthViaLDAP +``` + +Gets the LDAP connection and tries a bind with the provided credentials: +``` +if err := l.Bind(userDn, r.FormValue("password")); err != nil { + log.Errorf("AUTH/LOCAL > Authentication for user %s failed: %v", user.Username, err) + return nil, fmt.Errorf("AUTH/LDAP > Authentication failed") +} +``` + +## JWT 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: +``` +return (user != nil && user.AuthSource == AuthViaToken) || + r.Header.Get("Authorization") != "" || + r.URL.Query().Get("login-token") != "" +``` + +The Login function: +* Parses the token +* Check if the signing method is EdDSA or HS256 or HS512 +* Check if claims are valid and extracts the claims +* The following claims have to be present: + - `sub`: The subject, in this case this is the username + - `exp`: Expiration in Unix epoch time + - `roles`: String array with roles of user +* In case user is not yet set, which is usually the case: + - Try to fetch user from database + - In case user is not yet present add user to user databse table with `AuthViaToken` AuthSource. +* 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 iterates over all authenticators +* Calls `Auth()` on every authenticator +* If err is not nil and the user object is valid it puts the user object in the + request context and starts the onsuccess http handler +* Otherwise it calls the onfailure handler + +## Local + +Calls the `AuthViaSession()` function in `auth.go`. This will extract username, +projects and roles from the session and initialize a user object with those +values. + +## LDAP + +Calls the `AuthViaSession()` function in `auth.go`. This will extract username, +projects and roles from the session and initialize a user object with those +values. + +# JWT + +Check for JWT token: +* Is token passed in the `X-Auth-Token` or `Authorization` header +* If no token is found in a header it tries to read the token from a configured +cookie. + +Finally it calls AuthViaSession in `auth.go` if a valid session exists. This is +true if a JWT token was previously used to initiate a session. In this case the +user object initialized with the session is returned rightaway. + +In case a token was found extract and parse the token: +* Check if signing method is Ed25519/EdDSA +* In case publicKeyCrossLogin is configured: + - Check if `iss` issuer claim matched trusted issuer from configuration + - Return public cross login key + - Otherwise return standard public key +* Check if claims are valid +* Depending on the option `ForceJWTValidationViaDatabase ` the roles are + extracted from JWT token or taken from user object fetched from database +* In case the token was extracted from cookie create a new session and ask the + browser to delete the JWT cookie +* Return valid user object + diff --git a/internal/auth/auth.go b/internal/auth/auth.go index ea6c9f2..4a8b775 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -75,10 +75,7 @@ func getRoleEnum(roleStr string) Role { } func isValidRole(role string) bool { - if getRoleEnum(role) == RoleError { - return false - } - return true + return getRoleEnum(role) != RoleError } func (u *User) HasValidRole(role string) (hasRole bool, isValid bool) { @@ -175,7 +172,7 @@ func GetValidRolesMap(user *User) (map[string]Role, error) { } return named, nil } - return named, fmt.Errorf("Only known users are allowed to fetch a list of roles") + return named, fmt.Errorf("only known users are allowed to fetch a list of roles") } // Find highest role @@ -300,6 +297,7 @@ func (auth *Authentication) AuthViaSession( 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) @@ -320,11 +318,9 @@ func (auth *Authentication) Login( err := errors.New("no authenticator applied") username := r.FormValue("username") user := (*User)(nil) + if username != "" { - if user, _ = auth.GetUser(username); err != nil { - // log.Warnf("login of unkown user %v", username) - _ = err - } + user, _ = auth.GetUser(username) } for _, authenticator := range auth.authenticators { diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go index 7a80c1c..09e12e4 100644 --- a/internal/auth/jwt.go +++ b/internal/auth/jwt.go @@ -103,7 +103,9 @@ func (ja *JWTAuthenticator) CanLogin( rw http.ResponseWriter, r *http.Request) bool { - return (user != nil && user.AuthSource == AuthViaToken) || r.Header.Get("Authorization") != "" || r.URL.Query().Get("login-token") != "" + return (user != nil && user.AuthSource == AuthViaToken) || + r.Header.Get("Authorization") != "" || + r.URL.Query().Get("login-token") != "" } func (ja *JWTAuthenticator) Login( @@ -111,13 +113,9 @@ func (ja *JWTAuthenticator) Login( rw http.ResponseWriter, r *http.Request) (*User, error) { - rawtoken := r.Header.Get("X-Auth-Token") + rawtoken := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") if rawtoken == "" { - rawtoken = r.Header.Get("Authorization") - rawtoken = strings.TrimPrefix(rawtoken, "Bearer ") - if rawtoken == "" { - rawtoken = r.URL.Query().Get("login-token") - } + rawtoken = r.URL.Query().Get("login-token") } token, err := jwt.Parse(rawtoken, func(t *jwt.Token) (interface{}, error) { @@ -134,7 +132,7 @@ func (ja *JWTAuthenticator) Login( return nil, err } - if err := token.Claims.Valid(); err != nil { + if err = token.Claims.Valid(); err != nil { log.Warn("jwt token claims are not valid") return nil, err } @@ -220,7 +218,10 @@ func (ja *JWTAuthenticator) Auth( } // Is there more than one public key? - if ja.publicKeyCrossLogin != nil && ja.config != nil && ja.config.TrustedExternalIssuer != "" { + 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 { From c5633e9e6d59a4e7b8bc63dc5c3ae4f53a7f059b Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Wed, 5 Jul 2023 10:01:46 +0200 Subject: [PATCH 2/5] Remove typos --- docs/dev-authentication.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/dev-authentication.md b/docs/dev-authentication.md index 5514c96..b0f7363 100644 --- a/docs/dev-authentication.md +++ b/docs/dev-authentication.md @@ -2,7 +2,7 @@ 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` -an interface is defined that any authentication provider must fullfil. 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 providers. @@ -15,7 +15,7 @@ The http router calls auth in the following cases: * `r.Handle("/login", authentication.Login( ... )).Methods(http.MethodPost)`: The POST request on the `/login` route will call the Login callback. * Any route in the secured subrouter will always call Auth(), on success it will - call the next handler in the chain, on falure it will render the login + call the next handler in the chain, on failure it will render the login template. ``` secured.Use(func(next http.Handler) http.Handler { @@ -30,10 +30,10 @@ secured.Use(func(next http.Handler) http.Handler { }) ``` -For non API routes a JWT token can be used to initate an authenticated user +For non API routes 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 the query URL or via the `Auth()` method on first access -to a secured URL via aspecial 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 and no session is initiated. @@ -41,7 +41,7 @@ and no session is initiated. 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 obejct is set to nil. + 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. @@ -49,7 +49,7 @@ The Login function (located in `auth.go`): object is returned. - Creates a new session object, stores the user attributes in the session and saves the session. - - Calls the `onSuccess` http handler + - Starts the `onSuccess` http handler ## Local authenticator @@ -105,7 +105,7 @@ The Login function: - `roles`: String array with roles of user * In case user is not yet set, which is usually the case: - Try to fetch user from database - - In case user is not yet present add user to user databse table with `AuthViaToken` AuthSource. + - In case user is not yet present add user to user database table with `AuthViaToken` AuthSource. * Return valid user object # Auth @@ -115,8 +115,8 @@ The Auth function (located in `auth.go`): * This handler iterates over all authenticators * Calls `Auth()` on every authenticator * If err is not nil and the user object is valid it puts the user object in the - request context and starts the onsuccess http handler -* Otherwise it calls the onfailure handler + request context and starts the onSuccess http handler +* Otherwise it calls the onFailure handler ## Local @@ -139,7 +139,7 @@ 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 rightaway. +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 From b25ceccae993bcf2fdb1f4d0eead4ba939f1bbfa Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Wed, 5 Jul 2023 10:15:12 +0200 Subject: [PATCH 3/5] Minor typos --- docs/dev-authentication.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/dev-authentication.md b/docs/dev-authentication.md index b0f7363..4237a6a 100644 --- a/docs/dev-authentication.md +++ b/docs/dev-authentication.md @@ -31,8 +31,8 @@ secured.Use(func(next http.Handler) http.Handler { ``` For non API routes 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 the query URL or via the `Auth()` method on first access +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. For API routes the access is authenticated on every request using the JWT token and no session is initiated. From 04e8279ae449fae2eeb7fe0f2bfc20ac65c87c23 Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Wed, 19 Jul 2023 09:04:27 +0200 Subject: [PATCH 4/5] Change log level for JWT Cross login warning to debug --- internal/auth/jwt.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go index 09e12e4..8df7017 100644 --- a/internal/auth/jwt.go +++ b/internal/auth/jwt.go @@ -92,7 +92,7 @@ func (ja *JWTAuthenticator) Init(auth *Authentication, conf interface{}) error { } } else { ja.publicKeyCrossLogin = nil - log.Warn("environment variable 'CROSS_LOGIN_JWT_PUBLIC_KEY' not set (cross login token based authentication will not work)") + log.Debug("environment variable 'CROSS_LOGIN_JWT_PUBLIC_KEY' not set (cross login token based authentication will not work)") } return nil From 592307019137789da4d0242ec0ad8070b40c33ea Mon Sep 17 00:00:00 2001 From: Jan Eitzinger Date: Wed, 19 Jul 2023 09:04:46 +0200 Subject: [PATCH 5/5] make distclean target phony --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 455ce3c..23d406d 100644 --- a/Makefile +++ b/Makefile @@ -28,7 +28,7 @@ SVELTE_SRC = $(wildcard $(FRONTEND)/src/*.svelte) \ $(wildcard $(FRONTEND)/src/plots/*.svelte) \ $(wildcard $(FRONTEND)/src/joblist/*.svelte) -.PHONY: clean test tags frontend $(TARGET) +.PHONY: clean distclean test tags frontend $(TARGET) .NOTPARALLEL: