Merge pull request #559 from ClusterCockpit/feature/558-replace-gorilla-sessions

Feature/558 replace gorilla sessions
This commit is contained in:
Jan Eitzinger
2026-06-17 10:05:07 +02:00
committed by GitHub
16 changed files with 184 additions and 118 deletions

1
.entire/.gitignore vendored
View File

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

View File

@@ -1,6 +1,6 @@
TARGET = ./cc-backend TARGET = ./cc-backend
FRONTEND = ./web/frontend FRONTEND = ./web/frontend
VERSION = 1.5.4 VERSION = 1.6.0
GIT_HASH := $(shell git rev-parse --short HEAD || echo 'development') GIT_HASH := $(shell git rev-parse --short HEAD || echo 'development')
CURRENT_TIME = $(shell date +"%Y-%m-%d:T%H:%M:%S") 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}' LD_FLAGS = '-s -X main.date=${CURRENT_TIME} -X main.version=${VERSION} -X main.commit=${GIT_HASH}'

View File

@@ -152,6 +152,21 @@ ln -s <your-existing-job-archive> ./var/job-archive
./cc-backend -help ./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 and
can be removed from your `.env`.
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 ## Database Configuration
cc-backend uses SQLite as its database. For large installations, SQLite memory cc-backend uses SQLite as its database. For large installations, SQLite memory

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. 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. 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.1 no database migration is required.
If you are upgrading from v1.5.0 you need to do another DB migration. This 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 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. policy is fixed, it is still recommended to use delete policy for cleanup.
This is also the default. This is also the default.
## Changes in 1.5.4
### Security fixes ### Security fixes
- **JWT HMAC empty-key bypass (critical)**: `jwtSession.go` now refuses to - **JWT HMAC empty-key bypass (critical)**: `jwtSession.go` now refuses to

View File

@@ -23,9 +23,6 @@ const envString = `
# You can generate your own keypair using the gen-keypair tool # You can generate your own keypair using the gen-keypair tool
JWT_PUBLIC_KEY="kzfYrYy+TzpanWZHJ5qSdMj5uKUWgq74BWhQG6copP0=" JWT_PUBLIC_KEY="kzfYrYy+TzpanWZHJ5qSdMj5uKUWgq74BWhQG6copP0="
JWT_PRIVATE_KEY="dtPC/6dWJFKZK7KZ78CvWuynylOmjBFyMsUWArwmodOTN9itjL5POlqdZkcnmpJ0yPm4pRaCrvgFaFAbpyik/Q==" 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 = ` const configString = `

View File

@@ -121,6 +121,7 @@ func (s *Server) init() error {
} }
authHandle := auth.GetAuthInstance() authHandle := auth.GetAuthInstance()
sessionManager := authHandle.SessionManager()
// Middleware must be defined before routes in chi // Middleware must be defined before routes in chi
s.router.Use(func(next http.Handler) http.Handler { 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) // Login/logout mutate the session, so they are wrapped with
s.router.HandleFunc("/jwt-login", authHandle.Login(loginFailureHandler).ServeHTTP) // 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) { http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
rw.Header().Add("Content-Type", "text/html; charset=utf-8") rw.Header().Add("Content-Type", "text/html; charset=utf-8")
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
@@ -234,7 +237,7 @@ func (s *Server) init() error {
Build: buildInfo, Build: buildInfo,
Infos: info, Infos: info,
}) })
})).ServeHTTP) }))).ServeHTTP)
} }
if flagDev { if flagDev {
@@ -246,6 +249,10 @@ func (s *Server) init() error {
// Secured routes (require authentication) // Secured routes (require authentication)
s.router.Group(func(secured chi.Router) { s.router.Group(func(secured chi.Router) {
if !config.Keys.DisableAuthentication { 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 { secured.Use(func(next http.Handler) http.Handler {
return authHandle.Auth( return authHandle.Auth(
next, next,
@@ -309,6 +316,7 @@ func (s *Server) init() error {
// the /config page route that is registered in the secured group) // the /config page route that is registered in the secured group)
s.router.Group(func(configapi chi.Router) { s.router.Group(func(configapi chi.Router) {
if !config.Keys.DisableAuthentication { if !config.Keys.DisableAuthentication {
configapi.Use(authHandle.LoadSession)
configapi.Use(func(next http.Handler) http.Handler { configapi.Use(func(next http.Handler) http.Handler {
return authHandle.AuthConfigAPI(next, onFailureResponse) return authHandle.AuthConfigAPI(next, onFailureResponse)
}) })
@@ -319,6 +327,7 @@ func (s *Server) init() error {
// Frontend API routes // Frontend API routes
s.router.Route("/frontend", func(frontendapi chi.Router) { s.router.Route("/frontend", func(frontendapi chi.Router) {
if !config.Keys.DisableAuthentication { if !config.Keys.DisableAuthentication {
frontendapi.Use(authHandle.LoadSession)
frontendapi.Use(func(next http.Handler) http.Handler { frontendapi.Use(func(next http.Handler) http.Handler {
return authHandle.AuthFrontendAPI(next, onFailureResponse) return authHandle.AuthFrontendAPI(next, onFailureResponse)
}) })

View File

@@ -7,8 +7,5 @@ JWT_PRIVATE_KEY="dtPC/6dWJFKZK7KZ78CvWuynylOmjBFyMsUWArwmodOTN9itjL5POlqdZkcnmpJ
# Keys in PEM format can be converted, see `tools/convert-pem-pubkey/Readme.md` # Keys in PEM format can be converted, see `tools/convert-pem-pubkey/Readme.md`
CROSS_LOGIN_JWT_PUBLIC_KEY="" 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) # Password for the ldap server (optional)
LDAP_ADMIN_PASSWORD="mashup" LDAP_ADMIN_PASSWORD="mashup"

4
go.mod
View File

@@ -12,6 +12,8 @@ require (
github.com/ClusterCockpit/cc-lib/v2 v2.12.0 github.com/ClusterCockpit/cc-lib/v2 v2.12.0
github.com/ClusterCockpit/cc-line-protocol/v2 v2.4.0 github.com/ClusterCockpit/cc-line-protocol/v2 v2.4.0
github.com/Masterminds/squirrel v1.5.4 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 v1.41.7
github.com/aws/aws-sdk-go-v2/config v1.32.18 github.com/aws/aws-sdk-go-v2/config v1.32.18
github.com/aws/aws-sdk-go-v2/credentials v1.19.17 github.com/aws/aws-sdk-go-v2/credentials v1.19.17
@@ -25,7 +27,6 @@ require (
github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-jwt/jwt/v5 v5.3.1
github.com/golang-migrate/migrate/v4 v4.19.1 github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/gops v0.3.29 github.com/google/gops v0.3.29
github.com/gorilla/sessions v1.4.0
github.com/jmoiron/sqlx v1.4.0 github.com/jmoiron/sqlx v1.4.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.44 github.com/mattn/go-sqlite3 v1.14.44
@@ -80,7 +81,6 @@ require (
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/uuid v1.6.0 // 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/gorilla/websocket v1.5.3 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/influxdata/influxdb-client-go/v2 v2.14.0 // indirect github.com/influxdata/influxdb-client-go/v2 v2.14.0 // indirect

11
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/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 h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= 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 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= 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= 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-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 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= 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 h1:n98J2qSOK1NJvRjdLDcjgDryjpIBGhbaqph1mXKL0rY=
github.com/google/gops v0.3.29/go.mod h1:8N3jZftuPazvUwtYY/ncG4iPrjp15ysNKLfq+QQPiwc= 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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
@@ -212,6 +210,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 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 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.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.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 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=

View File

@@ -170,7 +170,6 @@ func setup(t *testing.T) *api.RestAPI {
archiver.Start(repository.GetJobRepository(), context.Background()) archiver.Start(repository.GetJobRepository(), context.Background())
t.Setenv("SESSION_KEY", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
if cfg := ccconf.GetPackageConfig("auth"); cfg != nil { if cfg := ccconf.GetPackageConfig("auth"); cfg != nil {
auth.Init(&cfg) auth.Init(&cfg)
} else { } else {

View File

@@ -156,7 +156,6 @@ func setupNatsTest(t *testing.T) *NatsAPI {
archiver.Start(repository.GetJobRepository(), context.Background()) archiver.Start(repository.GetJobRepository(), context.Background())
t.Setenv("SESSION_KEY", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
if cfg := ccconf.GetPackageConfig("auth"); cfg != nil { if cfg := ccconf.GetPackageConfig("auth"); cfg != nil {
auth.Init(&cfg) auth.Init(&cfg)
} else { } else {

View File

@@ -9,16 +9,14 @@ package auth
import ( import (
"bytes" "bytes"
"context" "context"
"crypto/rand"
"database/sql" "database/sql"
"encoding/base64" "encoding/gob"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
"os"
"sync" "sync"
"time" "time"
@@ -29,7 +27,8 @@ import (
cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger" cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
"github.com/ClusterCockpit/cc-lib/v2/schema" "github.com/ClusterCockpit/cc-lib/v2/schema"
"github.com/ClusterCockpit/cc-lib/v2/util" "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. // Authenticator is the interface for all authentication methods.
@@ -118,7 +117,7 @@ var Keys AuthConfig
// Authentication manages all authentication methods and session handling // Authentication manages all authentication methods and session handling
type Authentication struct { type Authentication struct {
sessionStore *sessions.CookieStore sessionManager *scs.SessionManager
LdapAuth *LdapAuthenticator LdapAuth *LdapAuthenticator
JwtAuth *JWTAuthenticator JwtAuth *JWTAuthenticator
LocalAuth *LocalAuthenticator LocalAuth *LocalAuthenticator
@@ -126,49 +125,80 @@ type Authentication struct {
SessionMaxAge time.Duration 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( func (auth *Authentication) AuthViaSession(
rw http.ResponseWriter, rw http.ResponseWriter,
r *http.Request, r *http.Request,
) (*schema.User, error) { ) (*schema.User, error) {
session, err := auth.sessionStore.Get(r, "session") // The session data was loaded into the request context by the LoadSession
if err != nil { // middleware. No active session cookie => not logged in (mirrors session.IsNew).
cclog.Error("Error while getting session store") ctx := r.Context()
return nil, err if !auth.sessionManager.Exists(ctx, "username") {
}
if session.IsNew {
return nil, nil return nil, nil
} }
// Validate session data with proper type checking // Validate session data with proper type checking
username, ok := session.Values["username"].(string) username := auth.sessionManager.GetString(ctx, "username")
if !ok || username == "" { if username == "" {
cclog.Warn("Invalid session: missing or invalid username") cclog.Warn("Invalid session: missing or invalid username")
// Invalidate the corrupted session expireSessionCookie(rw)
session.Options.MaxAge = -1
_ = auth.sessionStore.Save(r, rw, session)
return nil, errors.New("invalid session data") return nil, errors.New("invalid session data")
} }
projects, ok := session.Values["projects"].([]string) projects, ok := auth.sessionManager.Get(ctx, "projects").([]string)
if !ok { if !ok {
cclog.Warn("Invalid session: projects not found or invalid type, using empty list") cclog.Warn("Invalid session: projects not found or invalid type, using empty list")
projects = []string{} projects = []string{}
} }
roles, ok := session.Values["roles"].([]string) roles, ok := auth.sessionManager.Get(ctx, "roles").([]string)
if !ok || len(roles) == 0 { if !ok || len(roles) == 0 {
cclog.Warn("Invalid session: missing or invalid roles") cclog.Warn("Invalid session: missing or invalid roles")
// Invalidate the corrupted session expireSessionCookie(rw)
session.Options.MaxAge = -1
_ = auth.sessionStore.Save(r, rw, session)
return nil, errors.New("invalid session data") return nil, errors.New("invalid session data")
} }
authSourceInt, ok := session.Values["authSource"].(int) // GetInt returns 0 (== schema.AuthViaLocalPassword) when the key is absent.
if !ok { authSourceInt := auth.sessionManager.GetInt(ctx, "authSource")
authSourceInt = int(schema.AuthViaLocalPassword)
}
return &schema.User{ return &schema.User{
Username: username, Username: username,
@@ -186,31 +216,30 @@ func Init(authCfg *json.RawMessage) {
// Start background cleanup of rate limiters // Start background cleanup of rate limiters
startRateLimiterCleanup() startRateLimiterCleanup()
sessKey := os.Getenv("SESSION_KEY") // Server-side sessions via scs, persisted in the existing SQLite DB so
if sessKey == "" { // sessions survive restarts. Only an opaque random token is stored in the
if !config.Keys.DisableAuthentication { // cookie, so no secret signing key (the former SESSION_KEY) is required.
cclog.Fatal("environment variable 'SESSION_KEY' not set: refusing to start with an ephemeral session key. " + gob.Register([]string{}) // user.Projects / user.Roles are stored as []string
"Set SESSION_KEY in .env (base64-encoded 32 random bytes); a random key would invalidate all sessions on every restart " + sm := scs.New()
"and prevent sessions from validating across replicas.") sm.Store = sqlite3store.New(repository.GetConnection().DB.DB)
} sm.Cookie.Name = "session"
// Authentication is disabled: no user sessions are issued, so an sm.Cookie.Path = "/"
// ephemeral random key is sufficient and SESSION_KEY is not required. sm.Cookie.HttpOnly = true
ephemeralKey := make([]byte, 32) sm.Cookie.SameSite = http.SameSiteLaxMode
if _, err := rand.Read(ephemeralKey); err != nil { // scs sets Secure globally (no per-request option). Enable it when this
cclog.Fatalf("Error while initializing authentication -> generating ephemeral session key failed: %v", err) // process terminates TLS itself. Deployments terminating TLS at a reverse
} // proxy can set this via a future config flag if needed.
authInstance.sessionStore = sessions.NewCookieStore(ephemeralKey) sm.Cookie.Secure = config.Keys.HTTPSCertFile != ""
} 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)
}
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 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 // When authentication is disabled no authenticators are required; the
// session store created above is enough for the server to run with a // session store created above is enough for the server to run with a
@@ -319,36 +348,22 @@ func handleLdapUser(ldapUser *schema.User) {
} }
func (auth *Authentication) SaveSession(rw http.ResponseWriter, r *http.Request, user *schema.User) error { func (auth *Authentication) SaveSession(rw http.ResponseWriter, r *http.Request, user *schema.User) error {
session, err := auth.sessionStore.New(r, "session") // The login routes are wrapped by scs.LoadAndSave, which loaded the session
if err != nil { // into the request context and will commit it (persist to the store and write
cclog.Errorf("session creation failed: %s", err.Error()) // 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) http.Error(rw, err.Error(), http.StatusInternalServerError)
return err return err
} }
if auth.SessionMaxAge != 0 { auth.sessionManager.Put(ctx, "username", user.Username)
session.Options.MaxAge = int(auth.SessionMaxAge.Seconds()) auth.sessionManager.Put(ctx, "projects", user.Projects)
} auth.sessionManager.Put(ctx, "roles", user.Roles)
if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" { auth.sessionManager.Put(ctx, "authSource", int(user.AuthSource))
// 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
}
return nil return nil
} }
@@ -609,20 +624,13 @@ func (auth *Authentication) AuthFrontendAPI(
func (auth *Authentication) Logout(onsuccess http.Handler) http.Handler { func (auth *Authentication) Logout(onsuccess http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
session, err := auth.sessionStore.Get(r, "session") // The logout route is wrapped by scs.LoadAndSave: Destroy removes the
if err != nil { // 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) http.Error(rw, err.Error(), http.StatusInternalServerError)
return 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) onsuccess.ServeHTTP(rw, r)
}) })
} }

View File

@@ -21,11 +21,12 @@ import (
// is added to internal/repository/migrations/sqlite3/. // is added to internal/repository/migrations/sqlite3/.
// //
// Version history: // 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 11: Optimize job table indexes (reduce from ~78 to 48, add covering/partial indexes)
// - Version 10: Node table // - Version 10: Node table
// //
// Migration files are embedded at build time from the migrations directory. // Migration files are embedded at build time from the migrations directory.
const Version uint = 11 const Version uint = 12
//go:embed migrations/* //go:embed migrations/*
var migrationFiles embed.FS 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

@@ -4,7 +4,7 @@ import {
setContextClient, setContextClient,
fetchExchange, fetchExchange,
} from "@urql/svelte"; } from "@urql/svelte";
import { setContext, getContext, hasContext, onDestroy, tick } from "svelte"; import { setContext, getContext, onDestroy, tick } from "svelte";
import { readable } from "svelte/store"; import { readable } from "svelte/store";
import { round } from "mathjs"; 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) * - Adds 'getHardwareTopology' to the context, a function that takes a cluster nad subCluster and returns the subCluster topology (or undefined)
*/ */
export function init(extraInitQuery = "") { export function init(extraInitQuery = "") {
const jwt = hasContext("jwt") // The web UI authenticates GraphQL requests via the session cookie
? getContext("jwt") // (same-origin), so no Authorization header is attached here. External
: getContext("cc-config")["jwt"]; // clients use a JWT against /query directly.
const client = new Client({ const client = new Client({
url: `${window.location.origin}/query`, url: `${window.location.origin}/query`,
fetchOptions:
jwt != null ? { headers: { Authorization: `Bearer ${jwt}` } } : {},
exchanges: [ exchanges: [
expiringCacheExchange({ expiringCacheExchange({
ttl: 5 * 60 * 1000, ttl: 5 * 60 * 1000,