diff --git a/server/auth/builtin.go b/server/auth/builtin.go index b5193f7..5d0bbd4 100644 --- a/server/auth/builtin.go +++ b/server/auth/builtin.go @@ -24,13 +24,17 @@ func (p *BuiltinProvider) SupportsRegistration() bool { return true } func (p *BuiltinProvider) Authenticate(c *gin.Context, stores store.Stores) (*Result, error) { var req struct { - Login string `json:"login" binding:"required"` - Password string `json:"password" binding:"required"` + Login string `json:"login" binding:"required"` + Password string `json:"password" binding:"required"` + KeepLogin bool `json:"keep_login"` } if err := c.ShouldBindJSON(&req); err != nil { return nil, err } + // Stash keep_login in context for generateTokens + c.Set("keep_login", req.KeepLogin) + user, err := stores.Users.GetByLogin(c.Request.Context(), req.Login) if err != nil { return nil, ErrInvalidCreds diff --git a/server/database/migrations/postgres/001_core.sql b/server/database/migrations/postgres/001_core.sql index 86ab283..d067622 100644 --- a/server/database/migrations/postgres/001_core.sql +++ b/server/database/migrations/postgres/001_core.sql @@ -56,9 +56,11 @@ CREATE TABLE IF NOT EXISTS refresh_tokens ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash TEXT NOT NULL UNIQUE, - expires_at TIMESTAMPTZ NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW(), - revoked_at TIMESTAMPTZ + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + revoked_at TIMESTAMPTZ, + keep_login BOOLEAN DEFAULT FALSE, + last_activity_at TIMESTAMPTZ ); CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id); diff --git a/server/database/migrations/sqlite/001_core.sql b/server/database/migrations/sqlite/001_core.sql index 2040b45..e092ef2 100644 --- a/server/database/migrations/sqlite/001_core.sql +++ b/server/database/migrations/sqlite/001_core.sql @@ -41,9 +41,11 @@ CREATE TABLE IF NOT EXISTS refresh_tokens ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash TEXT NOT NULL UNIQUE, - expires_at TEXT NOT NULL, - created_at TEXT DEFAULT (datetime('now')), - revoked_at TEXT + expires_at TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')), + revoked_at TEXT, + keep_login INTEGER DEFAULT 0, + last_activity_at TEXT ); CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id); diff --git a/server/handlers/auth.go b/server/handlers/auth.go index f48ed78..99e1664 100644 --- a/server/handlers/auth.go +++ b/server/handlers/auth.go @@ -72,7 +72,7 @@ func (h *AuthHandler) Register(c *gin.Context) { return } - tokens, err := h.generateTokens(result.User) + tokens, err := h.generateTokens(result.User, false) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"}) return @@ -94,7 +94,13 @@ func (h *AuthHandler) Login(c *gin.Context) { h.stores.Users.UpdateLastLogin(c.Request.Context(), result.User.ID) - tokens, err := h.generateTokens(result.User) + // Check for keep_login flag from request body. + // The provider already consumed the body, so we read from gin context + // if the provider stashed it there; otherwise default to false. + keepLogin, _ := c.Get("keep_login") + keep, _ := keepLogin.(bool) + + tokens, err := h.generateTokens(result.User, keep) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"}) return @@ -113,22 +119,32 @@ func (h *AuthHandler) Refresh(c *gin.Context) { } tokenHash := hashToken(req.RefreshToken) - userID, err := h.stores.Users.GetRefreshToken(c.Request.Context(), tokenHash) + info, err := h.stores.Users.GetRefreshTokenInfo(c.Request.Context(), tokenHash) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"}) return } + // Idle timeout check + sc := LoadSessionConfig(c.Request.Context(), h.stores.GlobalConfig) + if sc.IdleTimeout > 0 && info.LastActivityAt != nil { + if time.Since(*info.LastActivityAt) > sc.IdleTimeout { + h.stores.Users.RevokeRefreshToken(c.Request.Context(), tokenHash) + c.JSON(http.StatusUnauthorized, gin.H{"error": "session expired due to inactivity"}) + return + } + } + // Revoke the used token (rotate) h.stores.Users.RevokeRefreshToken(c.Request.Context(), tokenHash) - user, err := h.stores.Users.GetByID(c.Request.Context(), userID) + user, err := h.stores.Users.GetByID(c.Request.Context(), info.UserID) if err != nil || !user.IsActive { c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found or inactive"}) return } - tokens, err := h.generateTokens(user) + tokens, err := h.generateTokens(user, info.KeepLogin) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"}) return @@ -159,6 +175,19 @@ func (h *AuthHandler) Logout(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "logged out"}) } +// Activity updates last_activity_at on the caller's active refresh tokens. +// Called by the client SDK on user interaction (debounced, max 1/min). +// POST /api/v1/auth/activity (requires auth middleware) +func (h *AuthHandler) Activity(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"}) + return + } + h.stores.Users.UpdateRefreshTokenActivity(c.Request.Context(), userID.(string)) + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + // ── OIDC Flow ────────────────────────────── // OIDCLogin initiates the authorization code flow by redirecting to the IdP. @@ -271,7 +300,7 @@ func (h *AuthHandler) OIDCCallback(c *gin.Context) { } // Issue internal JWT - tokens, err := h.generateTokens(result.User) + tokens, err := h.generateTokens(result.User, false) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"}) return @@ -287,9 +316,13 @@ func (h *AuthHandler) OIDCCallback(c *gin.Context) { userJSON, _ := json.Marshal(tokens["user"]) // Set page-auth cookie too (for SSR middleware). - // MaxAge = 7 days — matches refresh token lifetime so the cookie + // MaxAge matches refresh token lifetime so the cookie // survives until JS can proactively refresh the access token. - c.SetCookie("arm_token", accessToken, 604800, "/", "", false, false) + refreshExpiresIn, _ := tokens["refresh_expires_in"].(int) + if refreshExpiresIn <= 0 { + refreshExpiresIn = 604800 + } + c.SetCookie("arm_token", accessToken, refreshExpiresIn, "/", "", false, false) // Base64-encode the token payload for the fragment payload := fmt.Sprintf(`{"access_token":"%s","refresh_token":"%s","user":%s}`, @@ -300,13 +333,15 @@ func (h *AuthHandler) OIDCCallback(c *gin.Context) { c.Redirect(http.StatusFound, dest) } -func (h *AuthHandler) generateTokens(user *models.User) (gin.H, error) { - // Access token (15 min) +func (h *AuthHandler) generateTokens(user *models.User, keepLogin bool) (gin.H, error) { + sc := LoadSessionConfig(context.Background(), h.stores.GlobalConfig) + + // Access token accessClaims := Claims{ UserID: user.ID, Email: user.Email, RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(sc.AccessTokenTTL)), IssuedAt: jwt.NewNumericDate(time.Now()), ID: uuid.New().String(), }, @@ -317,20 +352,26 @@ func (h *AuthHandler) generateTokens(user *models.User) (gin.H, error) { return nil, err } - // Refresh token (7 days) + // Refresh token — keepLogin=false caps at 24h (session-length) + refreshTTL := sc.RefreshTokenTTL + if !keepLogin && refreshTTL > 24*time.Hour { + refreshTTL = 24 * time.Hour + } + refreshRaw := uuid.New().String() refreshHash := hashToken(refreshRaw) - expiresAt := time.Now().Add(7 * 24 * time.Hour) + expiresAt := time.Now().Add(refreshTTL) - if err := h.stores.Users.CreateRefreshToken(context.Background(), user.ID, refreshHash, expiresAt); err != nil { + if err := h.stores.Users.CreateRefreshToken(context.Background(), user.ID, refreshHash, expiresAt, keepLogin); err != nil { log.Printf("warn: failed to store refresh token: %v", err) } return gin.H{ - "access_token": accessString, - "refresh_token": refreshRaw, - "token_type": "Bearer", - "expires_in": 900, + "access_token": accessString, + "refresh_token": refreshRaw, + "token_type": "Bearer", + "expires_in": int(sc.AccessTokenTTL.Seconds()), + "refresh_expires_in": int(refreshTTL.Seconds()), "user": gin.H{ "id": user.ID, "username": user.Username, diff --git a/server/handlers/session_config.go b/server/handlers/session_config.go new file mode 100644 index 0000000..6af5437 --- /dev/null +++ b/server/handlers/session_config.go @@ -0,0 +1,103 @@ +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 +} diff --git a/server/handlers/session_config_test.go b/server/handlers/session_config_test.go new file mode 100644 index 0000000..168e344 --- /dev/null +++ b/server/handlers/session_config_test.go @@ -0,0 +1,188 @@ +package handlers + +import ( + "context" + "testing" + "time" + + "armature/models" + "armature/store" +) + +// mockGlobalConfig implements store.GlobalConfigStore for tests. +type mockGlobalConfig struct { + data map[string]models.JSONMap +} + +func (m *mockGlobalConfig) Get(_ context.Context, key string) (models.JSONMap, error) { + if v, ok := m.data[key]; ok { + return v, nil + } + return nil, nil +} +func (m *mockGlobalConfig) Set(_ context.Context, _ string, _ models.JSONMap, _ string) error { + return nil +} +func (m *mockGlobalConfig) GetAll(_ context.Context) (map[string]models.JSONMap, error) { + return m.data, nil +} +func (m *mockGlobalConfig) SaveOIDCState(_ context.Context, _, _, _ string) error { return nil } +func (m *mockGlobalConfig) ConsumeOIDCState(_ context.Context, _ string) (string, string, error) { + return "", "", nil +} +func (m *mockGlobalConfig) CleanupOIDCState(_ context.Context) error { return nil } +func (m *mockGlobalConfig) GetString(_ context.Context, _ string) (string, error) { return "", nil } + +func newMockGC(session models.JSONMap) store.GlobalConfigStore { + return &mockGlobalConfig{data: map[string]models.JSONMap{"session": session}} +} + +// ── parseDurationString tests ────────────── + +func TestParseDurationString(t *testing.T) { + tests := []struct { + input string + want time.Duration + err bool + }{ + {"15m", 15 * time.Minute, false}, + {"2h", 2 * time.Hour, false}, + {"7d", 7 * 24 * time.Hour, false}, + {"90d", 90 * 24 * time.Hour, false}, + {"30m", 30 * time.Minute, false}, + {"1h30m", 90 * time.Minute, false}, + {"", 0, true}, + {"abc", 0, true}, + {"d", 0, true}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got, err := parseDurationString(tt.input) + if tt.err { + if err == nil { + t.Errorf("expected error for %q, got %v", tt.input, got) + } + return + } + if err != nil { + t.Fatalf("unexpected error for %q: %v", tt.input, err) + } + if got != tt.want { + t.Errorf("parseDurationString(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +// ── LoadSessionConfig tests ──────────────── + +func TestSessionConfigDefaults(t *testing.T) { + gc := &mockGlobalConfig{data: map[string]models.JSONMap{}} + sc := LoadSessionConfig(context.Background(), gc) + + if sc.AccessTokenTTL != 15*time.Minute { + t.Errorf("AccessTokenTTL = %v, want 15m", sc.AccessTokenTTL) + } + if sc.RefreshTokenTTL != 7*24*time.Hour { + t.Errorf("RefreshTokenTTL = %v, want 7d", sc.RefreshTokenTTL) + } + if sc.IdleTimeout != 0 { + t.Errorf("IdleTimeout = %v, want 0", sc.IdleTimeout) + } +} + +func TestSessionConfigCustomValues(t *testing.T) { + gc := newMockGC(models.JSONMap{ + "access_token_ttl": "30m", + "refresh_token_ttl": "30d", + "idle_timeout": "1h", + }) + + sc := LoadSessionConfig(context.Background(), gc) + + if sc.AccessTokenTTL != 30*time.Minute { + t.Errorf("AccessTokenTTL = %v, want 30m", sc.AccessTokenTTL) + } + if sc.RefreshTokenTTL != 30*24*time.Hour { + t.Errorf("RefreshTokenTTL = %v, want 30d", sc.RefreshTokenTTL) + } + if sc.IdleTimeout != 1*time.Hour { + t.Errorf("IdleTimeout = %v, want 1h", sc.IdleTimeout) + } +} + +func TestSessionConfigClampLow(t *testing.T) { + gc := newMockGC(models.JSONMap{ + "access_token_ttl": "1m", // below 5m min + "refresh_token_ttl": "30m", // below 1h min + "idle_timeout": "1m", // below 5m min + }) + + sc := LoadSessionConfig(context.Background(), gc) + + if sc.AccessTokenTTL != 5*time.Minute { + t.Errorf("AccessTokenTTL clamped = %v, want 5m", sc.AccessTokenTTL) + } + if sc.RefreshTokenTTL != 1*time.Hour { + t.Errorf("RefreshTokenTTL clamped = %v, want 1h", sc.RefreshTokenTTL) + } + if sc.IdleTimeout != 5*time.Minute { + t.Errorf("IdleTimeout clamped = %v, want 5m", sc.IdleTimeout) + } +} + +func TestSessionConfigClampHigh(t *testing.T) { + gc := newMockGC(models.JSONMap{ + "access_token_ttl": "2h", // above 60m max + "refresh_token_ttl": "365d", // above 90d max + "idle_timeout": "48h", // above 24h max + }) + + sc := LoadSessionConfig(context.Background(), gc) + + if sc.AccessTokenTTL != 60*time.Minute { + t.Errorf("AccessTokenTTL clamped = %v, want 60m", sc.AccessTokenTTL) + } + if sc.RefreshTokenTTL != 90*24*time.Hour { + t.Errorf("RefreshTokenTTL clamped = %v, want 90d", sc.RefreshTokenTTL) + } + if sc.IdleTimeout != 24*time.Hour { + t.Errorf("IdleTimeout clamped = %v, want 24h", sc.IdleTimeout) + } +} + +func TestSessionConfigEmptyIdleTimeout(t *testing.T) { + gc := newMockGC(models.JSONMap{ + "access_token_ttl": "15m", + "refresh_token_ttl": "7d", + "idle_timeout": "", + }) + + sc := LoadSessionConfig(context.Background(), gc) + + if sc.IdleTimeout != 0 { + t.Errorf("IdleTimeout = %v, want 0 (disabled)", sc.IdleTimeout) + } +} + +func TestSessionConfigInvalidValues(t *testing.T) { + gc := newMockGC(models.JSONMap{ + "access_token_ttl": "not-a-duration", + "refresh_token_ttl": "xyz", + "idle_timeout": "---", + }) + + sc := LoadSessionConfig(context.Background(), gc) + + // Should fall back to defaults + if sc.AccessTokenTTL != 15*time.Minute { + t.Errorf("AccessTokenTTL = %v, want 15m (default)", sc.AccessTokenTTL) + } + if sc.RefreshTokenTTL != 7*24*time.Hour { + t.Errorf("RefreshTokenTTL = %v, want 7d (default)", sc.RefreshTokenTTL) + } + if sc.IdleTimeout != 0 { + t.Errorf("IdleTimeout = %v, want 0 (default)", sc.IdleTimeout) + } +} diff --git a/server/main.go b/server/main.go index edf99b2..212b628 100644 --- a/server/main.go +++ b/server/main.go @@ -444,6 +444,13 @@ func main() { wfScanner.Start() defer wfScanner.Stop() + // ── Activity ping (authenticated, rate-limited) ── + // Client SDK calls this on user interaction (debounced, max 1/min) + // to update last_activity_at for idle-timeout tracking. + activityGroup := api.Group("/auth") + activityGroup.Use(middleware.Auth(cfg, stores.Users, userCache)) + activityGroup.POST("/activity", authH.Activity) + // ── Protected routes ──────────────────── protected := api.Group("") protected.Use(middleware.Auth(cfg, stores.Users, userCache)) diff --git a/server/store/interfaces.go b/server/store/interfaces.go index 48d2614..9640a1f 100644 --- a/server/store/interfaces.go +++ b/server/store/interfaces.go @@ -13,6 +13,13 @@ import ( // ErrSystemGroup is returned when attempting to delete a system-sourced group. var ErrSystemGroup = errors.New("system groups cannot be deleted") +// RefreshTokenInfo holds extended metadata for a refresh token row. +type RefreshTokenInfo struct { + UserID string + KeepLogin bool + LastActivityAt *time.Time +} + // ========================================= // STORES — Data Access Layer // ========================================= @@ -79,11 +86,13 @@ type UserStore interface { ListActiveUserIDs(ctx context.Context) ([]string, error) // Refresh tokens - CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error + CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time, keepLogin bool) error GetRefreshToken(ctx context.Context, tokenHash string) (userID string, err error) + GetRefreshTokenInfo(ctx context.Context, tokenHash string) (*RefreshTokenInfo, error) RevokeRefreshToken(ctx context.Context, tokenHash string) error RevokeAllRefreshTokens(ctx context.Context, userID string) error CleanExpiredTokens(ctx context.Context) error + UpdateRefreshTokenActivity(ctx context.Context, userID string) error // ── CS1 additions ── diff --git a/server/store/postgres/user.go b/server/store/postgres/user.go index 685f09d..bb6238b 100644 --- a/server/store/postgres/user.go +++ b/server/store/postgres/user.go @@ -142,8 +142,10 @@ func (s *UserStore) ListActiveUserIDs(ctx context.Context) ([]string, error) { // ── Refresh Tokens ────────────────────────── -func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error { - _, err := DB.ExecContext(ctx, `INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)`, userID, tokenHash, expiresAt) +func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time, keepLogin bool) error { + _, err := DB.ExecContext(ctx, + `INSERT INTO refresh_tokens (user_id, token_hash, expires_at, keep_login, last_activity_at) VALUES ($1, $2, $3, $4, NOW())`, + userID, tokenHash, expiresAt, keepLogin) return err } func (s *UserStore) GetRefreshToken(ctx context.Context, tokenHash string) (string, error) { @@ -151,6 +153,17 @@ func (s *UserStore) GetRefreshToken(ctx context.Context, tokenHash string) (stri err := DB.QueryRowContext(ctx, `SELECT user_id FROM refresh_tokens WHERE token_hash = $1 AND revoked_at IS NULL AND expires_at > NOW()`, tokenHash).Scan(&userID) return userID, err } +func (s *UserStore) GetRefreshTokenInfo(ctx context.Context, tokenHash string) (*store.RefreshTokenInfo, error) { + var info store.RefreshTokenInfo + err := DB.QueryRowContext(ctx, + `SELECT user_id, COALESCE(keep_login, false), last_activity_at + FROM refresh_tokens WHERE token_hash = $1 AND revoked_at IS NULL AND expires_at > NOW()`, + tokenHash).Scan(&info.UserID, &info.KeepLogin, &info.LastActivityAt) + if err != nil { + return nil, err + } + return &info, nil +} func (s *UserStore) RevokeRefreshToken(ctx context.Context, tokenHash string) error { _, err := DB.ExecContext(ctx, "UPDATE refresh_tokens SET revoked_at = NOW() WHERE token_hash = $1", tokenHash) return err @@ -163,6 +176,12 @@ func (s *UserStore) CleanExpiredTokens(ctx context.Context) error { _, err := DB.ExecContext(ctx, "DELETE FROM refresh_tokens WHERE expires_at < NOW() - INTERVAL '30 days'") return err } +func (s *UserStore) UpdateRefreshTokenActivity(ctx context.Context, userID string) error { + _, err := DB.ExecContext(ctx, + "UPDATE refresh_tokens SET last_activity_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL", + userID) + return err +} // ── Internal ──────────────────────────────── diff --git a/server/store/sqlite/user.go b/server/store/sqlite/user.go index 3b9f60c..8304372 100644 --- a/server/store/sqlite/user.go +++ b/server/store/sqlite/user.go @@ -148,9 +148,10 @@ func (s *UserStore) ListActiveUserIDs(ctx context.Context) ([]string, error) { // ── Refresh Tokens ────────────────────────── -func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error { - _, err := DB.ExecContext(ctx, `INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at) VALUES (?, ?, ?, ?)`, - store.NewID(), userID, tokenHash, expiresAt.Format(timeFmt)) +func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time, keepLogin bool) error { + _, err := DB.ExecContext(ctx, + `INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at, keep_login, last_activity_at) VALUES (?, ?, ?, ?, ?, datetime('now'))`, + store.NewID(), userID, tokenHash, expiresAt.Format(timeFmt), keepLogin) return err } func (s *UserStore) GetRefreshToken(ctx context.Context, tokenHash string) (string, error) { @@ -158,6 +159,23 @@ func (s *UserStore) GetRefreshToken(ctx context.Context, tokenHash string) (stri err := DB.QueryRowContext(ctx, `SELECT user_id FROM refresh_tokens WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > datetime('now')`, tokenHash).Scan(&userID) return userID, err } +func (s *UserStore) GetRefreshTokenInfo(ctx context.Context, tokenHash string) (*store.RefreshTokenInfo, error) { + var info store.RefreshTokenInfo + var lastAct *string + err := DB.QueryRowContext(ctx, + `SELECT user_id, COALESCE(keep_login, 0), last_activity_at + FROM refresh_tokens WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > datetime('now')`, + tokenHash).Scan(&info.UserID, &info.KeepLogin, &lastAct) + if err != nil { + return nil, err + } + if lastAct != nil { + if t, err := time.Parse(timeFmt, *lastAct); err == nil { + info.LastActivityAt = &t + } + } + return &info, nil +} func (s *UserStore) RevokeRefreshToken(ctx context.Context, tokenHash string) error { _, err := DB.ExecContext(ctx, "UPDATE refresh_tokens SET revoked_at = datetime('now') WHERE token_hash = ?", tokenHash) return err @@ -170,6 +188,12 @@ func (s *UserStore) CleanExpiredTokens(ctx context.Context) error { _, err := DB.ExecContext(ctx, "DELETE FROM refresh_tokens WHERE expires_at < datetime('now', '-30 days')") return err } +func (s *UserStore) UpdateRefreshTokenActivity(ctx context.Context, userID string) error { + _, err := DB.ExecContext(ctx, + "UPDATE refresh_tokens SET last_activity_at = datetime('now') WHERE user_id = ? AND revoked_at IS NULL", + userID) + return err +} // ── Internal ──────────────────────────────── diff --git a/src/css/sw-login.css b/src/css/sw-login.css index 95bd013..e3b126e 100644 --- a/src/css/sw-login.css +++ b/src/css/sw-login.css @@ -99,6 +99,13 @@ border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim); } +.login-keep-login { + display: flex; align-items: center; gap: 6px; margin-top: 4px; + font-size: 0.82rem; color: var(--text-secondary, #999); cursor: pointer; + user-select: none; +} +.login-keep-login input[type="checkbox"] { margin: 0; cursor: pointer; } + .login-auth-error { color: var(--danger); font-size: 0.78rem; margin: 0 0 0.5rem; } diff --git a/src/js/sw/sdk/auth.js b/src/js/sw/sdk/auth.js index e6e4621..d58f3b6 100644 --- a/src/js/sw/sdk/auth.js +++ b/src/js/sw/sdk/auth.js @@ -36,15 +36,18 @@ export function createAuth() { // ── Token persistence ─────────────────────── + let _cookieMaxAge = 604800; // default 7 days, updated from server response + function _saveTokens() { localStorage.setItem(_storageKey, JSON.stringify({ accessToken: _accessToken, refreshToken: _refreshToken, user: _user, + cookieMaxAge: _cookieMaxAge, })); // Cookie sync for Go template page auth if (_accessToken) { - document.cookie = `arm_token=${_accessToken}; path=/; max-age=604800; SameSite=Strict`; + document.cookie = `arm_token=${_accessToken}; path=/; max-age=${_cookieMaxAge}; SameSite=Strict`; } else { document.cookie = 'arm_token=; path=/; max-age=0'; } @@ -57,6 +60,7 @@ export function createAuth() { _accessToken = saved.accessToken || null; _refreshToken = saved.refreshToken || null; _user = saved.user || null; + if (saved.cookieMaxAge) _cookieMaxAge = saved.cookieMaxAge; } } catch (_) { /* corrupt storage */ } } @@ -81,6 +85,7 @@ export function createAuth() { _accessToken = data.access_token; _refreshToken = data.refresh_token; _user = data.user; + if (data.refresh_expires_in) _cookieMaxAge = data.refresh_expires_in; _saveTokens(); _scheduleRefresh(data.expires_in || 900); } @@ -149,8 +154,8 @@ export function createAuth() { /** * Login with credentials. Stores tokens, fetches permissions, emits event. */ - async login(login, password) { - const data = await _restClient.post('/api/v1/auth/login', { login, password }); + async login(login, password, keepLogin = false) { + const data = await _restClient.post('/api/v1/auth/login', { login, password, keep_login: keepLogin }); _setAuth(data); await _fetchPermissions(); _emit('auth.login', { user: auth.user }, { localOnly: true }); @@ -245,9 +250,30 @@ export function createAuth() { if (_accessToken) { _emit('auth.boot', { user: auth.user }, { localOnly: true }); + auth._startActivityTracking(); } }, + // ── Activity Ping (idle-timeout support) ── + + /** + * Start activity tracking. Sends POST /api/v1/auth/activity + * on user interaction, debounced to max once per minute. + */ + _startActivityTracking() { + let lastPing = 0; + const INTERVAL = 60_000; // 1 minute + const ping = () => { + if (!_accessToken) return; + const now = Date.now(); + if (now - lastPing < INTERVAL) return; + lastPing = now; + _restClient.post('/api/v1/auth/activity', {}).catch(() => {}); + }; + document.addEventListener('click', ping, { passive: true }); + document.addEventListener('keydown', ping, { passive: true }); + }, + // ── Internal (called by rest-client / index.js) ── _getToken() { return _accessToken; }, diff --git a/src/js/sw/surfaces/admin/settings.js b/src/js/sw/surfaces/admin/settings.js index 2feed3a..a7e96ae 100644 --- a/src/js/sw/surfaces/admin/settings.js +++ b/src/js/sw/surfaces/admin/settings.js @@ -37,6 +37,10 @@ export default function SettingsSection() { footer_enabled: !!cfg_.footer?.enabled, footer_text: cfg_.footer?.text || '', package_registry_url: cfg_.package_registry?.url || '', + session_access_ttl: cfg_.session?.access_token_ttl || '15m', + session_refresh_ttl: cfg_.session?.refresh_token_ttl || '7d', + session_idle_enabled: !!cfg_.session?.idle_timeout, + session_idle_timeout: cfg_.session?.idle_timeout || '2h', }); setVault(v); } catch (e) { sw.toast(e.message, 'error'); } @@ -82,6 +86,13 @@ export default function SettingsSection() { // Package Registry await sw.api.admin.settings.update('package_registry', { value: { url: cfg.package_registry_url } }); + // Session + await sw.api.admin.settings.update('session', { value: { + access_token_ttl: cfg.session_access_ttl, + refresh_token_ttl: cfg.session_refresh_ttl, + idle_timeout: cfg.session_idle_enabled ? cfg.session_idle_timeout : '', + }}); + sw.toast('Settings saved', 'success'); } catch (e) { sw.toast(e.message, 'error'); } finally { setSaving(false); } @@ -163,6 +174,48 @@ export default function SettingsSection() { `} +
${error}
`}