From f817ac5240e68b4a3d04d48acb79185903c63abd Mon Sep 17 00:00:00 2001 From: Michael Schwarz Date: Wed, 19 Oct 2022 13:36:13 +0200 Subject: [PATCH] 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 --- configs/config.json | 8 +- configs/env-template.txt | 4 + docs/JWT-Handling.md | 36 +++++++ internal/auth/jwt.go | 123 ++++++++++++++++++++-- pkg/schema/config.go | 10 ++ tools/convert-pem-pubkey-for-cc/Readme.md | 25 +++++ tools/convert-pem-pubkey-for-cc/dummy.pub | 3 + tools/convert-pem-pubkey-for-cc/main.go | 81 ++++++++++++++ 8 files changed, 283 insertions(+), 7 deletions(-) create mode 100644 tools/convert-pem-pubkey-for-cc/Readme.md create mode 100644 tools/convert-pem-pubkey-for-cc/dummy.pub create mode 100644 tools/convert-pem-pubkey-for-cc/main.go diff --git a/configs/config.json b/configs/config.json index 3384630..6c838c5 100644 --- a/configs/config.json +++ b/configs/config.json @@ -29,5 +29,11 @@ "startTime": { "from": "2022-01-01T00:00:00Z", "to": null } } } - ] + ], + "jwts": { + "cookieName": "", + "forceJWTValidationViaDatabase": false, + "max-age": 0, + "trustedExternalIssuer": "" + } } diff --git a/configs/env-template.txt b/configs/env-template.txt index a33da3e..35a4634 100644 --- a/configs/env-template.txt +++ b/configs/env-template.txt @@ -3,6 +3,10 @@ JWT_PUBLIC_KEY="kzfYrYy+TzpanWZHJ5qSdMj5uKUWgq74BWhQG6copP0=" JWT_PRIVATE_KEY="dtPC/6dWJFKZK7KZ78CvWuynylOmjBFyMsUWArwmodOTN9itjL5POlqdZkcnmpJ0yPm4pRaCrvgFaFAbpyik/Q==" +# Base64 encoded Ed25519 public key for accepting externally generated JWTs +# Keys in PEM format can be converted, see `tools/convert-pem-pubkey-for-cc/Readme.md` +CROSS_LOGIN_JWT_PUBLIC_KEY="" + # Some random bytes used as secret for cookie-based sessions (DO NOT USE THIS ONE IN PRODUCTION) SESSION_KEY="67d829bf61dc5f87a73fd814e2c9f629" diff --git a/docs/JWT-Handling.md b/docs/JWT-Handling.md index bdb6367..8b03246 100644 --- a/docs/JWT-Handling.md +++ b/docs/JWT-Handling.md @@ -44,3 +44,39 @@ $ ./cc-backend -jwt -no-server ``` $ curl -X GET "" -H "accept: application/json" -H "Content-Type: application/json" -H "Authorization: Bearer " ``` + +## 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: + +1. `.env`: CC needs a public ed25519 key to verify foreign JWT signatures. Public keys in PEM format can be converted with the instructions in [/tools/convert-pem-pubkey-for-cc](../tools/convert-pem-pubkey-for-cc/Readme.md) . + +``` +CROSS_LOGIN_JWT_PUBLIC_KEY="+51iXX8BdLFocrppRxIw52xCOf8xFSH/eNilN5IHVGc=" +``` + +2. `config.json`: Insert a name for the cookie (set by the external service) containing the JWT so that CC knows where to look at. Define a trusted issuer (JWT claim 'iss'), otherwise it will be rejected. +If you want usernames and user roles from JWTs ('sub' and 'roles' claim) to be validated against CC's internal database, you need to enable it here. Unknown users will then be rejected and roles set via JWT will be ignored. + +```json +"jwts": { + "cookieName": "access_cc", + "forceJWTValidationViaDatabase": true, + "trustedExternalIssuer": "auth.example.com" +} +``` + +3. Make sure your external service includes the same issuer (`iss`) in its JWTs. Example JWT payload: + +```json +{ + "iat": 1668161471, + "nbf": 1668161471, + "exp": 1668161531, + "sub": "alice", + "roles": [ + "user" + ], + "jti": "a1b2c3d4-1234-5678-abcd-a1b2c3d4e5f6", + "iss": "auth.example.com" +} +``` diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go index 949fa6f..ccc8240 100644 --- a/internal/auth/jwt.go +++ b/internal/auth/jwt.go @@ -23,8 +23,9 @@ import ( 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 @@ -62,6 +63,34 @@ func (ja *JWTAuthenticator) Init(auth *Authentication, conf interface{}) error { 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 { + 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.Warn("environment variable 'CROSS_LOGIN_JWT_PUBLIC_KEY' not set (cross login token based authentication will not work)") + } + return nil } @@ -149,38 +178,120 @@ 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 { return nil, err } + // Check token validity if err := token.Claims.Valid(); err != nil { return nil, err } + // Token is valid, extract payload claims := token.Claims.(jwt.MapClaims) sub, _ := claims["sub"].(string) var roles []string - if rawroles, ok := claims["roles"].([]interface{}); ok { - for _, rr := range rawroles { - if r, ok := rr.(string); ok { - roles = append(roles, r) + + // Validate user + roles from JWT against database? + if ja.config != nil && ja.config.ForceJWTValidationViaDatabase { + user, err := ja.auth.GetUser(sub) + + // Deny any logins for unknown usernames + if err != 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) + } } } } + 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.Errorf("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, diff --git a/pkg/schema/config.go b/pkg/schema/config.go index 9aa8697..11bd1a9 100644 --- a/pkg/schema/config.go +++ b/pkg/schema/config.go @@ -23,6 +23,16 @@ type JWTAuthConfig struct { // Specifies for how long a session or JWT shall be valid // as a string parsable by time.ParseDuration(). MaxAge int64 `json:"max-age"` + + // Specifies which cookie should be checked for a JWT token (if no authorization header is present) + CookieName string `json:"cookieName"` + + // Deny login for users not in database (but defined in JWT). + // Ignore user roles defined in JWTs ('roles' claim), get them from db. + ForceJWTValidationViaDatabase bool `json:"forceJWTValidationViaDatabase"` + + // Specifies which issuer should be accepted when validating external JWTs ('iss' claim) + TrustedExternalIssuer string `json:"trustedExternalIssuer"` } type IntRange struct { diff --git a/tools/convert-pem-pubkey-for-cc/Readme.md b/tools/convert-pem-pubkey-for-cc/Readme.md new file mode 100644 index 0000000..1429acc --- /dev/null +++ b/tools/convert-pem-pubkey-for-cc/Readme.md @@ -0,0 +1,25 @@ +# Convert a public Ed25519 key (in PEM format) for use in ClusterCockpit + +Imagine you have externally generated JSON Web Tokens (JWT) that should be accepted by CC backend. This external provider shares its public key (used for JWT signing) in PEM format: + +``` +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEA+51iXX8BdLFocrppRxIw52xCOf8xFSH/eNilN5IHVGc= +-----END PUBLIC KEY----- +``` + +Unfortunately, ClusterCockpit does not handle this format (yet). You can use this tool to convert the public PEM key into a representation for CC: + +``` +CROSS_LOGIN_JWT_PUBLIC_KEY="+51iXX8BdLFocrppRxIw52xCOf8xFSH/eNilN5IHVGc=" +``` + +Instructions + +- `cd tools/convert-pem-pubkey-for-cc/` +- Insert your public ed25519 PEM key into `dummy.pub` +- `go run . dummy.pub` +- Copy the result into ClusterCockpit's `.env` +- (Re)start ClusterCockpit backend + +Now CC can validate generated JWTs from the external provider. diff --git a/tools/convert-pem-pubkey-for-cc/dummy.pub b/tools/convert-pem-pubkey-for-cc/dummy.pub new file mode 100644 index 0000000..a3ba5fc --- /dev/null +++ b/tools/convert-pem-pubkey-for-cc/dummy.pub @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEA+51iXX8BdLFocrppRxIw52xCOf8xFSH/eNilN5IHVGc= +-----END PUBLIC KEY----- \ No newline at end of file diff --git a/tools/convert-pem-pubkey-for-cc/main.go b/tools/convert-pem-pubkey-for-cc/main.go new file mode 100644 index 0000000..97504c2 --- /dev/null +++ b/tools/convert-pem-pubkey-for-cc/main.go @@ -0,0 +1,81 @@ +// Copyright (C) 2022 Paderborn Center for Parallel Computing, Paderborn University +// This code is released under MIT License: +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +package main + +import ( + "crypto/ed25519" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "os" +) + +func main() { + filepath := "" + if len(os.Args) > 1 { + filepath = os.Args[1] + } else { + PrintUsage() + os.Exit(1) + } + + pubkey, err := LoadEd255519PubkeyFromPEMFile(filepath) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %s\n", err.Error()) + os.Exit(1) + } + + fmt.Fprintf(os.Stdout, + "CROSS_LOGIN_JWT_PUBLIC_KEY=%#v\n", + base64.StdEncoding.EncodeToString(pubkey)) +} + +// Loads an ed25519 public key stored in a file in PEM format +func LoadEd255519PubkeyFromPEMFile(filePath string) (ed25519.PublicKey, error) { + buffer, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } + + block, _ := pem.Decode(buffer) + if block == nil { + return nil, fmt.Errorf("no pem block found") + } + + pubkey, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, err + } + + ed25519PublicKey, success := pubkey.(ed25519.PublicKey) + if !success { + return nil, fmt.Errorf("not an ed25519 key") + } + + return ed25519PublicKey, nil +} + +func PrintUsage() { + fmt.Fprintf(os.Stderr, "Usage: %s \n", os.Args[0]) + fmt.Fprintf(os.Stderr, "where contains an Ed25519 public key in PEM format\n") + fmt.Fprintf(os.Stderr, "(starting with '-----BEGIN PUBLIC KEY-----')\n") +}