diff --git a/.entire/.gitignore b/.entire/.gitignore index 2cffdefa..e66987d2 100644 --- a/.entire/.gitignore +++ b/.entire/.gitignore @@ -2,3 +2,4 @@ tmp/ settings.local.json metadata/ logs/ +redactors/local/ 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/Makefile b/Makefile index 04ad954c..1cdb2283 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ TARGET = ./cc-backend FRONTEND = ./web/frontend -VERSION = 1.5.4 +VERSION = 1.6.0 GIT_HASH := $(shell git rev-parse --short HEAD || echo 'development') CURRENT_TIME = $(shell date +"%Y-%m-%d:T%H:%M:%S") LD_FLAGS = '-s -X main.date=${CURRENT_TIME} -X main.version=${VERSION} -X main.commit=${GIT_HASH}' diff --git a/README.md b/README.md index 86b12034..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: @@ -152,6 +151,27 @@ ln -s ./var/job-archive ./cc-backend -help ``` +### Authentication and sessions + +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. + +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 +`config.json`); otherwise it is left unset so that plain-HTTP development works. +For production deployments, serve cc-backend over HTTPS so the session cookie is +marked `Secure`. If you terminate TLS at a reverse proxy, prefer letting +cc-backend serve HTTPS directly for now so the flag is applied. + ## Database Configuration cc-backend uses SQLite as its database. For large installations, SQLite memory diff --git a/ReleaseNotes.md b/ReleaseNotes.md index 6eea3bf2..13aa14f9 100644 --- a/ReleaseNotes.md +++ b/ReleaseNotes.md @@ -1,10 +1,48 @@ -# `cc-backend` version 1.5.4 +# `cc-backend` version 1.6.0 + +Supports job archive version 3 and database version 12. + +This release replaces the browser session implementation and requires a database +migration to version 12. Run `./cc-backend -migrate-db` after upgrading; the new +`sessions` table is created automatically (a fresh `-init-db` also creates it). +Existing login sessions are invalidated by the upgrade, so users have to log in +again once. See the behavior changes below regarding the session cookie `Secure` +flag and the removal of the `SESSION_KEY` environment variable. +For release specific notes visit the [ClusterCockpit Documentation](https://clusterockpit.org/docs/release/). + +## Changes in 1.6.0 + +### Behavior changes + +- **Session backend replaced (`gorilla/sessions` → `alexedwards/scs`)**: Browser + sessions are now managed by `github.com/alexedwards/scs/v2` with server-side + storage in the SQLite database (new `sessions` table, database version 12) + instead of `gorilla/sessions` client-side cookie storage. Only an opaque random + token is stored in the cookie; the session payload lives server-side. Sessions + still survive backend restarts. This requires the database migration noted above + and invalidates existing sessions. +- **Session cookie `Secure` flag**: The `Secure` attribute on the session cookie + is now derived from the server configuration — it is set when cc-backend + terminates TLS itself (`https-cert-file` and `https-key-file` configured) and + unset otherwise. Previously the flag was effectively never set. Deployments that + terminate TLS at a reverse proxy and want the `Secure` flag should serve + cc-backend over HTTPS directly for now. +- **`SESSION_KEY` removed**: The `SESSION_KEY` environment variable is no longer + used and should be removed from your `.env`. Server-side sessions use random + tokens, so no cookie-signing secret is required. It is ignored if left in place. + +### Dependencies + +- **Added** `github.com/alexedwards/scs/v2` and + `github.com/alexedwards/scs/sqlite3store`. +- **Removed** `github.com/gorilla/sessions` (and `github.com/gorilla/securecookie`). + +## Changes in 1.5.4 Supports job archive version 3 and database version 11. -This is a security and bugfix release of `cc-backend`, the API backend and +This was a security and bugfix release of `cc-backend`, the API backend and frontend implementation of ClusterCockpit. -For release specific notes visit the [ClusterCockpit Documentation](https://clusterockpit.org/docs/release/). If you are upgrading from v1.5.1 no database migration is required. If you are upgrading from v1.5.0 you need to do another DB migration. This should not take long. For optimal database performance after the migration it is @@ -15,8 +53,6 @@ While we are confident that the memory issue with the metricstore cleanup move policy is fixed, it is still recommended to use delete policy for cleanup. This is also the default. -## Changes in 1.5.4 - ### Security fixes - **JWT HMAC empty-key bypass (critical)**: `jwtSession.go` now refuses to 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 a4def54e..d7bcaf32 100644 --- a/cmd/cc-backend/init.go +++ b/cmd/cc-backend/init.go @@ -18,16 +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==" - -# Some random bytes used as secret for cookie-based sessions (DO NOT USE THIS ONE IN PRODUCTION) -SESSION_KEY="67d829bf61dc5f87a73fd814e2c9f629" -` - const configString = ` { "main": { @@ -53,7 +43,9 @@ const configString = ` }, "auth": { "jwts": { - "max-age": "2000h" + "max-age": "2000h", + "public-key": "kzfYrYy+TzpanWZHJ5qSdMj5uKUWgq74BWhQG6copP0=", + "private-key": "dtPC/6dWJFKZK7KZ78CvWuynylOmjBFyMsUWArwmodOTN9itjL5POlqdZkcnmpJ0yPm4pRaCrvgFaFAbpyik/Q==" } } } @@ -68,10 +60,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/cmd/cc-backend/server.go b/cmd/cc-backend/server.go index 4a9e71b1..5909fb9d 100644 --- a/cmd/cc-backend/server.go +++ b/cmd/cc-backend/server.go @@ -121,6 +121,7 @@ func (s *Server) init() error { } authHandle := auth.GetAuthInstance() + sessionManager := authHandle.SessionManager() // Middleware must be defined before routes in chi s.router.Use(func(next http.Handler) http.Handler { @@ -220,10 +221,12 @@ func (s *Server) init() error { }) } - s.router.Post("/login", authHandle.Login(loginFailureHandler).ServeHTTP) - s.router.HandleFunc("/jwt-login", authHandle.Login(loginFailureHandler).ServeHTTP) + // Login/logout mutate the session, so they are wrapped with + // scs.LoadAndSave, which commits the session and writes the cookie. + s.router.Post("/login", sessionManager.LoadAndSave(authHandle.Login(loginFailureHandler)).ServeHTTP) + s.router.Handle("/jwt-login", sessionManager.LoadAndSave(authHandle.Login(loginFailureHandler))) - s.router.Post("/logout", authHandle.Logout( + s.router.Post("/logout", sessionManager.LoadAndSave(authHandle.Logout( http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { rw.Header().Add("Content-Type", "text/html; charset=utf-8") rw.WriteHeader(http.StatusOK) @@ -234,7 +237,7 @@ func (s *Server) init() error { Build: buildInfo, Infos: info, }) - })).ServeHTTP) + }))).ServeHTTP) } if flagDev { @@ -246,6 +249,10 @@ func (s *Server) init() error { // Secured routes (require authentication) s.router.Group(func(secured chi.Router) { if !config.Keys.DisableAuthentication { + // Non-buffering session load: makes the session available to + // AuthViaSession without wrapping/buffering the (potentially large, + // e.g. GraphQL /query) response. + secured.Use(authHandle.LoadSession) secured.Use(func(next http.Handler) http.Handler { return authHandle.Auth( next, @@ -309,6 +316,7 @@ func (s *Server) init() error { // the /config page route that is registered in the secured group) s.router.Group(func(configapi chi.Router) { if !config.Keys.DisableAuthentication { + configapi.Use(authHandle.LoadSession) configapi.Use(func(next http.Handler) http.Handler { return authHandle.AuthConfigAPI(next, onFailureResponse) }) @@ -319,6 +327,7 @@ func (s *Server) init() error { // Frontend API routes s.router.Route("/frontend", func(frontendapi chi.Router) { if !config.Keys.DisableAuthentication { + frontendapi.Use(authHandle.LoadSession) frontendapi.Use(func(next http.Handler) http.Handler { return authHandle.AuthFrontendAPI(next, onFailureResponse) }) 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 c90499fc..df85cb3c 100644 --- a/configs/config.json +++ b/configs/config.json @@ -17,6 +17,10 @@ "default-policy": "medium", "default-algo": "lttb" }, + "footer-links": { + "imprint": "/imprint", + "privacy": "/privacy" + }, "api-subjects": { "subject-job-event": "cc.job.event", "subject-node-state": "cc.node.state" @@ -29,7 +33,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 e62a1fae..00000000 --- a/configs/env-template.txt +++ /dev/null @@ -1,14 +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="" - -# Some random bytes used as secret for cookie-based sessions (DO NOT USE THIS ONE IN PRODUCTION) -SESSION_KEY="67d829bf61dc5f87a73fd814e2c9f629" - -# Password for the ldap server (optional) -LDAP_ADMIN_PASSWORD="mashup" diff --git a/go.mod b/go.mod index 54aec9c4..b3dc1a12 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,8 @@ require ( github.com/ClusterCockpit/cc-lib/v2 v2.12.0 github.com/ClusterCockpit/cc-line-protocol/v2 v2.4.0 github.com/Masterminds/squirrel v1.5.4 + github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de + github.com/alexedwards/scs/v2 v2.9.0 github.com/aws/aws-sdk-go-v2 v1.41.7 github.com/aws/aws-sdk-go-v2/config v1.32.18 github.com/aws/aws-sdk-go-v2/credentials v1.19.17 @@ -25,9 +27,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-migrate/migrate/v4 v4.19.1 github.com/google/gops v0.3.29 - github.com/gorilla/sessions v1.4.0 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 @@ -80,7 +80,6 @@ require ( github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/influxdata/influxdb-client-go/v2 v2.14.0 // indirect diff --git a/go.sum b/go.sum index 2d0719f8..d5689234 100644 --- a/go.sum +++ b/go.sum @@ -27,6 +27,10 @@ github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de h1:c72K9HLu6K442et0j3BUL/9HEYaUJouLkkVANdmqTOo= +github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de/go.mod h1:Iyk7S76cxGaiEX/mSYmTZzYehp4KfyylcLaV3OnToss= +github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90= +github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= @@ -153,18 +157,12 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= -github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= -github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gops v0.3.29 h1:n98J2qSOK1NJvRjdLDcjgDryjpIBGhbaqph1mXKL0rY= github.com/google/gops v0.3.29/go.mod h1:8N3jZftuPazvUwtYY/ncG4iPrjp15ysNKLfq+QQPiwc= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= -github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= -github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= -github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= @@ -193,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= @@ -212,6 +208,7 @@ github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= diff --git a/init/README.md b/init/README.md index 5ecc9be6..ecc02b03 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. @@ -39,6 +37,13 @@ cp privacy.tmpl /opt/monitoring/cc-backend/var/ # Ensure your logo, and any images you use in your login template has a suitable size. cp -R img /opt/monitoring/cc-backend/img +# 4b. (Optional) Instead of overriding imprint.tmpl/privacy.tmpl, you can point the +# footer links to external pages via "main.footer-links" in config.json: +# "footer-links": { "imprint": "https://example.com/imprint", "privacy": "https://example.com/privacy" } +# Values may be internal paths (default "/imprint", "/privacy") or external URLs; +# external URLs (http/https) open in a new browser tab. An empty value falls back +# to the internal page. + # 5. Copy the systemd service unit file. You may adopt it to your needs. sudo cp ./init/clustercockpit.service /etc/systemd/system/clustercockpit.service @@ -57,8 +62,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 +74,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/api/api_test.go b/internal/api/api_test.go index 7ede49cb..ec3f55ff 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -170,7 +170,6 @@ func setup(t *testing.T) *api.RestAPI { archiver.Start(repository.GetJobRepository(), context.Background()) - t.Setenv("SESSION_KEY", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") if cfg := ccconf.GetPackageConfig("auth"); cfg != nil { auth.Init(&cfg) } else { diff --git a/internal/api/nats_test.go b/internal/api/nats_test.go index cc0bdf90..b1d2a624 100644 --- a/internal/api/nats_test.go +++ b/internal/api/nats_test.go @@ -156,7 +156,6 @@ func setupNatsTest(t *testing.T) *NatsAPI { archiver.Start(repository.GetJobRepository(), context.Background()) - t.Setenv("SESSION_KEY", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") if cfg := ccconf.GetPackageConfig("auth"); cfg != nil { auth.Init(&cfg) } else { diff --git a/internal/auth/auth.go b/internal/auth/auth.go index efb72d85..f8ce52fa 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -9,9 +9,8 @@ package auth import ( "bytes" "context" - "crypto/rand" "database/sql" - "encoding/base64" + "encoding/gob" "encoding/json" "errors" "fmt" @@ -29,7 +28,8 @@ import ( cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger" "github.com/ClusterCockpit/cc-lib/v2/schema" "github.com/ClusterCockpit/cc-lib/v2/util" - "github.com/gorilla/sessions" + "github.com/alexedwards/scs/sqlite3store" + "github.com/alexedwards/scs/v2" ) // Authenticator is the interface for all authentication methods. @@ -116,9 +116,21 @@ 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 { - sessionStore *sessions.CookieStore + sessionManager *scs.SessionManager LdapAuth *LdapAuthenticator JwtAuth *JWTAuthenticator LocalAuth *LocalAuthenticator @@ -126,49 +138,80 @@ type Authentication struct { SessionMaxAge time.Duration } +// SessionManager exposes the scs session manager so the HTTP router can install +// the session middleware (LoadAndSave on write paths, LoadSession on read paths). +func (auth *Authentication) SessionManager() *scs.SessionManager { + return auth.sessionManager +} + +// LoadSession is a non-buffering, read-only session middleware. It loads any +// existing session into the request context so AuthViaSession can read it, but +// (unlike scs.LoadAndSave) it never wraps the ResponseWriter and never commits, +// so large responses (e.g. the GraphQL /query endpoint) stream without being +// buffered in memory. Use it on routes that only read the session to +// authenticate; use scs.LoadAndSave on the login/logout routes that mutate it. +func (auth *Authentication) LoadSession(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + var token string + if c, err := r.Cookie(auth.sessionManager.Cookie.Name); err == nil { + token = c.Value + } + ctx, err := auth.sessionManager.Load(r.Context(), token) + if err != nil { + cclog.Errorf("session load failed: %s", err.Error()) + http.Error(rw, "internal server error", http.StatusInternalServerError) + return + } + next.ServeHTTP(rw, r.WithContext(ctx)) + }) +} + +// expireSessionCookie clears a (corrupted) session cookie on the client. Used on +// read paths where there is no commit to drive the deletion. +func expireSessionCookie(rw http.ResponseWriter) { + http.SetCookie(rw, &http.Cookie{ + Name: "session", + Path: "/", + MaxAge: -1, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) +} + func (auth *Authentication) AuthViaSession( rw http.ResponseWriter, r *http.Request, ) (*schema.User, error) { - session, err := auth.sessionStore.Get(r, "session") - if err != nil { - cclog.Error("Error while getting session store") - return nil, err - } - - if session.IsNew { + // The session data was loaded into the request context by the LoadSession + // middleware. No active session cookie => not logged in (mirrors session.IsNew). + ctx := r.Context() + if !auth.sessionManager.Exists(ctx, "username") { return nil, nil } // Validate session data with proper type checking - username, ok := session.Values["username"].(string) - if !ok || username == "" { + username := auth.sessionManager.GetString(ctx, "username") + if username == "" { cclog.Warn("Invalid session: missing or invalid username") - // Invalidate the corrupted session - session.Options.MaxAge = -1 - _ = auth.sessionStore.Save(r, rw, session) + expireSessionCookie(rw) return nil, errors.New("invalid session data") } - projects, ok := session.Values["projects"].([]string) + projects, ok := auth.sessionManager.Get(ctx, "projects").([]string) if !ok { cclog.Warn("Invalid session: projects not found or invalid type, using empty list") projects = []string{} } - roles, ok := session.Values["roles"].([]string) + roles, ok := auth.sessionManager.Get(ctx, "roles").([]string) if !ok || len(roles) == 0 { cclog.Warn("Invalid session: missing or invalid roles") - // Invalidate the corrupted session - session.Options.MaxAge = -1 - _ = auth.sessionStore.Save(r, rw, session) + expireSessionCookie(rw) return nil, errors.New("invalid session data") } - authSourceInt, ok := session.Values["authSource"].(int) - if !ok { - authSourceInt = int(schema.AuthViaLocalPassword) - } + // GetInt returns 0 (== schema.AuthViaLocalPassword) when the key is absent. + authSourceInt := auth.sessionManager.GetInt(ctx, "authSource") return &schema.User{ Username: username, @@ -186,31 +229,30 @@ func Init(authCfg *json.RawMessage) { // Start background cleanup of rate limiters startRateLimiterCleanup() - sessKey := os.Getenv("SESSION_KEY") - if sessKey == "" { - if !config.Keys.DisableAuthentication { - cclog.Fatal("environment variable 'SESSION_KEY' not set: refusing to start with an ephemeral session key. " + - "Set SESSION_KEY in .env (base64-encoded 32 random bytes); a random key would invalidate all sessions on every restart " + - "and prevent sessions from validating across replicas.") - } - // Authentication is disabled: no user sessions are issued, so an - // ephemeral random key is sufficient and SESSION_KEY is not required. - ephemeralKey := make([]byte, 32) - if _, err := rand.Read(ephemeralKey); err != nil { - cclog.Fatalf("Error while initializing authentication -> generating ephemeral session key failed: %v", err) - } - authInstance.sessionStore = sessions.NewCookieStore(ephemeralKey) - } else { - keyBytes, err := base64.StdEncoding.DecodeString(sessKey) - if err != nil { - cclog.Fatal("Error while initializing authentication -> decoding session key failed") - } - authInstance.sessionStore = sessions.NewCookieStore(keyBytes) - } + // Server-side sessions via scs, persisted in the existing SQLite DB so + // sessions survive restarts. Only an opaque random token is stored in the + // cookie, so no secret signing key (the former SESSION_KEY) is required. + gob.Register([]string{}) // user.Projects / user.Roles are stored as []string + sm := scs.New() + sm.Store = sqlite3store.New(repository.GetConnection().DB.DB) + sm.Cookie.Name = "session" + sm.Cookie.Path = "/" + sm.Cookie.HttpOnly = true + sm.Cookie.SameSite = http.SameSiteLaxMode + // scs sets Secure globally (no per-request option). Enable it when this + // process terminates TLS itself. Deployments terminating TLS at a reverse + // proxy can set this via a future config flag if needed. + sm.Cookie.Secure = config.Keys.HTTPSCertFile != "" - if d, err := time.ParseDuration(config.Keys.SessionMaxAge); err == nil { + if d, err := time.ParseDuration(config.Keys.SessionMaxAge); err == nil && d != 0 { + sm.Lifetime = d authInstance.SessionMaxAge = d + } else { + // SessionMaxAge of 0/empty means "do not expire": approximate with a + // long absolute lifetime (the cookie remains persistent). + sm.Lifetime = 10 * 365 * 24 * time.Hour } + authInstance.sessionManager = sm // When authentication is disabled no authenticators are required; the // session store created above is enough for the server to run with a @@ -319,36 +361,22 @@ func handleLdapUser(ldapUser *schema.User) { } func (auth *Authentication) SaveSession(rw http.ResponseWriter, r *http.Request, user *schema.User) error { - session, err := auth.sessionStore.New(r, "session") - if err != nil { - cclog.Errorf("session creation failed: %s", err.Error()) + // The login routes are wrapped by scs.LoadAndSave, which loaded the session + // into the request context and will commit it (persist to the store and write + // the Set-Cookie header) after the handler returns. + ctx := r.Context() + + // Generate a new session token to prevent session fixation. + if err := auth.sessionManager.RenewToken(ctx); err != nil { + cclog.Errorf("session renew failed: %s", err.Error()) http.Error(rw, err.Error(), http.StatusInternalServerError) return err } - if auth.SessionMaxAge != 0 { - session.Options.MaxAge = int(auth.SessionMaxAge.Seconds()) - } - if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" { - // If neither TLS or an encrypted reverse proxy are used, do not mark cookies as secure. - cclog.Warn("Authenticating with unencrypted request. Session cookies will not have Secure flag set (insecure for production)") - if r.Header.Get("X-Forwarded-Proto") == "" { - // This warning will not be printed if e.g. X-Forwarded-Proto == http - cclog.Warn("If you are using a reverse proxy, make sure X-Forwarded-Proto is set") - } - session.Options.Secure = false - } - session.Options.SameSite = http.SameSiteLaxMode - session.Options.HttpOnly = true - session.Values["username"] = user.Username - session.Values["projects"] = user.Projects - session.Values["roles"] = user.Roles - session.Values["authSource"] = int(user.AuthSource) - if err := auth.sessionStore.Save(r, rw, session); err != nil { - cclog.Warnf("session save failed: %s", err.Error()) - http.Error(rw, err.Error(), http.StatusInternalServerError) - return err - } + auth.sessionManager.Put(ctx, "username", user.Username) + auth.sessionManager.Put(ctx, "projects", user.Projects) + auth.sessionManager.Put(ctx, "roles", user.Roles) + auth.sessionManager.Put(ctx, "authSource", int(user.AuthSource)) return nil } @@ -609,20 +637,13 @@ func (auth *Authentication) AuthFrontendAPI( 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 { + // The logout route is wrapped by scs.LoadAndSave: Destroy removes the + // session from the store and the middleware clears the cookie on the way out. + if err := auth.sessionManager.Destroy(r.Context()); err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return } - if !session.IsNew { - session.Options.MaxAge = -1 - if err := auth.sessionStore.Save(r, rw, session); err != nil { - http.Error(rw, err.Error(), http.StatusInternalServerError) - return - } - } - onsuccess.ServeHTTP(rw, r) }) } 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 b4cbc8f8..83322403 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"` @@ -80,6 +80,18 @@ type ProgramConfig struct { // Database tuning configuration DbConfig *DbConfig `json:"db-config"` + + // Optional external/legal links shown in the footer. + FooterLinks FooterLinksConfig `json:"footer-links"` +} + +// FooterLinksConfig configures the legal/footer links rendered in the UI. +// Each value may be an internal path (e.g. "/imprint") or an external URL. +type FooterLinksConfig struct { + // Target URL/path for the "Imprint" footer entry. + Imprint string `json:"imprint"` + // Target URL/path for the "Privacy Policy" footer entry. + Privacy string `json:"privacy"` } type DbConfig struct { @@ -145,6 +157,10 @@ var Keys ProgramConfig = ProgramConfig{ SessionMaxAge: "168h", StopJobsExceedingWalltime: 0, ShortRunningJobsDuration: 5 * 60, + FooterLinks: FooterLinksConfig{ + Imprint: "/imprint", + Privacy: "/privacy", + }, } func Init(mainConfig json.RawMessage) { diff --git a/internal/config/schema.go b/internal/config/schema.go index 13b4cb7f..7a1ecb2a 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": { @@ -127,6 +127,20 @@ var configSchema = ` }, "required": ["subject-job-event", "subject-node-state"] }, + "footer-links": { + "description": "Optional footer links for legal pages (imprint/privacy). Each value may be an internal path or an external URL.", + "type": "object", + "properties": { + "imprint": { + "description": "Target URL/path for the footer imprint link.", + "type": "string" + }, + "privacy": { + "description": "Target URL/path for the footer privacy link.", + "type": "string" + } + } + }, "nodestate-retention": { "description": "Node state retention configuration for cleaning up old node_state rows.", "type": "object", diff --git a/internal/repository/migration.go b/internal/repository/migration.go index 3a8f5e6c..b97db97d 100644 --- a/internal/repository/migration.go +++ b/internal/repository/migration.go @@ -21,11 +21,12 @@ import ( // is added to internal/repository/migrations/sqlite3/. // // Version history: +// - Version 12: Sessions table (server-side sessions via alexedwards/scs) // - Version 11: Optimize job table indexes (reduce from ~78 to 48, add covering/partial indexes) // - Version 10: Node table // // Migration files are embedded at build time from the migrations directory. -const Version uint = 11 +const Version uint = 12 //go:embed migrations/* var migrationFiles embed.FS diff --git a/internal/repository/migrations/sqlite3/12_add-sessions-table.down.sql b/internal/repository/migrations/sqlite3/12_add-sessions-table.down.sql new file mode 100644 index 00000000..63d205dc --- /dev/null +++ b/internal/repository/migrations/sqlite3/12_add-sessions-table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS sessions; diff --git a/internal/repository/migrations/sqlite3/12_add-sessions-table.up.sql b/internal/repository/migrations/sqlite3/12_add-sessions-table.up.sql new file mode 100644 index 00000000..1d90f012 --- /dev/null +++ b/internal/repository/migrations/sqlite3/12_add-sessions-table.up.sql @@ -0,0 +1,7 @@ +CREATE TABLE sessions ( + token TEXT PRIMARY KEY, + data BLOB NOT NULL, + expiry REAL NOT NULL +); + +CREATE INDEX sessions_expiry_idx ON sessions(expiry); 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)) } diff --git a/web/frontend/src/generic/utils.js b/web/frontend/src/generic/utils.js index 72a6d333..6819bf56 100644 --- a/web/frontend/src/generic/utils.js +++ b/web/frontend/src/generic/utils.js @@ -4,7 +4,7 @@ import { setContextClient, fetchExchange, } from "@urql/svelte"; -import { setContext, getContext, hasContext, onDestroy, tick } from "svelte"; +import { setContext, getContext, onDestroy, tick } from "svelte"; import { readable } from "svelte/store"; import { round } from "mathjs"; @@ -21,14 +21,11 @@ import { round } from "mathjs"; * - Adds 'getHardwareTopology' to the context, a function that takes a cluster nad subCluster and returns the subCluster topology (or undefined) */ export function init(extraInitQuery = "") { - const jwt = hasContext("jwt") - ? getContext("jwt") - : getContext("cc-config")["jwt"]; - + // The web UI authenticates GraphQL requests via the session cookie + // (same-origin), so no Authorization header is attached here. External + // clients use a JWT against /query directly. const client = new Client({ url: `${window.location.origin}/query`, - fetchOptions: - jwt != null ? { headers: { Authorization: `Bearer ${jwt}` } } : {}, exchanges: [ expiringCacheExchange({ ttl: 5 * 60 * 1000, diff --git a/web/templates/base.tmpl b/web/templates/base.tmpl index fd94668c..95ccf266 100644 --- a/web/templates/base.tmpl +++ b/web/templates/base.tmpl @@ -52,8 +52,8 @@ {{block "footer" .}}