Feat v0.6.9 cookie fix roadmap #44
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
103
server/handlers/session_config.go
Normal file
103
server/handlers/session_config.go
Normal file
@@ -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
|
||||
}
|
||||
188
server/handlers/session_config_test.go
Normal file
188
server/handlers/session_config_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
|
||||
@@ -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 ────────────────────────────────
|
||||
|
||||
|
||||
@@ -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 ────────────────────────────────
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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; },
|
||||
|
||||
@@ -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() {
|
||||
`}
|
||||
</div>
|
||||
|
||||
<div class="settings-section"><h3>Session</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Access token TTL</label>
|
||||
<select value=${cfg.session_access_ttl} onChange=${e => set('session_access_ttl', e.target.value)}>
|
||||
<option value="5m">5 minutes</option>
|
||||
<option value="10m">10 minutes</option>
|
||||
<option value="15m">15 minutes (default)</option>
|
||||
<option value="30m">30 minutes</option>
|
||||
<option value="60m">60 minutes</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group"><label>Refresh token TTL</label>
|
||||
<select value=${cfg.session_refresh_ttl} onChange=${e => set('session_refresh_ttl', e.target.value)}>
|
||||
<option value="1h">1 hour</option>
|
||||
<option value="12h">12 hours</option>
|
||||
<option value="24h">24 hours</option>
|
||||
<option value="7d">7 days (default)</option>
|
||||
<option value="30d">30 days</option>
|
||||
<option value="90d">90 days</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-muted" style="font-size:12px;">Access token controls JWT lifetime. Refresh token controls how long sessions persist. "Keep me logged in" unchecked caps refresh at 24h.</span>
|
||||
<div style="margin-top:8px;">
|
||||
<label class="toggle-label"><input type="checkbox" checked=${cfg.session_idle_enabled} onChange=${e => set('session_idle_enabled', e.target.checked)} /><span class="toggle-track"></span><span>Enable idle timeout</span></label>
|
||||
${cfg.session_idle_enabled && html`
|
||||
<div class="form-group" style="margin-top:8px;"><label>Idle timeout</label>
|
||||
<select value=${cfg.session_idle_timeout} onChange=${e => set('session_idle_timeout', e.target.value)}>
|
||||
<option value="15m">15 minutes</option>
|
||||
<option value="30m">30 minutes</option>
|
||||
<option value="1h">1 hour</option>
|
||||
<option value="2h">2 hours (default)</option>
|
||||
<option value="4h">4 hours</option>
|
||||
<option value="8h">8 hours</option>
|
||||
<option value="24h">24 hours</option>
|
||||
</select>
|
||||
</div>
|
||||
<span class="text-muted" style="font-size:12px;">Rejects token refresh if no user activity within the timeout period.</span>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section"><h3>Encryption Vault</h3>
|
||||
${vault
|
||||
? html`<div class="text-muted" style="font-size:12px;">Key set: ${vault.encryption_key_set ? 'Yes' : 'No'} \u2022 User vaults: ${vault.user_vaults_count ?? '?'}</div>`
|
||||
|
||||
@@ -10,6 +10,7 @@ const { useState, useRef, useCallback } = hooks;
|
||||
export function LoginForm({ onSuccess }) {
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [keepLogin, setKeepLogin] = useState(false);
|
||||
const userRef = useRef(null);
|
||||
const passRef = useRef(null);
|
||||
|
||||
@@ -25,7 +26,7 @@ export function LoginForm({ onSuccess }) {
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await sw.auth.login(username, password);
|
||||
await sw.auth.login(username, password, keepLogin);
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
} else {
|
||||
@@ -35,7 +36,7 @@ export function LoginForm({ onSuccess }) {
|
||||
setError(e.message || 'Login failed');
|
||||
setLoading(false);
|
||||
}
|
||||
}, [onSuccess]);
|
||||
}, [onSuccess, keepLogin]);
|
||||
|
||||
const onUserKey = useCallback((e) => {
|
||||
if (e.key === 'Enter') passRef.current?.focus();
|
||||
@@ -59,6 +60,11 @@ export function LoginForm({ onSuccess }) {
|
||||
placeholder="Enter password" autocomplete="current-password"
|
||||
onKeyDown=${onPassKey} />
|
||||
</div>
|
||||
<label class="login-keep-login">
|
||||
<input type="checkbox" checked=${keepLogin}
|
||||
onChange=${e => setKeepLogin(e.target.checked)} />
|
||||
<span>Keep me logged in</span>
|
||||
</label>
|
||||
${error && html`<p class="login-auth-error">${error}</p>`}
|
||||
<button class="login-btn" disabled=${loading} onClick=${doLogin}>
|
||||
${loading ? 'Logging in\u2026' : 'Log In'}
|
||||
|
||||
Reference in New Issue
Block a user