Compare commits

...
2 Commits
Author SHA1 Message Date
tony fb1f91f162 Merge pull request 'feat(auth): add configurable session policy' (#47) from codex/block-19-session-policy into main
CI / validate (push) Canceled after 0s
Reviewed-on: #47
Reviewed-by: tony <1+tony@noreply.localhost>
2026-08-26 00:36:19 +02:00
codex 9a66aeccb3 feat(auth): add configurable session policy
CI / validate (pull_request) Successful in 26m25s
2026-08-26 00:28:59 +02:00
19 changed files with 342 additions and 93 deletions
+2
View File
@@ -39,6 +39,7 @@ Read this compact operational baseline before starting a milestone. Open detaile
- Dedicated administrator Audit and Settings pages; notification channels, audit retention/purge and game-container labels retain their existing backend contracts outside the Dashboard.
- Audit uses server-side filtering and 50-event pagination; timestamps remain UTC in SQLite and are rendered in the Compose `TZ` IANA timezone with an invalid-zone fallback to UTC. Instance audit events retain a minimal game/name/slug snapshot so history stays readable after instance deletion.
- Separate personal account settings and administrator user management, including email/password preferences, active-state session revocation, protected global roles, per-instance memberships and permission overrides.
- Administrator-configurable browser session policy: seven-day absolute lifetime and 24-hour inactivity timeout by default, bounded validation, optional inactivity expiry, dynamic enforcement for existing sessions, and throttled activity persistence. Normal authorized operations no longer require an arbitrary recent-authentication window.
- Restrictive browser headers and bounded public HTTP headers.
- Hardened read-only two-service Compose, capability dropping, private agent networking and distinct minimal OCI image targets.
- Linux black-box bootstrap/authentication E2E coverage plus a documented disposable-Docker V1 release verification matrix.
@@ -77,6 +78,7 @@ Read this compact operational baseline before starting a milestone. Open detaile
- Notification delivery attempts are capped at five with exponential minute-scale backoff and never determine the originating operation result.
- Audit retention defaults to 30 days and 10,000 entries; zero explicitly selects unlimited retention/count within documented bounds.
- At least one active global administrator is always retained; deactivation revokes that user's sessions atomically.
- Session policy is stored in the existing `system_settings` table. `created_at` remains the absolute lifetime anchor, `last_seen_at` tracks inactivity with writes no more often than every five minutes, and logout/deactivation/expiry revoke server-side session rows.
- The local template directory (`/var/lib/dogama/templates`, under the application data bind mount) is the catalog source of truth for administrator-owned customizations. Bundled templates are copied only when their destination files are absent; after every local scan, the current bundled immutable snapshots are synchronized into SQLite and selected for new deployments while older snapshots remain available for existing instances, audit and diagnostics.
- Administrators can persist bounded HTTP(S) template-repository definitions for future use. They are configuration only: remote retrieval, authentication, synchronization and automatic updates are deliberately unavailable, and Catalog Scan remains local-only.
- V Rising is the first template-scoped TCP RCON module. Its manifest currently declares only verified connectivity/status; command operations are not advertised until validated end-to-end against a real server. Go/WASI reactor modules use `-buildmode=c-shared` and initialize through `_initialize`; the V Rising success path uses concrete results to avoid Go 1.26 WASI reactor nil-interface traps. Wazero is pinned at v1.12.0.
+2 -2
View File
@@ -77,7 +77,7 @@ Admin-only system permissions are not delegated per instance in V1.
## Evaluation algorithm
1. Deny unauthenticated or disabled users.
2. Allow global admin, subject to re-authentication requirements for critical actions.
2. Allow global admin for authorized operations; the server-side session policy controls session validity.
3. Require an active membership for the target instance.
4. Start from the membership baseline.
5. Apply explicit deny overrides before explicit allows.
@@ -113,4 +113,4 @@ without an active global administrator.
## Sensitive-action safeguards
Restore, destructive delete, membership changes, secret rotation and security configuration require recent authentication. Data removal requires separate checkboxes and typed instance-name confirmation. A manager never gains new abilities merely because a module exposes a capability.
Restore, destructive delete, membership changes, secret rotation and security configuration remain protected by authorization, CSRF, confirmations and recoverable workflows as applicable. They do not use a global arbitrary recent-authentication window. A manager never gains new abilities merely because a module exposes a capability.
+2 -1
View File
@@ -7,7 +7,8 @@ SQLite is authoritative for product state. Runtime Docker state is reconciled in
| Entity | Purpose | Important fields |
|---|---|---|
| `users` | Local identities | id, username, email, password_hash, global_role, disabled_at, language, created_at |
| `sessions` | Revocable browser sessions | id_hash, user_id, expires_at, last_seen_at |
| `sessions` | Revocable browser sessions | id_hash, user_id, created_at, expires_at, last_seen_at |
| `system_settings.session_policy` | Global administrator-managed session lifetime policy | max_lifetime_seconds, inactivity_timeout_seconds, inactivity_enabled |
| `instance_memberships` | Per-instance baseline role | instance_id, user_id, role (`user`, `manager`) |
| `permission_overrides` | Explicit allow/deny beyond baseline | instance_id, user_id, permission, effect |
| `templates` | Catalog identity and origin | id, origin, trust_status, active_version |
+1 -1
View File
@@ -69,7 +69,7 @@ The preview reports detected game/type, file count, expanded size, world/player
## Restore
1. Verify permission and recent authentication.
1. Verify permission and a valid session.
2. Verify archive checksum, manifest and compatibility.
3. Show overwritten destinations and compatibility confidence.
4. Create a `pre_restore` safety backup by default; disabling it is an administrator-only exceptional action.
+1 -1
View File
@@ -33,7 +33,7 @@
| Password attack | Modern password hashing, rate limits, backoff, generic errors, repeated-failure audit/notification |
| Supply-chain substitution | Immutable version snapshots, checksums, optional signatures/trust labels, digest-pinned images, controlled activation |
| Label/template injection | Structured key/value parsing, reserved namespaces, explicit substitution allowlist, no arbitrary template execution |
| Destructive mistake | Preview, recent authentication, typed-name confirmation, pre-restore/update backups and recoverable workflows |
| Destructive mistake | Preview, typed-name confirmation, pre-restore/update backups and recoverable workflows |
| Resource exhaustion | Upload/extraction limits, job concurrency, per-instance locks, Docker limits, disk checks, notification/module bounds |
| Replay/race | Signed nonce/timestamp agent calls, idempotency keys, optimistic revisions and durable operation phases |
+1 -1
View File
@@ -48,7 +48,7 @@ Import validation precedes container creation. Warnings distinguish guaranteed f
Backup list shows origin, created/imported dates, size, validation, game/template version and checksum status. Cron has common presets, custom expression, timezone and next-run preview.
Restore shows overwritten data, safety-backup behavior and server downtime. It requires recent authentication and typed confirmation where data is replaced. Results distinguish rolled back, safely stopped and intervention required.
Restore shows overwritten data, safety-backup behavior and server downtime. It requires the normal valid session, CSRF and typed confirmation where data is replaced. Results distinguish rolled back, safely stopped and intervention required.
## Administration
+89 -9
View File
@@ -8,6 +8,7 @@ import (
"crypto/subtle"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/mail"
@@ -31,11 +32,38 @@ var (
)
const (
absoluteLifetime = 24 * time.Hour
idleLifetime = 30 * time.Minute
attemptWindow = 15 * time.Minute
DefaultSessionMaxLifetime = 7 * 24 * time.Hour
DefaultSessionInactivity = 24 * time.Hour
MinSessionMaxLifetime = time.Hour
MaxSessionMaxLifetime = 90 * 24 * time.Hour
MinSessionInactivity = 5 * time.Minute
MaxSessionInactivity = 30 * 24 * time.Hour
activityWriteInterval = 5 * time.Minute
attemptWindow = 15 * time.Minute
)
// SessionPolicy controls the server-side lifetime of every browser session.
// MaxLifetime is absolute from login; inactivity is independently bounded.
type SessionPolicy struct {
MaxLifetime time.Duration `json:"max_lifetime"`
InactivityTimeout time.Duration `json:"inactivity_timeout"`
InactivityEnabled bool `json:"inactivity_enabled"`
}
func DefaultSessionPolicy() SessionPolicy {
return SessionPolicy{MaxLifetime: DefaultSessionMaxLifetime, InactivityTimeout: DefaultSessionInactivity, InactivityEnabled: true}
}
func (p SessionPolicy) Validate() error {
if p.MaxLifetime < MinSessionMaxLifetime || p.MaxLifetime > MaxSessionMaxLifetime {
return fmt.Errorf("maximum session lifetime must be between %s and %s", MinSessionMaxLifetime, MaxSessionMaxLifetime)
}
if p.InactivityTimeout < MinSessionInactivity || p.InactivityTimeout > MaxSessionInactivity {
return fmt.Errorf("inactivity timeout must be between %s and %s", MinSessionInactivity, MaxSessionInactivity)
}
return nil
}
// User is the authenticated principal exposed to application handlers.
type User struct {
ID string `json:"id"`
@@ -61,12 +89,57 @@ type Service struct {
dummyPasswordHash string
}
const sessionPolicyKey = "session_policy"
// New constructs an authentication service.
func New(db *sql.DB) *Service {
salt := make([]byte, 16)
return &Service{db: db, now: time.Now, dummyPasswordHash: encodePassword("not a real account password", salt)}
}
// SessionPolicy returns the current persisted policy, falling back to the
// documented defaults for databases created before this setting existed.
func (s *Service) SessionPolicy(ctx context.Context) (SessionPolicy, error) {
policy := DefaultSessionPolicy()
var body string
err := s.db.QueryRowContext(ctx, "SELECT value_json FROM system_settings WHERE key=?", sessionPolicyKey).Scan(&body)
if errors.Is(err, sql.ErrNoRows) || (err != nil && strings.Contains(err.Error(), "no such table: system_settings")) {
return policy, nil
}
if err != nil {
return SessionPolicy{}, fmt.Errorf("load session policy: %w", err)
}
var stored struct {
MaxLifetimeSeconds int64 `json:"max_lifetime_seconds"`
InactivitySeconds int64 `json:"inactivity_timeout_seconds"`
InactivityEnabled bool `json:"inactivity_enabled"`
}
if err := json.Unmarshal([]byte(body), &stored); err != nil {
return SessionPolicy{}, fmt.Errorf("decode session policy: %w", err)
}
policy = SessionPolicy{MaxLifetime: time.Duration(stored.MaxLifetimeSeconds) * time.Second, InactivityTimeout: time.Duration(stored.InactivitySeconds) * time.Second, InactivityEnabled: stored.InactivityEnabled}
if err := policy.Validate(); err != nil {
return SessionPolicy{}, fmt.Errorf("stored session policy is invalid: %w", err)
}
return policy, nil
}
func (s *Service) SetSessionPolicy(ctx context.Context, policy SessionPolicy) error {
if err := policy.Validate(); err != nil {
return err
}
body, err := json.Marshal(struct {
MaxLifetimeSeconds int64 `json:"max_lifetime_seconds"`
InactivitySeconds int64 `json:"inactivity_timeout_seconds"`
InactivityEnabled bool `json:"inactivity_enabled"`
}{int64(policy.MaxLifetime / time.Second), int64(policy.InactivityTimeout / time.Second), policy.InactivityEnabled})
if err != nil {
return err
}
_, err = s.db.ExecContext(ctx, "INSERT INTO system_settings(key,value_json,revision,updated_at) VALUES(?,?,1,?) ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json, revision=system_settings.revision+1, updated_at=excluded.updated_at", sessionPolicyKey, string(body), s.now().UTC().Format(time.RFC3339Nano))
return err
}
// BootstrapRequired reports whether the one-time administrator setup is pending.
func (s *Service) BootstrapRequired(ctx context.Context) (bool, error) {
var completed sql.NullString
@@ -181,19 +254,22 @@ func (s *Service) Authenticate(ctx context.Context, token string) (User, error)
expires, err1 := time.Parse(time.RFC3339Nano, expiresAt)
lastSeen, err2 := time.Parse(time.RFC3339Nano, lastSeenAt)
authenticatedAt, err3 := time.Parse(time.RFC3339Nano, createdAt)
if err1 != nil || err2 != nil || err3 != nil || !now.Before(expires) || now.Sub(lastSeen) > idleLifetime {
policy, policyErr := s.SessionPolicy(ctx)
if err1 != nil || err2 != nil || err3 != nil || policyErr != nil || !now.Before(expires) || now.Sub(authenticatedAt) >= policy.MaxLifetime || (policy.InactivityEnabled && now.Sub(lastSeen) >= policy.InactivityTimeout) {
_ = s.Revoke(ctx, token)
return User{}, ErrInvalidSession
}
if _, err := s.db.ExecContext(ctx, "UPDATE sessions SET last_seen_at = ? WHERE id_hash = ?", now.Format(time.RFC3339Nano), digest(token)); err != nil {
return User{}, fmt.Errorf("refresh session: %w", err)
if now.Sub(lastSeen) >= activityWriteInterval {
if _, err := s.db.ExecContext(ctx, "UPDATE sessions SET last_seen_at = ? WHERE id_hash = ?", now.Format(time.RFC3339Nano), digest(token)); err != nil {
return User{}, fmt.Errorf("refresh session: %w", err)
}
}
user.AuthenticatedAt = authenticatedAt
return user, nil
}
// CreateUser adds a local identity after the caller has enforced administrator
// authorization and recent authentication.
// authorization checks are performed by the caller.
func (s *Service) CreateUser(ctx context.Context, username, password, role string) (User, error) {
return s.CreateUserWithEmail(ctx, username, username+"@local.invalid", password, role, false)
}
@@ -342,8 +418,12 @@ func (s *Service) Revoke(ctx context.Context, token string) error {
func (s *Service) createSession(ctx context.Context, userID string, now time.Time) (Session, error) {
token, csrf := randomToken(32), randomToken(32)
expires := now.Add(absoluteLifetime)
_, err := s.db.ExecContext(ctx, `INSERT INTO sessions(id_hash, user_id, csrf_hash, created_at, expires_at, last_seen_at)
policy, err := s.SessionPolicy(ctx)
if err != nil {
return Session{}, err
}
expires := now.Add(policy.MaxLifetime)
_, err = s.db.ExecContext(ctx, `INSERT INTO sessions(id_hash, user_id, csrf_hash, created_at, expires_at, last_seen_at)
VALUES (?, ?, ?, ?, ?, ?)`, digest(token), userID, digest(csrf), now.Format(time.RFC3339Nano), expires.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano))
if err != nil {
return Session{}, fmt.Errorf("create session: %w", err)
+60 -1
View File
@@ -146,12 +146,70 @@ func TestLoginRateLimitAndIdleExpiry(t *testing.T) {
if err != nil {
t.Fatal(err)
}
service.now = func() time.Time { return now.Add(idleLifetime + time.Second) }
policy, err := service.SessionPolicy(ctx)
if err != nil {
t.Fatal(err)
}
service.now = func() time.Time { return now.Add(policy.InactivityTimeout + time.Second) }
if _, err := service.Authenticate(ctx, session.Token); !errors.Is(err, ErrInvalidSession) {
t.Fatalf("idle session error = %v", err)
}
}
func TestSessionPolicyAbsoluteInactivityAndPersistence(t *testing.T) {
service := testService(t)
ctx := context.Background()
if err := service.BootstrapAdmin(ctx, "admin", "correct horse battery staple"); err != nil {
t.Fatal(err)
}
now := time.Now().UTC().Truncate(time.Second)
service.now = func() time.Time { return now }
policy := SessionPolicy{MaxLifetime: 2 * time.Hour, InactivityTimeout: time.Hour, InactivityEnabled: true}
if err := service.SetSessionPolicy(ctx, policy); err != nil {
t.Fatal(err)
}
if got, err := service.SessionPolicy(ctx); err != nil || got != policy {
t.Fatalf("policy = %#v, error = %v", got, err)
}
session, err := service.Login(ctx, "admin", "correct horse battery staple", "192.0.2.1:1234")
if err != nil {
t.Fatal(err)
}
service.now = func() time.Time { return now.Add(30 * time.Minute) }
if _, err := service.Authenticate(ctx, session.Token); err != nil {
t.Fatalf("active session rejected: %v", err)
}
service.now = func() time.Time { return now.Add(89 * time.Minute) }
if _, err := service.Authenticate(ctx, session.Token); err != nil {
t.Fatalf("activity did not extend inactivity window: %v", err)
}
service.now = func() time.Time { return now.Add(2*time.Hour + time.Second) }
if _, err := service.Authenticate(ctx, session.Token); !errors.Is(err, ErrInvalidSession) {
t.Fatalf("absolute lifetime error = %v", err)
}
service.now = func() time.Time { return now }
if err := service.SetSessionPolicy(ctx, SessionPolicy{MaxLifetime: 30 * 24 * time.Hour, InactivityTimeout: time.Hour, InactivityEnabled: false}); err != nil {
t.Fatal(err)
}
withoutIdle, err := service.Login(ctx, "admin", "correct horse battery staple", "192.0.2.2:1234")
if err != nil {
t.Fatal(err)
}
service.now = func() time.Time { return now.Add(2 * time.Hour) }
if _, err := service.Authenticate(ctx, withoutIdle.Token); err != nil {
t.Fatalf("disabled inactivity rejected session: %v", err)
}
if err := service.SetSessionPolicy(ctx, SessionPolicy{MaxLifetime: 7 * 24 * time.Hour, InactivityTimeout: 24 * time.Hour, InactivityEnabled: true}); err != nil {
t.Fatal(err)
}
service.now = func() time.Time { return now.Add(8 * 24 * time.Hour) }
if _, err := service.Authenticate(ctx, withoutIdle.Token); !errors.Is(err, ErrInvalidSession) {
t.Fatalf("reduced policy error = %v", err)
}
}
func TestRecordFailureSerializesConcurrentUpdates(t *testing.T) {
service := testService(t)
now := time.Now().UTC()
@@ -296,6 +354,7 @@ func testService(t *testing.T) *Service {
CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE COLLATE NOCASE, email TEXT UNIQUE COLLATE NOCASE, password_hash TEXT NOT NULL, global_role TEXT NOT NULL CHECK (global_role IN ('admin', 'user')), disabled_at TEXT, created_at TEXT NOT NULL, language TEXT NOT NULL DEFAULT 'en' CHECK (language IN ('en', 'fr')));
CREATE TABLE sessions (id_hash BLOB PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, csrf_hash BLOB NOT NULL, created_at TEXT NOT NULL, expires_at TEXT NOT NULL, last_seen_at TEXT NOT NULL);
CREATE TABLE authentication_attempts (attempt_key TEXT PRIMARY KEY, failures INTEGER NOT NULL, blocked_until TEXT, updated_at TEXT NOT NULL);
CREATE TABLE system_settings (key TEXT PRIMARY KEY, value_json TEXT NOT NULL, revision INTEGER NOT NULL DEFAULT 1, updated_at TEXT NOT NULL);
`); err != nil {
t.Fatal(err)
}
+9 -30
View File
@@ -6,7 +6,6 @@ import (
"errors"
"sort"
"strings"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
)
@@ -16,11 +15,8 @@ var (
ErrInvalidInput = errors.New("invalid authorization input")
ErrNotFound = errors.New("authorization object not found")
ErrConflict = errors.New("authorization conflict")
ErrRecentAuth = errors.New("recent authentication required")
)
const RecentAuthenticationWindow = 10 * time.Minute
const (
PermissionInstanceView = "instance.view"
PermissionInstanceStart = "instance.start"
@@ -124,10 +120,9 @@ type Repository interface {
type Service struct {
repository Repository
now func() time.Time
}
func New(repository Repository) *Service { return &Service{repository: repository, now: time.Now} }
func New(repository Repository) *Service { return &Service{repository: repository} }
func Permissions() []string {
result := make([]string, 0, len(allPermissions))
@@ -143,7 +138,7 @@ func (s *Service) Require(ctx context.Context, principal auth.User, instanceID,
return ErrDenied
}
if principal.Role == "admin" {
return s.requireRecentForPermission(principal, permission)
return nil
}
if principal.Role != "user" || instanceID == "" {
return ErrDenied
@@ -163,13 +158,13 @@ func (s *Service) Require(ctx context.Context, principal auth.User, instanceID,
baseline = managerBaseline
}
if baseline[permission] || access.Overrides[permission] == "allow" {
return s.requireRecentForPermission(principal, permission)
return nil
}
return ErrDenied
}
func (s *Service) SetMembership(ctx context.Context, actor auth.User, instanceID, userID, role string) error {
if err := s.requireRecentAdmin(actor); err != nil {
if err := s.requireAdmin(actor); err != nil {
return err
}
if instanceID == "" || userID == "" || (role != "user" && role != "manager") {
@@ -178,10 +173,8 @@ func (s *Service) SetMembership(ctx context.Context, actor auth.User, instanceID
return s.repository.SetMembership(ctx, actor.ID, instanceID, userID, role)
}
func (s *Service) RequireRecentAdmin(actor auth.User) error { return s.requireRecentAdmin(actor) }
func (s *Service) DeleteMembership(ctx context.Context, actor auth.User, instanceID, userID string) error {
if err := s.requireRecentAdmin(actor); err != nil {
if err := s.requireAdmin(actor); err != nil {
return err
}
if instanceID == "" || userID == "" {
@@ -191,7 +184,7 @@ func (s *Service) DeleteMembership(ctx context.Context, actor auth.User, instanc
}
func (s *Service) SetOverride(ctx context.Context, actor auth.User, instanceID, userID, permission, effect string) error {
if err := s.requireRecentAdmin(actor); err != nil {
if err := s.requireAdmin(actor); err != nil {
return err
}
if instanceID == "" || userID == "" || !knownPermission(permission) || (effect != "allow" && effect != "deny") {
@@ -201,7 +194,7 @@ func (s *Service) SetOverride(ctx context.Context, actor auth.User, instanceID,
}
func (s *Service) DeleteOverride(ctx context.Context, actor auth.User, instanceID, userID, permission string) error {
if err := s.requireRecentAdmin(actor); err != nil {
if err := s.requireAdmin(actor); err != nil {
return err
}
if instanceID == "" || userID == "" || !knownPermission(permission) {
@@ -236,7 +229,7 @@ func (s *Service) ListInstallationRequests(ctx context.Context, actor auth.User)
}
func (s *Service) ReviewInstallationRequest(ctx context.Context, actor auth.User, requestID, decision, reason string) (InstallationRequest, error) {
if err := s.requireRecentAdmin(actor); err != nil {
if err := s.requireAdmin(actor); err != nil {
return InstallationRequest{}, err
}
reason = strings.TrimSpace(reason)
@@ -246,24 +239,10 @@ func (s *Service) ReviewInstallationRequest(ctx context.Context, actor auth.User
return s.repository.ReviewInstallationRequest(ctx, actor.ID, requestID, decision, reason)
}
func (s *Service) requireRecentAdmin(actor auth.User) error {
func (s *Service) requireAdmin(actor auth.User) error {
if actor.ID == "" || actor.Disabled || actor.Role != "admin" {
return ErrDenied
}
if actor.AuthenticatedAt.IsZero() || s.now().Sub(actor.AuthenticatedAt) > RecentAuthenticationWindow {
return ErrRecentAuth
}
return nil
}
func (s *Service) requireRecentForPermission(actor auth.User, permission string) error {
if permission != PermissionInstanceConfigure && permission != PermissionInstanceDelete && permission != PermissionBackupRestore && permission != PermissionBackupDelete {
return nil
}
age := s.now().Sub(actor.AuthenticatedAt)
if actor.AuthenticatedAt.IsZero() || age < 0 || age > RecentAuthenticationWindow {
return ErrRecentAuth
}
return nil
}
+8 -8
View File
@@ -52,9 +52,9 @@ func TestInstanceBaselinesOverridesAndIdentifierSubstitution(t *testing.T) {
t.Fatal(err)
}
staleUser := user
staleUser.AuthenticatedAt = time.Now().Add(-authorization.RecentAuthenticationWindow - time.Minute)
if err := service.Require(ctx, staleUser, instanceID, authorization.PermissionBackupRestore); !errors.Is(err, authorization.ErrRecentAuth) {
t.Fatalf("stale user restore error = %v", err)
staleUser.AuthenticatedAt = time.Now().Add(-365 * 24 * time.Hour)
if err := service.Require(ctx, staleUser, instanceID, authorization.PermissionBackupRestore); err != nil {
t.Fatalf("old session metadata should not trigger an age-based denial: %v", err)
}
if err := service.Require(ctx, user, "substituted-instance-id", authorization.PermissionInstanceView); !errors.Is(err, authorization.ErrDenied) {
t.Fatalf("substituted ID error = %v", err)
@@ -63,12 +63,12 @@ func TestInstanceBaselinesOverridesAndIdentifierSubstitution(t *testing.T) {
t.Fatal(err)
}
stale := admin
stale.AuthenticatedAt = time.Now().Add(-authorization.RecentAuthenticationWindow - time.Minute)
if err := service.Require(ctx, stale, instanceID, authorization.PermissionInstanceDelete); !errors.Is(err, authorization.ErrRecentAuth) {
t.Fatalf("stale admin delete error = %v", err)
stale.AuthenticatedAt = time.Now().Add(-365 * 24 * time.Hour)
if err := service.Require(ctx, stale, instanceID, authorization.PermissionInstanceDelete); err != nil {
t.Fatalf("old session metadata should not trigger an age-based denial: %v", err)
}
if err := service.SetMembership(ctx, stale, instanceID, user.ID, "user"); !errors.Is(err, authorization.ErrRecentAuth) {
t.Fatalf("stale admin error = %v", err)
if err := service.SetMembership(ctx, stale, instanceID, user.ID, "user"); err != nil {
t.Fatalf("old session metadata should not trigger an age-based denial: %v", err)
}
}
+20 -20
View File
@@ -8,10 +8,18 @@ import (
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/audit"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/authorization"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/notification"
)
func (s *server) requireRecentAdmin(w http.ResponseWriter, r *http.Request, api bool) (auth.User, bool) {
func requireAdmin(user auth.User) error {
if user.ID == "" || user.Disabled || user.Role != "admin" {
return authorization.ErrDenied
}
return nil
}
func (s *server) requireAdminMutation(w http.ResponseWriter, r *http.Request, api bool) (auth.User, bool) {
var user auth.User
var ok bool
if api {
@@ -33,14 +41,6 @@ func (s *server) requireRecentAdmin(w http.ResponseWriter, r *http.Request, api
}
return auth.User{}, false
}
if time.Since(user.AuthenticatedAt) > 10*time.Minute {
if api {
s.apiProblem(w, http.StatusForbidden, "reauthentication_required", "Recent authentication is required.")
} else {
s.problem(w, http.StatusForbidden, "Recent authentication is required.")
}
return auth.User{}, false
}
return user, true
}
func (s *server) recordAudit(r *http.Request, actor auth.User, action, outcome string, summary map[string]string) {
@@ -79,7 +79,7 @@ func (s *server) auditPolicyGet(w http.ResponseWriter, r *http.Request) {
s.apiJSON(w, 200, p)
}
func (s *server) auditPolicyPut(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, true)
actor, ok := s.requireAdminMutation(w, r, true)
if !ok {
return
}
@@ -96,7 +96,7 @@ func (s *server) auditPolicyPut(w http.ResponseWriter, r *http.Request) {
s.apiJSON(w, 200, p)
}
func (s *server) auditPurge(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, true)
actor, ok := s.requireAdminMutation(w, r, true)
if !ok {
return
}
@@ -140,7 +140,7 @@ func (s *server) notificationUpdate(w http.ResponseWriter, r *http.Request) {
s.notificationUpsert(w, r, r.PathValue("id"))
}
func (s *server) notificationUpsert(w http.ResponseWriter, r *http.Request, id string) {
actor, ok := s.requireRecentAdmin(w, r, true)
actor, ok := s.requireAdminMutation(w, r, true)
if !ok {
return
}
@@ -162,7 +162,7 @@ func (s *server) notificationUpsert(w http.ResponseWriter, r *http.Request, id s
s.apiJSON(w, status, value)
}
func (s *server) notificationDelete(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, true)
actor, ok := s.requireAdminMutation(w, r, true)
if !ok {
return
}
@@ -174,7 +174,7 @@ func (s *server) notificationDelete(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
func (s *server) notificationTest(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, true)
actor, ok := s.requireAdminMutation(w, r, true)
if !ok {
return
}
@@ -190,7 +190,7 @@ func (s *server) notificationTest(w http.ResponseWriter, r *http.Request) {
}
func (s *server) notificationForm(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, false)
actor, ok := s.requireAdminMutation(w, r, false)
if !ok {
return
}
@@ -204,7 +204,7 @@ func (s *server) notificationForm(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/administration#notifications", http.StatusSeeOther)
}
func (s *server) notificationLanguageForm(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, false)
actor, ok := s.requireAdminMutation(w, r, false)
if !ok {
return
}
@@ -216,7 +216,7 @@ func (s *server) notificationLanguageForm(w http.ResponseWriter, r *http.Request
http.Redirect(w, r, "/administration#notifications", http.StatusSeeOther)
}
func (s *server) notificationTestForm(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, false)
actor, ok := s.requireAdminMutation(w, r, false)
if !ok {
return
}
@@ -228,7 +228,7 @@ func (s *server) notificationTestForm(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/administration#notifications", http.StatusSeeOther)
}
func (s *server) notificationDeleteForm(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, false)
actor, ok := s.requireAdminMutation(w, r, false)
if !ok {
return
}
@@ -240,7 +240,7 @@ func (s *server) notificationDeleteForm(w http.ResponseWriter, r *http.Request)
http.Redirect(w, r, "/administration#notifications", http.StatusSeeOther)
}
func (s *server) auditPolicyForm(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, false)
actor, ok := s.requireAdminMutation(w, r, false)
if !ok {
return
}
@@ -254,7 +254,7 @@ func (s *server) auditPolicyForm(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/administration#audit", http.StatusSeeOther)
}
func (s *server) auditPurgeForm(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, false)
actor, ok := s.requireAdminMutation(w, r, false)
if !ok {
return
}
+2 -2
View File
@@ -17,7 +17,7 @@ var messages = map[string]map[string]string{
"catalog.title": "Catalog", "catalog.eyebrow": "Game library", "catalog.heading": "Catalog", "catalog.search": "Search games", "catalog.search_placeholder": "Search a game", "catalog.scan": "Scan", "catalog.found": "templates found", "catalog.valid": "valid", "catalog.invalid": "invalid", "catalog.no_match": "No matching game", "catalog.empty": "No game available", "catalog.empty_admin": "Add templates to /var/lib/dogama/templates, then use Scan.", "catalog.back": "Back to catalog", "catalog.minimum": "Minimum", "catalog.recommended": "Recommended", "catalog.memory": "Memory", "catalog.storage": "Storage", "catalog.other": "Other", "catalog.deploy": "Deploy", "catalog.deploy_unavailable": "Deployment will be available in the next milestone.",
"deployment.title": "Deploy", "deployment.eyebrow": "New instance", "deployment.heading": "Configure your server", "deployment.name": "Instance name", "deployment.description": "Instance description", "deployment.network": "Network ports", "deployment.network_help": "Host ports publish the template's internal ports. Private ports are not published.", "deployment.host_port": "Host port", "deployment.container_port": "Container port", "deployment.parameters": "Game parameters", "deployment.backup": "Import an external save", "deployment.backup_help": "ZIP, TAR, TAR.GZ or TAR.ZST archives are validated before use.", "deployment.go": "Go",
"settings.game_runtime": "Game server execution", "settings.game_runtime_help": "These defaults apply only to game-server containers.", "settings.game_runtime_uid": "Default UID", "settings.game_runtime_gid": "Default GID", "settings.game_runtime_policy_help": "Templates that allow an administered identity use these values. Templates using the image's native user ignore them.", "settings.save_game_runtime": "Save game server identity",
"settings.title": "Settings", "settings.eyebrow": "Administration", "settings.introduction": "Configure product services without crowding the server overview.", "settings.tabs": "Settings sections", "settings.notifications": "Notifications", "settings.audit": "Audit", "settings.containers": "Game containers", "settings.web_access": "Web access", "settings.require_https": "Require HTTPS", "settings.canonical_url": "Canonical base URL", "settings.web_help": "Configure HTTPS enforcement and the public origin. Configure and verify your HTTPS reverse proxy before enabling this lock.", "settings.save_web": "Save web access", "settings.canonical_help": "Verify the canonical URL works before saving it.", "settings.https_help": "When enabled, unsafe HTTP requests are refused and safe navigation is redirected to HTTPS.",
"settings.title": "Settings", "settings.eyebrow": "Administration", "settings.introduction": "Configure product services without crowding the server overview.", "settings.tabs": "Settings sections", "settings.notifications": "Notifications", "settings.audit": "Audit", "settings.containers": "Game containers", "settings.web_access": "Web access", "settings.sessions": "Sessions", "settings.sessions_help": "Set the absolute maximum lifetime and optional inactivity timeout for authenticated sessions.", "settings.session_max": "Maximum session lifetime", "settings.session_inactivity": "Inactivity timeout", "settings.session_inactivity_enabled": "Disconnect after a period of inactivity", "settings.days": "days", "settings.hours": "hours", "settings.save_sessions": "Save session policy", "settings.require_https": "Require HTTPS", "settings.canonical_url": "Canonical base URL", "settings.web_help": "Configure HTTPS enforcement and the public origin. Configure and verify your HTTPS reverse proxy before enabling this lock.", "settings.save_web": "Save web access", "settings.canonical_help": "Verify the canonical URL works before saving it.", "settings.https_help": "When enabled, unsafe HTTP requests are refused and safe navigation is redirected to HTTPS.",
"settings.channels": "Notification channels", "settings.channels_help": "Secrets remain encrypted and are never displayed after saving.", "settings.no_channels": "No channel configured.", "settings.send_test": "Send test", "settings.delete": "Delete", "settings.add_channel": "Add channel", "settings.name": "Name", "settings.type": "Type", "settings.enabled": "enabled", "settings.disabled": "disabled", "settings.events": "Events (space separated)", "settings.audit_retention": "Audit retention", "settings.audit_help": "Control history size and perform explicit bounded purges.", "settings.view_audit": "View audit events", "settings.retention_days": "Retention days", "settings.maximum_entries": "Maximum entries", "settings.zero_unlimited": "Zero means unlimited and may grow the database indefinitely.", "settings.save_retention": "Save retention", "settings.delete_before": "Delete events before", "settings.confirm_purge": "Confirm bounded audit purge", "settings.purge": "Purge audit events", "settings.container_labels": "Game-container labels", "settings.container_labels_help": "These labels apply only to game-server containers.", "settings.global_labels": "Global labels", "settings.apply": "Application", "settings.next_start": "Apply on next start", "settings.immediate": "Apply immediately", "settings.disconnection": "Immediate application stops and recreates affected containers.", "settings.confirm_disconnection": "I understand the immediate-disconnection warning", "settings.save_labels": "Save game-container labels",
"audit.title": "Audit", "audit.eyebrow": "Administration", "audit.introduction": "Review significant authentication and mutation events.", "audit.actor": "Actor ID", "audit.instance": "Instance ID", "audit.action": "Action", "audit.outcome": "Outcome", "audit.any": "Any", "audit.allowed": "Allowed", "audit.denied": "Denied", "audit.failed": "Failed", "audit.filter": "Filter audit", "audit.time": "Time", "audit.actor_column": "Actor", "audit.instance_column": "Instance", "audit.empty": "No audit event matches these filters.",
"field.username": "Username", "field.password": "Password", "language": "Language", "language.english": "English", "language.french": "French", "language.save": "Save language", "account.signed_in": "Signed in as",
@@ -31,7 +31,7 @@ var messages = map[string]map[string]string{
"dashboard.title": "Tableau de bord", "dashboard.eyebrow": "Tableau de bord", "dashboard.heading": "Serveurs de jeux", "dashboard.introduction": "Vue d'ensemble de toutes vos instances de serveurs de jeux.", "dashboard.search": "Rechercher des instances", "dashboard.search_placeholder": "Rechercher une instance", "dashboard.summary": "Résumé des instances", "dashboard.total": "Instances totales", "dashboard.running": "En cours", "dashboard.stopped": "Arrêtées", "dashboard.updating": "Mise à jour", "dashboard.error": "Erreur", "dashboard.no_match": "Aucune instance correspondante", "dashboard.empty_heading": "Aucune instance déployée", "dashboard.empty_copy": "Vos serveurs de jeux apparaîtront ici lorsqu'ils seront ajoutés depuis le Catalogue.", "dashboard.instances_eyebrow": "Serveurs", "dashboard.instances": "Vos instances", "dashboard.activity_eyebrow": "Opérations", "dashboard.recent_activity": "Activité récente", "dashboard.no_activity": "Aucune activité récente", "dashboard.system_eyebrow": "Santé", "dashboard.system_status": "État du système", "dashboard.agent": "Agent Docker", "dashboard.database": "Base de données", "dashboard.storage": "Stockage", "dashboard.backups": "Sauvegardes", "dashboard.audit_log": "Journal d'audit", "dashboard.online": "En ligne", "dashboard.offline": "Hors ligne", "dashboard.healthy": "Saine", "dashboard.unavailable": "Indisponible", "dashboard.last_backup": "Dernière sauvegarde", "dashboard.no_backup": "Aucune sauvegarde", "dashboard.retention_days": "jours de rétention", "dashboard.unlimited": "Rétention illimitée", "dashboard.by": "par",
"catalog.title": "Catalogue", "catalog.eyebrow": "Bibliothèque de jeux", "catalog.heading": "Catalogue", "catalog.search": "Rechercher des jeux", "catalog.search_placeholder": "Rechercher un jeu", "catalog.scan": "Scanner", "catalog.found": "templates trouvés", "catalog.valid": "valides", "catalog.invalid": "invalides", "catalog.no_match": "Aucun jeu correspondant", "catalog.empty": "Aucun jeu disponible", "catalog.empty_admin": "Ajoutez des templates dans /var/lib/dogama/templates, puis utilisez Scanner.", "catalog.back": "Retour au catalogue", "catalog.minimum": "Minimum", "catalog.recommended": "Recommandé", "catalog.memory": "Mémoire", "catalog.storage": "Stockage", "catalog.other": "Autre", "catalog.deploy": "Déployer", "catalog.deploy_unavailable": "Le déploiement sera disponible au prochain bloc.",
"deployment.title": "Déployer", "deployment.eyebrow": "Nouvelle instance", "deployment.heading": "Configurer votre serveur", "deployment.name": "Nom de l’instance", "deployment.description": "Description de l’instance", "deployment.network": "Ports réseau", "deployment.network_help": "Les ports hôte publient les ports internes du template. Les ports privés ne sont pas publiés.", "deployment.host_port": "Port hôte", "deployment.container_port": "Port du conteneur", "deployment.parameters": "Paramètres du jeu", "deployment.backup": "Importer une sauvegarde externe", "deployment.backup_help": "Les archives ZIP, TAR, TAR.GZ et TAR.ZST sont validées avant utilisation.", "deployment.go": "Go",
"settings.title": "Paramètres", "settings.eyebrow": "Administration", "settings.introduction": "Configurez les services du produit sans encombrer la vue des serveurs.", "settings.tabs": "Sections des paramètres", "settings.notifications": "Notifications", "settings.audit": "Audit", "settings.containers": "Conteneurs de jeux", "settings.web_access": "Accès Web", "settings.require_https": "Exiger HTTPS", "settings.canonical_url": "URL de base canonique", "settings.web_help": "Configurez l'obligation HTTPS et l'origine publique. Configurez et vérifiez votre proxy inverse HTTPS avant d'activer ce verrouillage.", "settings.save_web": "Enregistrer l'accès Web", "settings.canonical_help": "Vérifiez que l'URL canonique fonctionne avant de l'enregistrer.", "settings.https_help": "Une fois activé, les requêtes HTTP non sûres sont refusées et les navigations sûres sont redirigées vers HTTPS.",
"settings.title": "Paramètres", "settings.eyebrow": "Administration", "settings.introduction": "Configurez les services du produit sans encombrer la vue des serveurs.", "settings.tabs": "Sections des paramètres", "settings.notifications": "Notifications", "settings.audit": "Audit", "settings.containers": "Conteneurs de jeux", "settings.web_access": "Accès Web", "settings.sessions": "Sessions", "settings.sessions_help": "Définissez la durée maximale et l'expiration optionnelle après inactivité des sessions authentifiées.", "settings.session_max": "Durée maximale d'une session", "settings.session_inactivity": "Expiration après inactivité", "settings.session_inactivity_enabled": "Déconnecter après une période d'inactivité", "settings.days": "jours", "settings.hours": "heures", "settings.save_sessions": "Enregistrer la politique des sessions", "settings.require_https": "Exiger HTTPS", "settings.canonical_url": "URL de base canonique", "settings.web_help": "Configurez l'obligation HTTPS et l'origine publique. Configurez et vérifiez votre proxy inverse HTTPS avant d'activer ce verrouillage.", "settings.save_web": "Enregistrer l'accès Web", "settings.canonical_help": "Vérifiez que l'URL canonique fonctionne avant de l'enregistrer.", "settings.https_help": "Une fois activé, les requêtes HTTP non sûres sont refusées et les navigations sûres sont redirigées vers HTTPS.",
"settings.channels": "Canaux de notification", "settings.channels_help": "Les secrets restent chiffrés et ne sont jamais affichés après enregistrement.", "settings.no_channels": "Aucun canal configuré.", "settings.send_test": "Envoyer un test", "settings.delete": "Supprimer", "settings.add_channel": "Ajouter un canal", "settings.name": "Nom", "settings.type": "Type", "settings.enabled": "activé", "settings.disabled": "désactivé", "settings.events": "Événements (séparés par des espaces)", "settings.audit_retention": "Rétention de l'audit", "settings.audit_help": "Contrôlez la taille de l'historique et effectuez des purges limitées explicites.", "settings.view_audit": "Voir les événements d'audit", "settings.retention_days": "Jours de rétention", "settings.maximum_entries": "Nombre maximal d'entrées", "settings.zero_unlimited": "Zéro signifie illimité et peut faire croître la base indéfiniment.", "settings.save_retention": "Enregistrer la rétention", "settings.delete_before": "Supprimer les événements antérieurs au", "settings.confirm_purge": "Confirmer la purge limitée de l'audit", "settings.purge": "Purger les événements d'audit", "settings.container_labels": "Étiquettes des conteneurs de jeux", "settings.container_labels_help": "Ces étiquettes s'appliquent uniquement aux conteneurs de serveurs de jeux.", "settings.global_labels": "Étiquettes globales", "settings.apply": "Application", "settings.next_start": "Appliquer au prochain démarrage", "settings.immediate": "Appliquer immédiatement", "settings.disconnection": "L'application immédiate arrête et recrée les conteneurs concernés.", "settings.confirm_disconnection": "Je comprends l'avertissement de déconnexion immédiate", "settings.save_labels": "Enregistrer les étiquettes des conteneurs",
"audit.title": "Audit", "audit.eyebrow": "Administration", "audit.introduction": "Consultez les événements importants d'authentification et de modification.", "audit.actor": "ID de l'acteur", "audit.instance": "ID de l'instance", "audit.action": "Action", "audit.outcome": "Résultat", "audit.any": "Tous", "audit.allowed": "Autorisé", "audit.denied": "Refusé", "audit.failed": "Échec", "audit.filter": "Filtrer l'audit", "audit.time": "Heure", "audit.actor_column": "Acteur", "audit.instance_column": "Instance", "audit.empty": "Aucun événement d'audit ne correspond à ces filtres.",
"field.username": "Nom d'utilisateur", "field.password": "Mot de passe", "language": "Langue", "language.english": "Anglais", "language.french": "Français", "language.save": "Enregistrer la langue", "account.signed_in": "Connecté en tant que",
+13 -10
View File
@@ -95,6 +95,7 @@ type pageData struct {
GlobalLabels string
GameContainerUID uint32
GameContainerGID uint32
SessionPolicy auth.SessionPolicy
IsAdmin bool
Channels []notification.Channel
NotificationPreferences map[string]bool
@@ -273,7 +274,7 @@ func newHandlerServices(authService *auth.Service, repository repository, lifecy
}
func newHandlerServicesWithCatalog(authService *auth.Service, repository repository, lifecycle *instance.LifecycleService, backupService *backup.Service, importService *importexport.Service, auditService *audit.Service, notificationService *notification.Service, scanner func(context.Context) (catalog.ScanResult, error), logger *slog.Logger) (http.Handler, error) {
templates, err := template.New("views").Funcs(template.FuncMap{"msg": message, "statusClass": statusClass, "statusLabel": statusLabel, "activityLabel": activityLabel, "relativeTime": relativeTime, "storagePercent": storagePercent, "formatBytes": formatBytes, "plus": func(a, b int) int { return a + b }, "minus": func(a, b int) int { return a - b }}).ParseFS(assets, "templates/*.html")
templates, err := template.New("views").Funcs(template.FuncMap{"msg": message, "statusClass": statusClass, "statusLabel": statusLabel, "activityLabel": activityLabel, "relativeTime": relativeTime, "storagePercent": storagePercent, "formatBytes": formatBytes, "divDuration": func(value time.Duration, divisor int64) int64 { return int64(value) / divisor }, "plus": func(a, b int) int { return a + b }, "minus": func(a, b int) int { return a - b }}).ParseFS(assets, "templates/*.html")
if err != nil {
return nil, err
}
@@ -300,6 +301,8 @@ func newHandlerServicesWithCatalog(authService *auth.Service, repository reposit
mux.HandleFunc("PUT /api/v1/admin/users/{id}", s.userUpdate)
mux.HandleFunc("GET /api/v1/admin/game-container-labels", s.globalLabelsGet)
mux.HandleFunc("PUT /api/v1/admin/game-container-labels", s.globalLabelsPut)
mux.HandleFunc("GET /api/v1/admin/session-policy", s.sessionPolicyGet)
mux.HandleFunc("PUT /api/v1/admin/session-policy", s.sessionPolicyPut)
if auditService != nil {
mux.HandleFunc("GET /api/v1/admin/audit", s.auditList)
mux.HandleFunc("GET /api/v1/admin/audit-policy", s.auditPolicyGet)
@@ -366,6 +369,7 @@ func newHandlerServicesWithCatalog(authService *auth.Service, repository reposit
mux.HandleFunc("POST /admin/game-container-labels", s.globalLabelsForm)
mux.HandleFunc("POST /admin/web-access", s.webAccessForm)
mux.HandleFunc("POST /admin/game-container-runtime", s.gameContainerRuntimeForm)
mux.HandleFunc("POST /admin/session-policy", s.sessionPolicyForm)
mux.HandleFunc("POST /admin/notification-channels", s.notificationForm)
mux.HandleFunc("POST /admin/notification-language", s.notificationLanguageForm)
mux.HandleFunc("POST /admin/notification-channels/{id}/test", s.notificationTestForm)
@@ -617,7 +621,7 @@ func (s *server) globalLabelsPut(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
if err := s.permissions.RequireRecentAdmin(actor); err != nil {
if err := requireAdmin(actor); err != nil {
s.authorizationProblem(w, err)
return
}
@@ -670,8 +674,8 @@ func (s *server) globalLabelsForm(w http.ResponseWriter, r *http.Request) {
s.problem(w, http.StatusForbidden, message("error.csrf"))
return
}
if err := s.permissions.RequireRecentAdmin(actor); err != nil {
s.problem(w, http.StatusForbidden, "Recent administrator authentication is required.")
if err := requireAdmin(actor); err != nil {
s.problem(w, http.StatusForbidden, "Administrator access is required.")
return
}
labels, err := instance.ParseLabels(r.FormValue("labels"))
@@ -1032,7 +1036,7 @@ func (s *server) instanceInstall(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
if err := s.permissions.RequireRecentAdmin(actor); err != nil {
if err := requireAdmin(actor); err != nil {
s.authorizationProblem(w, err)
return
}
@@ -1328,7 +1332,7 @@ func (s *server) importCreate(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
if err := s.permissions.RequireRecentAdmin(actor); err != nil {
if err := requireAdmin(actor); err != nil {
s.authorizationProblem(w, err)
return
}
@@ -1385,7 +1389,7 @@ func (s *server) userCreate(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
if err := s.permissions.RequireRecentAdmin(actor); err != nil {
if err := requireAdmin(actor); err != nil {
s.authorizationProblem(w, err)
return
}
@@ -1412,7 +1416,7 @@ func (s *server) userUpdate(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
if err := s.permissions.RequireRecentAdmin(actor); err != nil {
if err := requireAdmin(actor); err != nil {
s.authorizationProblem(w, err)
return
}
@@ -1622,8 +1626,6 @@ func (s *server) authorizationProblem(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, authorization.ErrDenied):
status, code = http.StatusForbidden, "permission_denied"
case errors.Is(err, authorization.ErrRecentAuth):
status, code = http.StatusForbidden, "reauthentication_required"
case errors.Is(err, authorization.ErrInvalidInput):
status, code = http.StatusUnprocessableEntity, "invalid_request"
case errors.Is(err, authorization.ErrNotFound):
@@ -2278,6 +2280,7 @@ func (s *server) settingsPage(w http.ResponseWriter, r *http.Request) {
policy, _ := settings.GetWebAccessPolicy(r.Context())
data.RequireHTTPS, data.CanonicalURL = policy.RequireHTTPS, policy.CanonicalURL
}
data.SessionPolicy, _ = s.auth.SessionPolicy(r.Context())
s.render(w, http.StatusOK, "settings.html", data)
}
+14
View File
@@ -387,6 +387,7 @@ func TestNotificationAndAuditAdministration(t *testing.T) {
for _, expected := range []string{
"Notification channels", "Web access", "href=\"#audit\"", "href=\"/audit\"",
"id=\"notifications\"", "id=\"notification-general\"", "id=\"email\"", "id=\"discord\"", "id=\"gotify\"", "id=\"game-runtime\"", "name=\"uid\"", "name=\"gid\"",
"Sessions", "name=\"max_lifetime_days\"", "name=\"inactivity_timeout_hours\"", "inactivity_enabled",
} {
if !strings.Contains(settingsBody, expected) {
t.Fatalf("settings UI section %q missing", expected)
@@ -400,6 +401,19 @@ func TestNotificationAndAuditAdministration(t *testing.T) {
}
invalidRuntime := formRequest(t, handler, "/admin/game-container-runtime", url.Values{"csrf_token": {session.CSRFToken}, "uid": {"1000:1000"}, "gid": {"5678"}}, cookie, &http.Cookie{Name: csrfCookie, Value: session.CSRFToken})
assertStatus(t, invalidRuntime, http.StatusUnprocessableEntity)
policySave := formRequest(t, handler, "/admin/session-policy", url.Values{"csrf_token": {session.CSRFToken}, "max_lifetime_days": {"14"}, "inactivity_timeout_hours": {"48"}, "inactivity_enabled": {"on"}}, cookie, &http.Cookie{Name: csrfCookie, Value: session.CSRFToken})
assertStatus(t, policySave, http.StatusSeeOther)
policy, err := authService.SessionPolicy(ctx)
if err != nil || policy.MaxLifetime != 14*24*time.Hour || policy.InactivityTimeout != 48*time.Hour || !policy.InactivityEnabled {
t.Fatalf("session policy = %#v error=%v", policy, err)
}
settingsReload := request(t, handler, http.MethodGet, "/administration", []*http.Cookie{cookie, &http.Cookie{Name: csrfCookie, Value: session.CSRFToken}})
assertStatus(t, settingsReload, http.StatusOK)
if !strings.Contains(settingsReload.Body.String(), "value=\"14\"") || !strings.Contains(settingsReload.Body.String(), "value=\"48\"") {
t.Fatal("saved session policy was not rendered after reload")
}
forgedPolicy := formRequest(t, handler, "/admin/session-policy", url.Values{"csrf_token": {"forged"}, "max_lifetime_days": {"7"}, "inactivity_timeout_hours": {"24"}}, cookie, &http.Cookie{Name: csrfCookie, Value: session.CSRFToken})
assertStatus(t, forgedPolicy, http.StatusForbidden)
notificationLanguage := formRequest(t, handler, "/admin/notification-language", url.Values{"csrf_token": {session.CSRFToken}, "language": {"fr"}}, cookie, &http.Cookie{Name: csrfCookie, Value: session.CSRFToken})
assertStatus(t, notificationLanguage, http.StatusSeeOther)
if got := notificationLanguage.Result().Header.Get("Location"); got != "/administration#notifications" {
+87
View File
@@ -0,0 +1,87 @@
package web
import (
"net/http"
"strconv"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
)
func (s *server) sessionPolicyGet(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAPIUser(w, r, true); !ok {
return
}
policy, err := s.auth.SessionPolicy(r.Context())
if err != nil {
s.apiProblem(w, http.StatusInternalServerError, "session_policy_unavailable", "The session policy is unavailable.")
return
}
s.apiJSON(w, http.StatusOK, sessionPolicyJSON(policy))
}
func (s *server) sessionPolicyPut(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAdminMutation(w, r, true)
if !ok {
return
}
var input struct {
MaxLifetimeSeconds int64 `json:"max_lifetime_seconds"`
InactivitySeconds int64 `json:"inactivity_timeout_seconds"`
InactivityEnabled bool `json:"inactivity_enabled"`
}
if !s.decodeAPIJSON(w, r, &input) {
return
}
policy, err := policyFromSeconds(input.MaxLifetimeSeconds, input.InactivitySeconds, input.InactivityEnabled)
if err != nil {
s.apiProblem(w, http.StatusUnprocessableEntity, "invalid_session_policy", err.Error())
return
}
if err := s.auth.SetSessionPolicy(r.Context(), policy); err != nil {
s.apiProblem(w, http.StatusUnprocessableEntity, "invalid_session_policy", err.Error())
return
}
s.recordAudit(r, actor, "session.policy.update", "allowed", nil)
s.apiJSON(w, http.StatusOK, sessionPolicyJSON(policy))
}
func (s *server) sessionPolicyForm(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAdminMutation(w, r, false)
if !ok {
return
}
maxDays, maxErr := strconv.Atoi(r.FormValue("max_lifetime_days"))
inactivityHours, inactivityErr := strconv.Atoi(r.FormValue("inactivity_timeout_hours"))
if maxErr != nil || inactivityErr != nil || maxDays <= 0 || inactivityHours <= 0 {
s.problem(w, http.StatusUnprocessableEntity, "Session durations must be positive whole units.")
return
}
policy, err := policyFromSeconds(int64(maxDays)*24*60*60, int64(inactivityHours)*60*60, r.FormValue("inactivity_enabled") == "on")
if err != nil {
s.problem(w, http.StatusUnprocessableEntity, err.Error())
return
}
if err := s.auth.SetSessionPolicy(r.Context(), policy); err != nil {
s.problem(w, http.StatusUnprocessableEntity, err.Error())
return
}
s.recordAudit(r, actor, "session.policy.update", "allowed", nil)
http.Redirect(w, r, "/administration#sessions", http.StatusSeeOther)
}
func policyFromSeconds(maxLifetime, inactivity int64, enabled bool) (auth.SessionPolicy, error) {
if maxLifetime <= 0 || inactivity <= 0 {
return auth.SessionPolicy{}, auth.SessionPolicy{}.Validate()
}
policy := auth.SessionPolicy{MaxLifetime: time.Duration(maxLifetime) * time.Second, InactivityTimeout: time.Duration(inactivity) * time.Second, InactivityEnabled: enabled}
return policy, policy.Validate()
}
func sessionPolicyJSON(policy auth.SessionPolicy) map[string]any {
return map[string]any{
"max_lifetime_seconds": int64(policy.MaxLifetime / time.Second),
"inactivity_timeout_seconds": int64(policy.InactivityTimeout / time.Second),
"inactivity_enabled": policy.InactivityEnabled,
}
}
+2 -2
View File
@@ -13,7 +13,7 @@ func (s *server) templateRepositoriesPage(w http.ResponseWriter, r *http.Request
}
func (s *server) templateRepositoryCreateForm(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, false)
actor, ok := s.requireAdminMutation(w, r, false)
if !ok {
return
}
@@ -30,7 +30,7 @@ func (s *server) templateRepositoryCreateForm(w http.ResponseWriter, r *http.Req
}
func (s *server) templateRepositoryDeleteForm(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireRecentAdmin(w, r, false)
actor, ok := s.requireAdminMutation(w, r, false)
if !ok {
return
}
+25
View File
@@ -34,6 +34,9 @@
<a href="#web-access">
{{.Msg "settings.web_access"}}
</a>
<a href="#sessions">
{{.Msg "settings.sessions"}}
</a>
<a href="#notifications">
{{.Msg "settings.notifications"}}
</a>
@@ -50,6 +53,28 @@
{{.Msg "settings.game_runtime"}}
</a>
</nav>
<section class="panel settings-section" id="sessions">
<h2>{{.Msg "settings.sessions"}}</h2>
<p>{{.Msg "settings.sessions_help"}}</p>
<form method="post" action="/admin/session-policy">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label>
{{.Msg "settings.session_max"}}
<input name="max_lifetime_days" type="number" min="1" max="90" step="1" value="{{divDuration .SessionPolicy.MaxLifetime 86400000000000}}">
</label>
<p class="help">{{.Msg "settings.days"}}</p>
<label>
<input type="checkbox" name="inactivity_enabled"{{if .SessionPolicy.InactivityEnabled}} checked{{end}}>
{{.Msg "settings.session_inactivity_enabled"}}
</label>
<label>
{{.Msg "settings.session_inactivity"}}
<input name="inactivity_timeout_hours" type="number" min="1" max="720" step="1" value="{{divDuration .SessionPolicy.InactivityTimeout 3600000000000}}">
</label>
<p class="help">{{.Msg "settings.hours"}}</p>
<button type="submit">{{.Msg "settings.save_sessions"}}</button>
</form>
</section>
<section class="panel settings-section" id="web-access">
<h2>
{{.Msg "settings.web_access"}}
+2 -3
View File
@@ -3,7 +3,6 @@ package web
import (
"errors"
"net/http"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/auth"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/authorization"
@@ -53,8 +52,8 @@ func (s *server) adminUserForm(w http.ResponseWriter, r *http.Request) (auth.Use
s.problem(w, http.StatusForbidden, message("error.csrf"))
return auth.User{}, false
}
if (s.permissions != nil && s.permissions.RequireRecentAdmin(actor) != nil) || (s.permissions == nil && (actor.AuthenticatedAt.IsZero() || time.Since(actor.AuthenticatedAt) > authorization.RecentAuthenticationWindow)) {
s.problem(w, http.StatusForbidden, "Recent administrator authentication is required.")
if actor.Role != "admin" || actor.Disabled {
s.problem(w, http.StatusForbidden, "Administrator access is required.")
return auth.User{}, false
}
return actor, true
+2 -2
View File
@@ -19,8 +19,8 @@ func (s *server) gameContainerRuntimeForm(w http.ResponseWriter, r *http.Request
s.problem(w, http.StatusForbidden, localized(s.language(r, ""), "error.csrf"))
return
}
if err := s.permissions.RequireRecentAdmin(user); err != nil {
s.problem(w, http.StatusForbidden, "Recent administrator authentication is required.")
if user.Role != "admin" || user.Disabled {
s.problem(w, http.StatusForbidden, "Administrator access is required.")
return
}
parse := func(name string) (uint32, error) {