10 Commits

Author SHA1 Message Date
Jan Eitzinger
9d1efcd55d Merge pull request #561 from ClusterCockpit/feature-283-remove-env-support-alt
feat(auth): replace .env/godotenv secret handling with config-based s…
2026-06-17 15:28:40 +02:00
3562bfa3aa Rewording of init README 2026-06-17 15:24:13 +02:00
83d04dff17 feat(auth): replace .env/godotenv secret handling with config-based secrets
Secrets (JWT keys, LDAP sync password, OIDC client id/secret, cross-login
keys) are now configured directly in config.json under the auth section
where they are used. Each secret can still be supplied via its existing
environment variable, which takes precedence over the config value.

The godotenv dependency, the .env file, configs/env-template.txt and the
loadEnvironment() bootstrap step are removed. -init now writes the demo
JWT keys into config.json instead of a .env file.

Closes #283

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 3a7cb814c53f
2026-06-17 12:28:17 +02:00
Jan Eitzinger
07b9a57479 Merge pull request #559 from ClusterCockpit/feature/558-replace-gorilla-sessions
Feature/558 replace gorilla sessions
2026-06-17 10:05:07 +02:00
b7f597bb7d Update entire config 2026-06-17 07:58:29 +02:00
2b01b57495 feat: replace gorilla/sessions with alexedwards/scs/v2
Browser sessions are now server-side, stored in the SQLite database via
scs/sqlite3store (new `sessions` table, DB migration to version 12) instead
of gorilla/sessions client-side cookie storage. Only an opaque random token
is kept in the cookie; session data lives server-side and survives restarts.

Session middleware is wired as a hybrid to avoid buffering large responses:
scs.LoadAndSave on the login/logout write paths, and a non-buffering
read-only LoadSession middleware on the secured/config/frontend read paths
so the large GraphQL /query responses stream unbuffered. JWT-only APIs
(/api, /userapi, /api/metricstore) and static files are left unwrapped.

The session cookie Secure flag is now derived from the server config (set
when cc-backend terminates TLS itself); previously it was effectively never
set. The SESSION_KEY env var is removed as server-side tokens need no
signing secret. The dormant Bearer-JWT branch in the frontend urql client
is removed; the web UI authenticates GraphQL via the session cookie.

Closes #558

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: b51075f43cc7
2026-06-17 07:54:26 +02:00
Jan Eitzinger
3bfd3d06ca Merge pull request #557 from ClusterCockpit/release/v1.5
Release/v1.5
2026-06-07 08:21:14 +02:00
9c6075ebb5 Update README to reflect main branch naming 2026-06-07 08:18:46 +02:00
af7528c8b2 Update CLAUDE.md
Entire-Checkpoint: 306db138cb4c
2026-06-07 08:16:10 +02:00
01fb4d53f1 Fix broken link in README 2026-06-07 08:12:25 +02:00
32 changed files with 400 additions and 262 deletions

1
.entire/.gitignore vendored
View File

@@ -2,3 +2,4 @@ tmp/
settings.local.json
metadata/
logs/
redactors/local/

View File

@@ -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
@@ -341,6 +347,21 @@ records, archives) at scale. All code changes must prioritize maximum throughput
and minimal latency. Avoid unnecessary allocations, prefer streaming over
buffering, and be mindful of lock contention. When in doubt, benchmark.
### Commit Message Convention
Commits must use conventional commit prefixes so goreleaser can generate the
changelog automatically. Only commits with these prefixes appear in releases:
| Prefix | Changelog group |
|---------|------------------------|
| `feat:` | New Features |
| `fix:` | Bug fixes |
| `sec:` | Security updates |
| `docs:` | Documentation updates |
Scoped variants are also recognised, e.g. `feat(api):`, `fix(deps):`.
Commits without one of these prefixes are excluded from the changelog.
### Change Impact Analysis
For any significant change, you MUST:

View File

@@ -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}'

103
README.md
View File

@@ -1,10 +1,9 @@
# NOTE
While we do our best to keep the master branch in a usable state, there is no guarantee the master branch works.
Please do not use it for production!
While we do our best to keep the main branch in a usable state, there is no
guarantee the main branch works. Please do not use it for production!
Please have a look at the [Release
Notes](https://github.com/ClusterCockpit/cc-backend/blob/master/ReleaseNotes.md)
Please have a look at the [Release Notes](https://github.com/ClusterCockpit/cc-backend/blob/main/ReleaseNotes.md)
for breaking changes!
# ClusterCockpit REST and GraphQL API backend
@@ -41,7 +40,7 @@ For real-time integration with HPC systems, the backend can subscribe to
state updates, providing an alternative to REST API polling.
Completed batch jobs are stored in a file-based job archive according to
[this specification](https://github.com/ClusterCockpit/cc-specifications/tree/master/job-archive).
[this specification](https://github.com/ClusterCockpit/cc-specifications/tree/main/job-archive).
The backend supports authentication via local accounts, an external LDAP
directory, and JWT tokens. Authorization for APIs is implemented with
[JWT](https://jwt.io/) tokens created with public/private key encryption.
@@ -130,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:
@@ -153,6 +151,27 @@ ln -s <your-existing-job-archive> ./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
@@ -243,73 +262,73 @@ The effective configuration is logged at startup for verification.
## Project file structure
- [`.github/`](https://github.com/ClusterCockpit/cc-backend/tree/master/.github)
- [`.github/`](https://github.com/ClusterCockpit/cc-backend/tree/main/.github)
GitHub Actions workflows and dependabot configuration for CI/CD.
- [`api/`](https://github.com/ClusterCockpit/cc-backend/tree/master/api)
- [`api/`](https://github.com/ClusterCockpit/cc-backend/tree/main/api)
contains the API schema files for the REST and GraphQL APIs. The REST API is
documented in the OpenAPI 3.0 format in
[./api/swagger.yaml](./api/swagger.yaml). The GraphQL schema is in
[./api/schema.graphqls](./api/schema.graphqls).
- [`cmd/cc-backend`](https://github.com/ClusterCockpit/cc-backend/tree/master/cmd/cc-backend)
- [`cmd/cc-backend`](https://github.com/ClusterCockpit/cc-backend/tree/main/cmd/cc-backend)
contains the main application entry point and CLI implementation.
- [`configs/`](https://github.com/ClusterCockpit/cc-backend/tree/master/configs)
- [`configs/`](https://github.com/ClusterCockpit/cc-backend/tree/main/configs)
contains documentation about configuration and command line options and required
environment variables. Sample configuration files are provided.
- [`init/`](https://github.com/ClusterCockpit/cc-backend/tree/master/init)
- [`init/`](https://github.com/ClusterCockpit/cc-backend/tree/main/init)
contains an example of setting up systemd for production use.
- [`internal/`](https://github.com/ClusterCockpit/cc-backend/tree/master/internal)
- [`internal/`](https://github.com/ClusterCockpit/cc-backend/tree/main/internal)
contains library source code that is not intended for use by others.
- [`api`](https://github.com/ClusterCockpit/cc-backend/tree/master/internal/api)
- [`api`](https://github.com/ClusterCockpit/cc-backend/tree/main/internal/api)
REST API handlers and NATS integration
- [`archiver`](https://github.com/ClusterCockpit/cc-backend/tree/master/internal/archiver)
- [`archiver`](https://github.com/ClusterCockpit/cc-backend/tree/main/internal/archiver)
Job archiving functionality
- [`auth`](https://github.com/ClusterCockpit/cc-backend/tree/master/internal/auth)
- [`auth`](https://github.com/ClusterCockpit/cc-backend/tree/main/internal/auth)
Authentication (local, LDAP, OIDC) and JWT token handling
- [`config`](https://github.com/ClusterCockpit/cc-backend/tree/master/internal/config)
- [`config`](https://github.com/ClusterCockpit/cc-backend/tree/main/internal/config)
Configuration management and validation
- [`graph`](https://github.com/ClusterCockpit/cc-backend/tree/master/internal/graph)
- [`graph`](https://github.com/ClusterCockpit/cc-backend/tree/main/internal/graph)
GraphQL schema and resolvers
- [`importer`](https://github.com/ClusterCockpit/cc-backend/tree/master/internal/importer)
- [`importer`](https://github.com/ClusterCockpit/cc-backend/tree/main/internal/importer)
Job data import and database initialization
- [`metricdispatch`](https://github.com/ClusterCockpit/cc-backend/tree/master/internal/metricdispatch)
- [`metricdispatch`](https://github.com/ClusterCockpit/cc-backend/tree/main/internal/metricdispatch)
Dispatches metric data loading to appropriate backends
- [`repository`](https://github.com/ClusterCockpit/cc-backend/tree/master/internal/repository)
- [`repository`](https://github.com/ClusterCockpit/cc-backend/tree/main/internal/repository)
Database repository layer for jobs and metadata
- [`routerConfig`](https://github.com/ClusterCockpit/cc-backend/tree/master/internal/routerConfig)
- [`routerConfig`](https://github.com/ClusterCockpit/cc-backend/tree/main/internal/routerConfig)
HTTP router configuration and middleware
- [`tagger`](https://github.com/ClusterCockpit/cc-backend/tree/master/internal/tagger)
- [`tagger`](https://github.com/ClusterCockpit/cc-backend/tree/main/internal/tagger)
Job classification and application detection
- [`taskmanager`](https://github.com/ClusterCockpit/cc-backend/tree/master/internal/taskmanager)
- [`taskmanager`](https://github.com/ClusterCockpit/cc-backend/tree/main/internal/taskmanager)
Background task management and scheduled jobs
- [`metricstoreclient`](https://github.com/ClusterCockpit/cc-backend/tree/master/internal/metricstoreclient)
- [`metricstoreclient`](https://github.com/ClusterCockpit/cc-backend/tree/main/internal/metricstoreclient)
Client for cc-metric-store queries
- [`pkg/`](https://github.com/ClusterCockpit/cc-backend/tree/master/pkg)
- [`pkg/`](https://github.com/ClusterCockpit/cc-backend/tree/main/pkg)
contains Go packages that can be used by other projects.
- [`archive`](https://github.com/ClusterCockpit/cc-backend/tree/master/pkg/archive)
- [`archive`](https://github.com/ClusterCockpit/cc-backend/tree/main/pkg/archive)
Job archive backend implementations (filesystem, S3, SQLite)
- [`metricstore`](https://github.com/ClusterCockpit/cc-backend/tree/master/pkg/metricstore)
- [`metricstore`](https://github.com/ClusterCockpit/cc-backend/tree/main/pkg/metricstore)
In-memory metric data store with checkpointing and metric loading
- [`tools/`](https://github.com/ClusterCockpit/cc-backend/tree/master/tools)
- [`tools/`](https://github.com/ClusterCockpit/cc-backend/tree/main/tools)
Additional command line helper tools.
- [`archive-manager`](https://github.com/ClusterCockpit/cc-backend/tree/master/tools/archive-manager)
- [`archive-manager`](https://github.com/ClusterCockpit/cc-backend/tree/main/tools/archive-manager)
Commands for getting infos about an existing job archive, importing jobs
between archive backends, and converting archives between JSON and Parquet formats.
- [`archive-migration`](https://github.com/ClusterCockpit/cc-backend/tree/master/tools/archive-migration)
- [`archive-migration`](https://github.com/ClusterCockpit/cc-backend/tree/main/tools/archive-migration)
Tool for migrating job archives between formats.
- [`convert-pem-pubkey`](https://github.com/ClusterCockpit/cc-backend/tree/master/tools/convert-pem-pubkey)
- [`convert-pem-pubkey`](https://github.com/ClusterCockpit/cc-backend/tree/main/tools/convert-pem-pubkey)
Tool to convert external pubkey for use in `cc-backend`.
- [`gen-keypair`](https://github.com/ClusterCockpit/cc-backend/tree/master/tools/gen-keypair)
- [`gen-keypair`](https://github.com/ClusterCockpit/cc-backend/tree/main/tools/gen-keypair)
contains a small application to generate a compatible JWT keypair. You find
documentation on how to use it
[here](https://github.com/ClusterCockpit/cc-backend/blob/master/docs/JWT-Handling.md).
- [`web/`](https://github.com/ClusterCockpit/cc-backend/tree/master/web)
[here](https://github.com/ClusterCockpit/cc-backend/blob/main/docs/JWT-Handling.md).
- [`web/`](https://github.com/ClusterCockpit/cc-backend/tree/main/web)
Server-side templates and frontend-related files:
- [`frontend`](https://github.com/ClusterCockpit/cc-backend/tree/master/web/frontend)
- [`frontend`](https://github.com/ClusterCockpit/cc-backend/tree/main/web/frontend)
Svelte components and static assets for the frontend UI
- [`templates`](https://github.com/ClusterCockpit/cc-backend/tree/master/web/templates)
- [`templates`](https://github.com/ClusterCockpit/cc-backend/tree/main/web/templates)
Server-side Go templates, including monitoring views
- [`gqlgen.yml`](https://github.com/ClusterCockpit/cc-backend/blob/master/gqlgen.yml)
- [`gqlgen.yml`](https://github.com/ClusterCockpit/cc-backend/blob/main/gqlgen.yml)
Configures the behaviour and generation of
[gqlgen](https://github.com/99designs/gqlgen).
- [`startDemo.sh`](https://github.com/ClusterCockpit/cc-backend/blob/master/startDemo.sh)
- [`startDemo.sh`](https://github.com/ClusterCockpit/cc-backend/blob/main/startDemo.sh)
is a shell script that sets up demo data, and builds and starts `cc-backend`.

View File

@@ -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

View File

@@ -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")

View File

@@ -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": {
@@ -57,7 +47,9 @@ const configString = `
},
"auth": {
"jwts": {
"max-age": "2000h"
"max-age": "2000h",
"public-key": "kzfYrYy+TzpanWZHJ5qSdMj5uKUWgq74BWhQG6copP0=",
"private-key": "dtPC/6dWJFKZK7KZ78CvWuynylOmjBFyMsUWArwmodOTN9itjL5POlqdZkcnmpJ0yPm4pRaCrvgFaFAbpyik/Q=="
}
}
}
@@ -72,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())
}

View File

@@ -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

View File

@@ -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)
})

View File

@@ -9,7 +9,9 @@
},
"auth": {
"jwts": {
"max-age": "2000h"
"max-age": "2000h",
"public-key": "kzfYrYy+TzpanWZHJ5qSdMj5uKUWgq74BWhQG6copP0=",
"private-key": "dtPC/6dWJFKZK7KZ78CvWuynylOmjBFyMsUWArwmodOTN9itjL5POlqdZkcnmpJ0yPm4pRaCrvgFaFAbpyik/Q=="
}
},
"metric-store-external": [

View File

@@ -30,7 +30,9 @@
},
"auth": {
"jwts": {
"max-age": "2000h"
"max-age": "2000h",
"public-key": "kzfYrYy+TzpanWZHJ5qSdMj5uKUWgq74BWhQG6copP0=",
"private-key": "dtPC/6dWJFKZK7KZ78CvWuynylOmjBFyMsUWArwmodOTN9itjL5POlqdZkcnmpJ0yPm4pRaCrvgFaFAbpyik/Q=="
}
},
"cron": {

View File

@@ -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"

5
go.mod
View File

@@ -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

13
go.sum
View File

@@ -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=

View File

@@ -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
- 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!
- 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!

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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)
})
}

View File

@@ -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()

View File

@@ -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

View File

@@ -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)

View File

@@ -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 != "" {

View File

@@ -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{

View File

@@ -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"]

View File

@@ -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"`

View File

@@ -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": {

View File

@@ -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

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS sessions;

View File

@@ -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);

View File

@@ -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.

View File

@@ -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))
}

View File

@@ -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,