mirror of
https://github.com/ClusterCockpit/cc-backend
synced 2026-06-19 09:47:29 +02:00
Merge branch 'main' into feature/526-average-resample
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -24,7 +24,7 @@ type ProgramConfig struct {
|
||||
|
||||
APISubjects *NATSConfig `json:"api-subjects"`
|
||||
|
||||
// Drop root permissions once .env was read and the port was taken.
|
||||
// Drop root permissions once the config was read and the port was taken.
|
||||
User string `json:"user"`
|
||||
Group string `json:"group"`
|
||||
|
||||
@@ -80,6 +80,18 @@ type ProgramConfig struct {
|
||||
|
||||
// Database tuning configuration
|
||||
DbConfig *DbConfig `json:"db-config"`
|
||||
|
||||
// Optional external/legal links shown in the footer.
|
||||
FooterLinks FooterLinksConfig `json:"footer-links"`
|
||||
}
|
||||
|
||||
// FooterLinksConfig configures the legal/footer links rendered in the UI.
|
||||
// Each value may be an internal path (e.g. "/imprint") or an external URL.
|
||||
type FooterLinksConfig struct {
|
||||
// Target URL/path for the "Imprint" footer entry.
|
||||
Imprint string `json:"imprint"`
|
||||
// Target URL/path for the "Privacy Policy" footer entry.
|
||||
Privacy string `json:"privacy"`
|
||||
}
|
||||
|
||||
type DbConfig struct {
|
||||
@@ -145,6 +157,10 @@ var Keys ProgramConfig = ProgramConfig{
|
||||
SessionMaxAge: "168h",
|
||||
StopJobsExceedingWalltime: 0,
|
||||
ShortRunningJobsDuration: 5 * 60,
|
||||
FooterLinks: FooterLinksConfig{
|
||||
Imprint: "/imprint",
|
||||
Privacy: "/privacy",
|
||||
},
|
||||
}
|
||||
|
||||
func Init(mainConfig json.RawMessage) {
|
||||
|
||||
@@ -21,11 +21,11 @@ var configSchema = `
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"description": "Drop root permissions once .env was read and the port was taken. Only applicable if using privileged port.",
|
||||
"description": "Drop root permissions once the config was read and the port was taken. Only applicable if using privileged port.",
|
||||
"type": "string"
|
||||
},
|
||||
"group": {
|
||||
"description": "Drop root permissions once .env was read and the port was taken. Only applicable if using privileged port.",
|
||||
"description": "Drop root permissions once the config was read and the port was taken. Only applicable if using privileged port.",
|
||||
"type": "string"
|
||||
},
|
||||
"disable-authentication": {
|
||||
@@ -127,6 +127,20 @@ var configSchema = `
|
||||
},
|
||||
"required": ["subject-job-event", "subject-node-state"]
|
||||
},
|
||||
"footer-links": {
|
||||
"description": "Optional footer links for legal pages (imprint/privacy). Each value may be an internal path or an external URL.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"imprint": {
|
||||
"description": "Target URL/path for the footer imprint link.",
|
||||
"type": "string"
|
||||
},
|
||||
"privacy": {
|
||||
"description": "Target URL/path for the footer privacy link.",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"nodestate-retention": {
|
||||
"description": "Node state retention configuration for cleaning up old node_state rows.",
|
||||
"type": "object",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS sessions;
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user