Merge pull request 'feat(auth): add configurable session policy' (#47) from codex/block-19-session-policy into main
CI / validate (push) Canceled after 0s
CI / validate (push) Canceled after 0s
Reviewed-on: #47 Reviewed-by: tony <1+tony@noreply.localhost>
This commit was merged in pull request #47.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user