- Admin-configurable session TTLs (access: 5m–60m, refresh: 1h–90d) stored in global_settings under "session" key - "Keep me logged in" checkbox on login form; unchecked caps refresh token at 24h, checked uses full admin-configured TTL - generateTokens() reads config instead of hardcoded 15m/7d; expires_in and refresh_expires_in in JSON response reflect actual TTLs - Cookie max-age tracks chosen refresh lifetime (not hardcoded 604800) - Optional idle timeout: admin toggle + configurable duration; server rejects refresh if last_activity_at exceeds threshold - Client SDK activity ping (POST /auth/activity, debounced 1/min) - Admin Settings > Session section with dropdowns for all three knobs - 7 new unit tests for duration parsing, clamping, and defaults Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
104 lines
2.5 KiB
Go
104 lines
2.5 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"armature/store"
|
|
)
|
|
|
|
// SessionConfig holds parsed, clamped session TTL settings.
|
|
type SessionConfig struct {
|
|
AccessTokenTTL time.Duration
|
|
RefreshTokenTTL time.Duration
|
|
IdleTimeout time.Duration // 0 = disabled
|
|
}
|
|
|
|
// Defaults & clamp bounds
|
|
const (
|
|
defaultAccessTTL = 15 * time.Minute
|
|
defaultRefreshTTL = 7 * 24 * time.Hour // 7d
|
|
defaultIdleTimeout = 0 // disabled
|
|
|
|
minAccessTTL = 5 * time.Minute
|
|
maxAccessTTL = 60 * time.Minute
|
|
minRefreshTTL = 1 * time.Hour
|
|
maxRefreshTTL = 90 * 24 * time.Hour // 90d
|
|
minIdleTTL = 5 * time.Minute
|
|
maxIdleTTL = 24 * time.Hour
|
|
)
|
|
|
|
// LoadSessionConfig reads session settings from the global_settings table.
|
|
// Returns defaults if the key is missing or values are unparseable.
|
|
func LoadSessionConfig(ctx context.Context, gc store.GlobalConfigStore) SessionConfig {
|
|
sc := SessionConfig{
|
|
AccessTokenTTL: defaultAccessTTL,
|
|
RefreshTokenTTL: defaultRefreshTTL,
|
|
IdleTimeout: defaultIdleTimeout,
|
|
}
|
|
|
|
raw, err := gc.Get(ctx, "session")
|
|
if err != nil || raw == nil {
|
|
return sc
|
|
}
|
|
|
|
if v, ok := raw["access_token_ttl"].(string); ok && v != "" {
|
|
if d, err := parseDurationString(v); err == nil {
|
|
sc.AccessTokenTTL = clamp(d, minAccessTTL, maxAccessTTL)
|
|
}
|
|
}
|
|
|
|
if v, ok := raw["refresh_token_ttl"].(string); ok && v != "" {
|
|
if d, err := parseDurationString(v); err == nil {
|
|
sc.RefreshTokenTTL = clamp(d, minRefreshTTL, maxRefreshTTL)
|
|
}
|
|
}
|
|
|
|
if v, ok := raw["idle_timeout"].(string); ok && v != "" {
|
|
if d, err := parseDurationString(v); err == nil {
|
|
sc.IdleTimeout = clamp(d, minIdleTTL, maxIdleTTL)
|
|
}
|
|
}
|
|
|
|
return sc
|
|
}
|
|
|
|
// parseDurationString parses strings like "15m", "2h", "7d", "30d".
|
|
// Supports suffixes: s (seconds), m (minutes), h (hours), d (days).
|
|
func parseDurationString(s string) (time.Duration, error) {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return 0, fmt.Errorf("empty duration")
|
|
}
|
|
|
|
// Try Go's standard parser first (handles "15m", "2h30m", etc.)
|
|
if d, err := time.ParseDuration(s); err == nil {
|
|
return d, nil
|
|
}
|
|
|
|
// Handle "Xd" suffix (days)
|
|
if strings.HasSuffix(s, "d") {
|
|
numStr := strings.TrimSuffix(s, "d")
|
|
n, err := strconv.ParseFloat(numStr, 64)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid duration: %s", s)
|
|
}
|
|
return time.Duration(n * float64(24*time.Hour)), nil
|
|
}
|
|
|
|
return 0, fmt.Errorf("invalid duration: %s", s)
|
|
}
|
|
|
|
func clamp(d, min, max time.Duration) time.Duration {
|
|
if d < min {
|
|
return min
|
|
}
|
|
if d > max {
|
|
return max
|
|
}
|
|
return d
|
|
}
|