mirror of
https://github.com/ClusterCockpit/cc-backend
synced 2024-12-25 12:59:06 +01:00
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
This commit is contained in:
parent
7814a184a1
commit
f817ac5240
@ -29,5 +29,11 @@
|
||||
"startTime": { "from": "2022-01-01T00:00:00Z", "to": null }
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"jwts": {
|
||||
"cookieName": "",
|
||||
"forceJWTValidationViaDatabase": false,
|
||||
"max-age": 0,
|
||||
"trustedExternalIssuer": ""
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
|
||||
|
@ -44,3 +44,39 @@ $ ./cc-backend -jwt <username> -no-server
|
||||
```
|
||||
$ curl -X GET "<API ENDPOINT>" -H "accept: application/json" -H "Content-Type: application/json" -H "Authorization: Bearer <JWT TOKEN>"
|
||||
```
|
||||
|
||||
## 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"
|
||||
}
|
||||
```
|
||||
|
@ -25,6 +25,7 @@ type JWTAuthenticator struct {
|
||||
|
||||
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,30 +178,79 @@ 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
|
||||
|
||||
// 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 {
|
||||
@ -180,6 +258,39 @@ 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.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,
|
||||
|
@ -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 {
|
||||
|
25
tools/convert-pem-pubkey-for-cc/Readme.md
Normal file
25
tools/convert-pem-pubkey-for-cc/Readme.md
Normal file
@ -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.
|
3
tools/convert-pem-pubkey-for-cc/dummy.pub
Normal file
3
tools/convert-pem-pubkey-for-cc/dummy.pub
Normal file
@ -0,0 +1,3 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEA+51iXX8BdLFocrppRxIw52xCOf8xFSH/eNilN5IHVGc=
|
||||
-----END PUBLIC KEY-----
|
81
tools/convert-pem-pubkey-for-cc/main.go
Normal file
81
tools/convert-pem-pubkey-for-cc/main.go
Normal file
@ -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 <filename>\n", os.Args[0])
|
||||
fmt.Fprintf(os.Stderr, "where <filename> contains an Ed25519 public key in PEM format\n")
|
||||
fmt.Fprintf(os.Stderr, "(starting with '-----BEGIN PUBLIC KEY-----')\n")
|
||||
}
|
Loading…
Reference in New Issue
Block a user