diff --git a/CLAUDE.md b/CLAUDE.md index 658b0bde..6af1c9ae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -161,9 +161,15 @@ applied automatically on startup. Version tracking in `version` table. - `username`: Authentication username (optional) - `password`: Authentication password (optional) - `creds-file-path`: Path to NATS credentials file (optional) -- **.env**: Environment variables (secrets like JWT keys) - - Copy from `configs/env-template.txt` - - NEVER commit this file +- **Secrets** (JWT keys, LDAP sync password, OIDC client id/secret, cross-login + keys): configured directly in `config.json` under the `auth` section where they + are used (e.g. `auth.jwts.public-key`, `auth.jwts.private-key`, + `auth.ldap.sync-password`, `auth.oidc.client-id`/`client-secret`). + - Each secret may also be supplied via its environment variable + (`JWT_PUBLIC_KEY`, `JWT_PRIVATE_KEY`, `LDAP_ADMIN_PASSWORD`, `OID_CLIENT_ID`, + `OID_CLIENT_SECRET`, `CROSS_LOGIN_JWT_PUBLIC_KEY`, `CROSS_LOGIN_JWT_HS512_KEY`). + - The environment variable takes precedence over the value in `config.json`. + - The former `.env`/godotenv mechanism has been removed. - **cluster.json**: Cluster topology and metric definitions (loaded from archive or config) ## Database diff --git a/README.md b/README.md index 899651ad..a433f7b1 100644 --- a/README.md +++ b/README.md @@ -129,12 +129,11 @@ git clone https://github.com/ClusterCockpit/cc-backend.git cd ./cc-backend/ make -# EDIT THE .env FILE BEFORE YOU DEPLOY (Change the secrets)! -# If authentication is disabled, it can be empty. -cp configs/env-template.txt .env -vim .env - cp configs/config.json . +# EDIT config.json BEFORE YOU DEPLOY: change the secrets under "auth.jwts" +# ("public-key"/"private-key"). Each secret can also be supplied via an +# environment variable (e.g. JWT_PUBLIC_KEY), which takes precedence over the +# value in config.json. vim config.json #Optional: Link an existing job archive: @@ -157,8 +156,14 @@ ln -s ./var/job-archive Browser sessions are stored server-side in the SQLite database (the `sessions` table) using [`alexedwards/scs`](https://github.com/alexedwards/scs); only an opaque random token is kept in the session cookie. No cookie-signing secret is -required, so the former `SESSION_KEY` environment variable is no longer used and -can be removed from your `.env`. +required, so the former `SESSION_KEY` environment variable is no longer used. + +Secrets (JWT keys, LDAP sync password, OIDC client id/secret, cross-login keys) +are configured directly in `config.json` under the `auth` section. Each secret +may also be supplied via its environment variable (e.g. `JWT_PUBLIC_KEY`, +`JWT_PRIVATE_KEY`, `LDAP_ADMIN_PASSWORD`, `OID_CLIENT_ID`, `OID_CLIENT_SECRET`, +`CROSS_LOGIN_JWT_PUBLIC_KEY`, `CROSS_LOGIN_JWT_HS512_KEY`); the environment +variable takes precedence when set. The previous `.env` file is no longer used. The session cookie's `Secure` flag is set automatically when cc-backend serves HTTPS itself (i.e. `https-cert-file` and `https-key-file` are configured in diff --git a/cmd/cc-backend/cli.go b/cmd/cc-backend/cli.go index 3896383a..9d6f1bf9 100644 --- a/cmd/cc-backend/cli.go +++ b/cmd/cc-backend/cli.go @@ -17,7 +17,7 @@ var ( ) func cliInit() { - flag.BoolVar(&flagInit, "init", false, "Setup var directory, initialize sqlite database file, config.json and .env") + flag.BoolVar(&flagInit, "init", false, "Setup var directory, initialize sqlite database file and config.json") flag.BoolVar(&flagReinitDB, "init-db", false, "Go through job-archive and re-initialize the 'job', 'tag', and 'jobtag' tables (all running jobs will be lost!)") flag.BoolVar(&flagSyncLDAP, "sync-ldap", false, "Sync the 'hpc_user' table with ldap") flag.BoolVar(&flagServer, "server", false, "Start a server, continues listening on port after initialization and argument handling") diff --git a/cmd/cc-backend/init.go b/cmd/cc-backend/init.go index cdb185fc..b372dd02 100644 --- a/cmd/cc-backend/init.go +++ b/cmd/cc-backend/init.go @@ -18,13 +18,6 @@ import ( "github.com/ClusterCockpit/cc-lib/v2/util" ) -const envString = ` -# Base64 encoded Ed25519 keys (DO NOT USE THESE TWO IN PRODUCTION!) -# You can generate your own keypair using the gen-keypair tool -JWT_PUBLIC_KEY="kzfYrYy+TzpanWZHJ5qSdMj5uKUWgq74BWhQG6copP0=" -JWT_PRIVATE_KEY="dtPC/6dWJFKZK7KZ78CvWuynylOmjBFyMsUWArwmodOTN9itjL5POlqdZkcnmpJ0yPm4pRaCrvgFaFAbpyik/Q==" -` - const configString = ` { "main": { @@ -54,7 +47,9 @@ const configString = ` }, "auth": { "jwts": { - "max-age": "2000h" + "max-age": "2000h", + "public-key": "kzfYrYy+TzpanWZHJ5qSdMj5uKUWgq74BWhQG6copP0=", + "private-key": "dtPC/6dWJFKZK7KZ78CvWuynylOmjBFyMsUWArwmodOTN9itjL5POlqdZkcnmpJ0yPm4pRaCrvgFaFAbpyik/Q==" } } } @@ -69,10 +64,6 @@ func initEnv() { cclog.Abortf("Could not write default ./config.json with permissions '0o666'. Application initialization failed, exited.\nError: %s\n", err.Error()) } - if err := os.WriteFile(".env", []byte(envString), 0o666); err != nil { - cclog.Abortf("Could not write default ./.env file with permissions '0o666'. Application initialization failed, exited.\nError: %s\n", err.Error()) - } - if err := os.Mkdir("var", 0o777); err != nil { cclog.Abortf("Could not create default ./var folder with permissions '0o777'. Application initialization failed, exited.\nError: %s\n", err.Error()) } diff --git a/cmd/cc-backend/main.go b/cmd/cc-backend/main.go index b950e473..9a47b3d7 100644 --- a/cmd/cc-backend/main.go +++ b/cmd/cc-backend/main.go @@ -39,7 +39,6 @@ import ( "github.com/ClusterCockpit/cc-lib/v2/schema" "github.com/ClusterCockpit/cc-lib/v2/util" "github.com/google/gops/agent" - "github.com/joho/godotenv" _ "github.com/mattn/go-sqlite3" ) @@ -89,13 +88,6 @@ func initGops() error { return nil } -func loadEnvironment() error { - if err := godotenv.Load(); err != nil { - return fmt.Errorf("loading .env file: %w", err) - } - return nil -} - func initConfiguration() error { ccconf.Init(flagConfigFile) @@ -224,7 +216,14 @@ func checkDefaultSecurityKeys() { // Default JWT public key from init.go defaultJWTPublic := "kzfYrYy+TzpanWZHJ5qSdMj5uKUWgq74BWhQG6copP0=" - if os.Getenv("JWT_PUBLIC_KEY") == defaultJWTPublic { + // Resolve the public key the same way the authenticators do: environment + // variable takes precedence over the value configured in config.json. + pubKey := os.Getenv("JWT_PUBLIC_KEY") + if pubKey == "" && auth.Keys.JwtConfig != nil { + pubKey = auth.Keys.JwtConfig.PublicKey + } + + if pubKey == defaultJWTPublic { cclog.Warn("Using default JWT keys - not recommended for production environments") } } @@ -495,7 +494,7 @@ func run() error { if flagInit { initEnv() cclog.Exit("Successfully setup environment!\n" + - "Please review config.json and .env and adjust it to your needs.\n" + + "Please review config.json and adjust it to your needs.\n" + "Add your job-archive at ./var/job-archive.") } @@ -505,17 +504,12 @@ func run() error { } // Initialize subsystems in dependency order: - // 1. Load environment variables from .env file (contains sensitive configuration) - // 2. Load configuration from config.json (may reference environment variables) - // 3. Handle database migration commands if requested - // 4. Initialize database connection (requires config for connection string) - // 5. Handle user commands if requested (requires database and authentication config) - // 6. Initialize subsystems like archive and metrics (require config and database) - - // Load environment and configuration - if err := loadEnvironment(); err != nil { - return err - } + // 1. Load configuration from config.json (secrets live in config; individual + // secrets may be overridden via environment variables) + // 2. Handle database migration commands if requested + // 3. Initialize database connection (requires config for connection string) + // 4. Handle user commands if requested (requires database and authentication config) + // 5. Initialize subsystems like archive and metrics (require config and database) if err := initConfiguration(); err != nil { return err diff --git a/configs/config-demo.json b/configs/config-demo.json index 8c72e37f..76b365f8 100644 --- a/configs/config-demo.json +++ b/configs/config-demo.json @@ -9,7 +9,9 @@ }, "auth": { "jwts": { - "max-age": "2000h" + "max-age": "2000h", + "public-key": "kzfYrYy+TzpanWZHJ5qSdMj5uKUWgq74BWhQG6copP0=", + "private-key": "dtPC/6dWJFKZK7KZ78CvWuynylOmjBFyMsUWArwmodOTN9itjL5POlqdZkcnmpJ0yPm4pRaCrvgFaFAbpyik/Q==" } }, "metric-store-external": [ diff --git a/configs/config.json b/configs/config.json index 6b654e4f..13e1a7bc 100644 --- a/configs/config.json +++ b/configs/config.json @@ -30,7 +30,9 @@ }, "auth": { "jwts": { - "max-age": "2000h" + "max-age": "2000h", + "public-key": "kzfYrYy+TzpanWZHJ5qSdMj5uKUWgq74BWhQG6copP0=", + "private-key": "dtPC/6dWJFKZK7KZ78CvWuynylOmjBFyMsUWArwmodOTN9itjL5POlqdZkcnmpJ0yPm4pRaCrvgFaFAbpyik/Q==" } }, "cron": { diff --git a/configs/env-template.txt b/configs/env-template.txt deleted file mode 100644 index dd51b122..00000000 --- a/configs/env-template.txt +++ /dev/null @@ -1,11 +0,0 @@ -# Base64 encoded Ed25519 keys (DO NOT USE THESE TWO IN PRODUCTION!) -# You can generate your own keypair using `go run tools/gen-keypair/main.go` -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/Readme.md` -CROSS_LOGIN_JWT_PUBLIC_KEY="" - -# Password for the ldap server (optional) -LDAP_ADMIN_PASSWORD="mashup" diff --git a/go.mod b/go.mod index d1f3caa9..b3dc1a12 100644 --- a/go.mod +++ b/go.mod @@ -28,7 +28,6 @@ require ( github.com/golang-migrate/migrate/v4 v4.19.1 github.com/google/gops v0.3.29 github.com/jmoiron/sqlx v1.4.0 - github.com/joho/godotenv v1.5.1 github.com/mattn/go-sqlite3 v1.14.44 github.com/parquet-go/parquet-go v0.30.1 github.com/qustavo/sqlhooks/v2 v2.1.0 diff --git a/go.sum b/go.sum index 721a6731..d5689234 100644 --- a/go.sum +++ b/go.sum @@ -191,8 +191,6 @@ github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZ github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= diff --git a/init/README.md b/init/README.md index 5ecc9be6..a4c0a7a9 100644 --- a/init/README.md +++ b/init/README.md @@ -1,19 +1,19 @@ -# How to run `cc-backend` as a systemd service. +# How to run `cc-backend` as a systemd service The files in this directory assume that you install ClusterCockpit to `/opt/monitoring/cc-backend`. Of course you can choose any other location, but make sure you replace all paths starting with `/opt/monitoring/cc-backend` in the `clustercockpit.service` file! -The `config.json` may contain the optional fields *user* and *group*. If +The `config.json` may contain the optional fields _user_ and _group_. If specified, the application will call [setuid](https://man7.org/linux/man-pages/man2/setuid.2.html) and [setgid](https://man7.org/linux/man-pages/man2/setgid.2.html) after reading the config file and binding to a TCP port (so it can take a privileged port), but before it starts accepting any connections. This is good for security, but also -means that the `var/` directory must be readable and writeable by this user. -The `.env` and `config.json` files may contain secrets and should not be -readable by this user. If these files are changed, the server must be restarted. +means that the `var/` directory must be readable and writeable by this user. The +`config.json` file may contain secrets. If this file is changed, the server must +be restarted. ```sh # 1. Clone this repository somewhere in your home @@ -25,11 +25,9 @@ make sudo mkdir -p /opt/monitoring/cc-backend/ cp ./cc-backend /opt/monitoring/cc-backend/ -# 3. Modify the `./config.json` and env-template.txt file from the configs directory to your liking and put it in the target directory +# 3. Modify the `./config.json` file from the configs directory to your liking and put it in the target directory cp ./configs/config.json /opt/monitoring/config.json -cp ./configs/env-template.txt /opt/monitoring/.env -vim /opt/monitoring/config.json # do your thing... -vim /opt/monitoring/.env # do your thing... +vim /opt/monitoring/config.json # do your thing (including the secrets under "auth")... # 4. (Optional) Customization: Add your versions of the login view, legal texts, and logo image. # You may use the templates in `./web/templates` as blueprint. Every overwrite separate. @@ -57,8 +55,9 @@ It is recommended to install all ClusterCockpit components in a common directory In the following we use `/opt/monitoring`. Two systemd services run on the central monitoring server: -* clustercockpit : binary cc-backend in `/opt/monitoring/cc-backend`. -* cc-metric-store : Binary cc-metric-store in `/opt/monitoring/cc-metric-store`. + +- clustercockpit : binary cc-backend in `/opt/monitoring/cc-backend`. +- cc-metric-store : Binary cc-metric-store in `/opt/monitoring/cc-metric-store`. ClusterCockpit is deployed as a single binary that embeds all static assets. We recommend keeping all `cc-backend` binary versions in a folder `archive` and @@ -68,12 +67,13 @@ This allows for easy roll-back in case something doesn't work. ## Workflow to deploy new version This example assumes the DB and job archive versions did not change. -* Stop systemd service: `$ sudo systemctl stop clustercockpit.service` -* Backup the sqlite DB file and Job archive directory tree! -* Copy `cc-backend` binary to `/opt/monitoring/cc-backend/archive` (Tip: Use a -date tag like `YYYYMMDD-cc-backend`) -* Link from cc-backend root to current version -* Start systemd service: `$ sudo systemctl start clustercockpit.service` -* Check if everything is ok: `$ sudo systemctl status clustercockpit.service` -* Check log for issues: `$ sudo journalctl -u clustercockpit.service` -* Check the ClusterCockpit web frontend and your Slurm adapters if anything is broken! + +- Stop systemd service: `$ sudo systemctl stop clustercockpit.service` +- Backup the sqlite DB file and Job archive directory tree! +- Copy `cc-backend` binary to `/opt/monitoring/cc-backend/archive` (Tip: Use a + date tag like `YYYYMMDD-cc-backend`) +- Link from cc-backend root to current version +- Start systemd service: `$ sudo systemctl start clustercockpit.service` +- Check if everything is ok: `$ sudo systemctl status clustercockpit.service` +- Check log for issues: `$ sudo journalctl -u clustercockpit.service` +- Check the ClusterCockpit web frontend and your Slurm adapters if anything is broken! diff --git a/internal/auth/auth.go b/internal/auth/auth.go index c5b44eae..f8ce52fa 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -17,6 +17,7 @@ import ( "net" "net/http" "net/url" + "os" "sync" "time" @@ -115,6 +116,18 @@ type AuthConfig struct { // Keys holds the global authentication configuration var Keys AuthConfig +// secretFromEnv resolves a secret from the environment or config. The +// environment variable takes precedence when set and non-empty; otherwise the +// value configured in config.json is used. This lets deployments inject secrets +// via the environment (or a secret manager) while keeping config.json +// self-contained for simple setups. +func secretFromEnv(envVar, configValue string) string { + if v := os.Getenv(envVar); v != "" { + return v + } + return configValue +} + // Authentication manages all authentication methods and session handling type Authentication struct { sessionManager *scs.SessionManager diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go index abdce313..4709e9ea 100644 --- a/internal/auth/jwt.go +++ b/internal/auth/jwt.go @@ -10,7 +10,6 @@ import ( "encoding/base64" "errors" "net/http" - "os" "strings" "time" @@ -39,6 +38,23 @@ type JWTAuthConfig struct { // Should an existent user be updated in the DB based on the information in the token UpdateUserOnLogin bool `json:"update-user-on-login"` + + // Base64 encoded Ed25519 public key used to validate JWTs. + // Overridden by the JWT_PUBLIC_KEY environment variable when set. + PublicKey string `json:"public-key"` + + // Base64 encoded Ed25519 private key used to sign JWTs. + // Overridden by the JWT_PRIVATE_KEY environment variable when set. + PrivateKey string `json:"private-key"` + + // Base64 encoded Ed25519 public key for accepting externally generated JWTs. + // Overridden by the CROSS_LOGIN_JWT_PUBLIC_KEY environment variable when set. + CrossLoginPublicKey string `json:"cross-login-public-key"` + + // Base64 encoded HMAC (HS256/HS512) key for accepting externally generated + // session login tokens. + // Overridden by the CROSS_LOGIN_JWT_HS512_KEY environment variable when set. + CrossLoginHS512Key string `json:"cross-login-hs512-key"` } type JWTAuthenticator struct { @@ -47,9 +63,10 @@ type JWTAuthenticator struct { } func (ja *JWTAuthenticator) Init() error { - pubKey, privKey := os.Getenv("JWT_PUBLIC_KEY"), os.Getenv("JWT_PRIVATE_KEY") + pubKey := secretFromEnv("JWT_PUBLIC_KEY", Keys.JwtConfig.PublicKey) + privKey := secretFromEnv("JWT_PRIVATE_KEY", Keys.JwtConfig.PrivateKey) if pubKey == "" || privKey == "" { - cclog.Warn("environment variables 'JWT_PUBLIC_KEY' or 'JWT_PRIVATE_KEY' not set (token based authentication will not work)") + cclog.Warn("JWT public/private key not configured ('public-key'/'private-key' in config or 'JWT_PUBLIC_KEY'/'JWT_PRIVATE_KEY' env): token based authentication will not work") } else { bytes, err := base64.StdEncoding.DecodeString(pubKey) if err != nil { @@ -121,7 +138,7 @@ func (ja *JWTAuthenticator) AuthViaJWT( // ProvideJWT generates a new JWT that can be used for authentication func (ja *JWTAuthenticator) ProvideJWT(user *schema.User) (string, error) { if ja.privateKey == nil { - return "", errors.New("environment variable 'JWT_PRIVATE_KEY' not set") + return "", errors.New("JWT private key not configured ('private-key' in config or 'JWT_PRIVATE_KEY' env)") } now := time.Now() diff --git a/internal/auth/jwtCookieSession.go b/internal/auth/jwtCookieSession.go index f16dbe5e..602a267f 100644 --- a/internal/auth/jwtCookieSession.go +++ b/internal/auth/jwtCookieSession.go @@ -11,7 +11,6 @@ import ( "errors" "fmt" "net/http" - "os" cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger" "github.com/ClusterCockpit/cc-lib/v2/schema" @@ -27,10 +26,11 @@ type JWTCookieSessionAuthenticator struct { var _ Authenticator = (*JWTCookieSessionAuthenticator)(nil) func (ja *JWTCookieSessionAuthenticator) Init() error { - pubKey, privKey := os.Getenv("JWT_PUBLIC_KEY"), os.Getenv("JWT_PRIVATE_KEY") + pubKey := secretFromEnv("JWT_PUBLIC_KEY", Keys.JwtConfig.PublicKey) + privKey := secretFromEnv("JWT_PRIVATE_KEY", Keys.JwtConfig.PrivateKey) if pubKey == "" || privKey == "" { - cclog.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)") + cclog.Warn("JWT public/private key not configured ('public-key'/'private-key' in config or 'JWT_PUBLIC_KEY'/'JWT_PRIVATE_KEY' env): token based authentication will not work") + return errors.New("JWT public/private key not configured: token based authentication will not work") } else { bytes, err := base64.StdEncoding.DecodeString(pubKey) if err != nil { @@ -47,8 +47,8 @@ func (ja *JWTCookieSessionAuthenticator) Init() error { } // Look for external public keys - pubKeyCrossLogin, keyFound := os.LookupEnv("CROSS_LOGIN_JWT_PUBLIC_KEY") - if keyFound && pubKeyCrossLogin != "" { + pubKeyCrossLogin := secretFromEnv("CROSS_LOGIN_JWT_PUBLIC_KEY", Keys.JwtConfig.CrossLoginPublicKey) + if pubKeyCrossLogin != "" { bytes, err := base64.StdEncoding.DecodeString(pubKeyCrossLogin) if err != nil { cclog.Warn("Could not decode cross login JWT public key") @@ -57,8 +57,8 @@ func (ja *JWTCookieSessionAuthenticator) Init() error { ja.publicKeyCrossLogin = ed25519.PublicKey(bytes) } else { ja.publicKeyCrossLogin = nil - cclog.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)") + cclog.Debug("cross login JWT public key not configured ('cross-login-public-key' in config or 'CROSS_LOGIN_JWT_PUBLIC_KEY' env): cross login token based authentication will not work") + return errors.New("cross login JWT public key not configured: cross login token based authentication will not work") } // Warn if other necessary settings are not configured diff --git a/internal/auth/jwtSession.go b/internal/auth/jwtSession.go index 8fae61e1..6b464548 100644 --- a/internal/auth/jwtSession.go +++ b/internal/auth/jwtSession.go @@ -10,7 +10,6 @@ import ( "errors" "fmt" "net/http" - "os" "strings" cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger" @@ -25,12 +24,12 @@ type JWTSessionAuthenticator struct { var _ Authenticator = (*JWTSessionAuthenticator)(nil) func (ja *JWTSessionAuthenticator) Init() error { - pubKey := os.Getenv("CROSS_LOGIN_JWT_HS512_KEY") + pubKey := secretFromEnv("CROSS_LOGIN_JWT_HS512_KEY", Keys.JwtConfig.CrossLoginHS512Key) if pubKey == "" { // Without a configured key the HMAC verification below would run against // an empty key, which lets anyone forge a valid token. Refuse to register // the authenticator in that case so JWT session login is simply disabled. - return errors.New("CROSS_LOGIN_JWT_HS512_KEY not set: JWT session login disabled") + return errors.New("cross login HS512 key not configured ('cross-login-hs512-key' in config or 'CROSS_LOGIN_JWT_HS512_KEY' env): JWT session login disabled") } bytes, err := base64.StdEncoding.DecodeString(pubKey) diff --git a/internal/auth/ldap.go b/internal/auth/ldap.go index b65e485f..c8d2b6a3 100644 --- a/internal/auth/ldap.go +++ b/internal/auth/ldap.go @@ -9,7 +9,6 @@ import ( "fmt" "net" "net/http" - "os" "strings" "time" @@ -33,6 +32,10 @@ type LdapConfig struct { // Should a non-existent user be added to the DB if user exists in ldap directory SyncUserOnLogin bool `json:"sync-user-on-login"` UpdateUserOnLogin bool `json:"update-user-on-login"` + + // Password for the LDAP admin account used for syncing (optional). + // Overridden by the LDAP_ADMIN_PASSWORD environment variable when set. + SyncPassword string `json:"sync-password"` } type LdapAuthenticator struct { @@ -44,9 +47,9 @@ type LdapAuthenticator struct { var _ Authenticator = (*LdapAuthenticator)(nil) func (la *LdapAuthenticator) Init() error { - la.syncPassword = os.Getenv("LDAP_ADMIN_PASSWORD") + la.syncPassword = secretFromEnv("LDAP_ADMIN_PASSWORD", Keys.LdapConfig.SyncPassword) if la.syncPassword == "" { - cclog.Warn("environment variable 'LDAP_ADMIN_PASSWORD' not set (ldap sync will not work)") + cclog.Warn("LDAP admin password not configured ('sync-password' in config or 'LDAP_ADMIN_PASSWORD' env): ldap sync will not work") } if Keys.LdapConfig.UserAttr != "" { diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go index 934c8542..de23ad63 100644 --- a/internal/auth/oidc.go +++ b/internal/auth/oidc.go @@ -12,7 +12,6 @@ import ( "fmt" "io" "net/http" - "os" "time" "github.com/ClusterCockpit/cc-backend/internal/repository" @@ -27,6 +26,14 @@ type OpenIDConfig struct { Provider string `json:"provider"` SyncUserOnLogin bool `json:"sync-user-on-login"` UpdateUserOnLogin bool `json:"update-user-on-login"` + + // OAuth2 client ID for the OIDC provider. + // Overridden by the OID_CLIENT_ID environment variable when set. + ClientID string `json:"client-id"` + + // OAuth2 client secret for the OIDC provider. + // Overridden by the OID_CLIENT_SECRET environment variable when set. + ClientSecret string `json:"client-secret"` } type OIDC struct { @@ -66,13 +73,13 @@ func NewOIDC(a *Authentication) *OIDC { if err != nil { cclog.Fatal(err) } - clientID := os.Getenv("OID_CLIENT_ID") + clientID := secretFromEnv("OID_CLIENT_ID", Keys.OpenIDConfig.ClientID) if clientID == "" { - cclog.Warn("environment variable 'OID_CLIENT_ID' not set (Open ID connect auth will not work)") + cclog.Warn("OIDC client ID not configured ('client-id' in config or 'OID_CLIENT_ID' env): Open ID connect auth will not work") } - clientSecret := os.Getenv("OID_CLIENT_SECRET") + clientSecret := secretFromEnv("OID_CLIENT_SECRET", Keys.OpenIDConfig.ClientSecret) if clientSecret == "" { - cclog.Warn("environment variable 'OID_CLIENT_SECRET' not set (Open ID connect auth will not work)") + cclog.Warn("OIDC client secret not configured ('client-secret' in config or 'OID_CLIENT_SECRET' env): Open ID connect auth will not work") } client := &oauth2.Config{ diff --git a/internal/auth/schema.go b/internal/auth/schema.go index b6ee0702..269cef45 100644 --- a/internal/auth/schema.go +++ b/internal/auth/schema.go @@ -34,6 +34,22 @@ var configSchema = ` "update-user-on-login": { "description": "Should an existent user attributes in the DB be updated at login attempt with values provided in JWT.", "type": "boolean" + }, + "public-key": { + "description": "Base64 encoded Ed25519 public key used to validate JWTs. Overridden by the JWT_PUBLIC_KEY environment variable when set.", + "type": "string" + }, + "private-key": { + "description": "Base64 encoded Ed25519 private key used to sign JWTs. Overridden by the JWT_PRIVATE_KEY environment variable when set.", + "type": "string" + }, + "cross-login-public-key": { + "description": "Base64 encoded Ed25519 public key for accepting externally generated JWTs. Overridden by the CROSS_LOGIN_JWT_PUBLIC_KEY environment variable when set.", + "type": "string" + }, + "cross-login-hs512-key": { + "description": "Base64 encoded HMAC (HS256/HS512) key for accepting externally generated session login tokens. Overridden by the CROSS_LOGIN_JWT_HS512_KEY environment variable when set.", + "type": "string" } }, "required": ["max-age"] @@ -52,6 +68,14 @@ var configSchema = ` "update-user-on-login": { "description": "Should an existent user attributes in the DB be updated at login attempt with values provided.", "type": "boolean" + }, + "client-id": { + "description": "OAuth2 client ID for the OIDC provider. Overridden by the OID_CLIENT_ID environment variable when set.", + "type": "string" + }, + "client-secret": { + "description": "OAuth2 client secret for the OIDC provider. Overridden by the OID_CLIENT_SECRET environment variable when set.", + "type": "string" } }, "required": ["provider"] @@ -103,6 +127,10 @@ var configSchema = ` "update-user-on-login": { "description": "Should an existent user attributes in the DB be updated at login attempt with values from LDAP.", "type": "boolean" + }, + "sync-password": { + "description": "Password for the LDAP admin account used for syncing. Overridden by the LDAP_ADMIN_PASSWORD environment variable when set.", + "type": "string" } }, "required": ["url", "user-base", "search-dn", "user-bind", "user-filter"] diff --git a/internal/config/config.go b/internal/config/config.go index 677b24e4..0850b167 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,7 +24,7 @@ type ProgramConfig struct { APISubjects *NATSConfig `json:"api-subjects"` - // Drop root permissions once .env was read and the port was taken. + // Drop root permissions once the config was read and the port was taken. User string `json:"user"` Group string `json:"group"` diff --git a/internal/config/schema.go b/internal/config/schema.go index 195dfaeb..d5bffda9 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -21,11 +21,11 @@ var configSchema = ` } }, "user": { - "description": "Drop root permissions once .env was read and the port was taken. Only applicable if using privileged port.", + "description": "Drop root permissions once the config was read and the port was taken. Only applicable if using privileged port.", "type": "string" }, "group": { - "description": "Drop root permissions once .env was read and the port was taken. Only applicable if using privileged port.", + "description": "Drop root permissions once the config was read and the port was taken. Only applicable if using privileged port.", "type": "string" }, "disable-authentication": { diff --git a/tools/convert-pem-pubkey/Readme.md b/tools/convert-pem-pubkey/Readme.md index 22fd0db2..1cdeaf95 100644 --- a/tools/convert-pem-pubkey/Readme.md +++ b/tools/convert-pem-pubkey/Readme.md @@ -11,7 +11,7 @@ MCowBQYDK2VwAyEA+51iXX8BdLFocrppRxIw52xCOf8xFSH/eNilN5IHVGc= 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=" +cross-login-public-key: "+51iXX8BdLFocrppRxIw52xCOf8xFSH/eNilN5IHVGc=" ``` Instructions @@ -19,7 +19,9 @@ Instructions - `cd tools/convert-pem-pubkey/` - Insert your public ed25519 PEM key into `dummy.pub` - `go run . dummy.pub` -- Copy the result into ClusterCockpit's `.env` +- Set the result as `cross-login-public-key` under `auth.jwts` in ClusterCockpit's + `config.json` (or supply it via the `CROSS_LOGIN_JWT_PUBLIC_KEY` environment + variable, which takes precedence) - (Re)start ClusterCockpit backend Now CC can validate generated JWTs from the external provider. diff --git a/tools/convert-pem-pubkey/main.go b/tools/convert-pem-pubkey/main.go index 97504c24..349acc92 100644 --- a/tools/convert-pem-pubkey/main.go +++ b/tools/convert-pem-pubkey/main.go @@ -44,8 +44,11 @@ func main() { os.Exit(1) } + // Print the value for use as auth.jwts.cross-login-public-key in config.json. + // It may alternatively be supplied via the CROSS_LOGIN_JWT_PUBLIC_KEY + // environment variable, which takes precedence. fmt.Fprintf(os.Stdout, - "CROSS_LOGIN_JWT_PUBLIC_KEY=%#v\n", + "cross-login-public-key: %#v\n", base64.StdEncoding.EncodeToString(pubkey)) }