diff --git a/frontend/components/common/settings/security-tab.tsx b/frontend/components/common/settings/security-tab.tsx index 4dabf956..1ed5debd 100644 --- a/frontend/components/common/settings/security-tab.tsx +++ b/frontend/components/common/settings/security-tab.tsx @@ -2,13 +2,27 @@ import {useEffect, useState} from "react" import {useMutation, useQuery, useQueryClient, type UseQueryResult} from "@tanstack/react-query" -import {Fingerprint, Globe, Loader2, Lock, Mail, Pencil, Plus, Settings, Shield, Trash2, UserPlus} from "lucide-react" +import { + Clock, + Fingerprint, + Globe, + Loader2, + Lock, + Mail, + Pencil, + Plus, + Settings, + Shield, + Trash2, + UserPlus +} from "lucide-react" import {Button} from "@/components/ui/button" import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card" import {Switch} from "@/components/ui/switch" import {Input} from "@/components/ui/input" import {Label} from "@/components/ui/label" +import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select" import {AuthSourceModal} from "@/components/common/settings/auth-source-modal" import {AdminService} from "@/lib/services" import type {AuthSource, SystemConfig} from "@/lib/services/admin" @@ -70,6 +84,9 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) { const [capTokenTTL, setCapTokenTTL] = useState("") const [capAutoSolve, setCapAutoSolve] = useState(true) + const [sessionTTL, setSessionTTL] = useState("168") + const [customHours, setCustomHours] = useState("") + const authSourcesQuery = useQuery({ queryKey: ["auth", "sources"], queryFn: () => AdminService.listAuthSources(), @@ -84,9 +101,58 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) { setCapTTL(cfgMap["cap_challenge_ttl_seconds"]?.value || "600") setCapTokenTTL(cfgMap["cap_token_ttl_seconds"]?.value || "1200") setCapAutoSolve(cfgMap["cap_auto_solve"]?.value !== "false") + + // 初始化登录保持设置 + const ttlVal = cfgMap["login_session_ttl_hours"]?.value || "0" + if (ttlVal === "0" || ttlVal === "168" || ttlVal === "720" || ttlVal === "-1") { + setSessionTTL(ttlVal) + setCustomHours("") + } else { + setSessionTTL("custom") + setCustomHours(ttlVal) + } } }, [systemConfigsQuery.data, configs]) + const updateTTLMutation = useMutation({ + mutationFn: async (value: string) => { + const config = configs["login_session_ttl_hours"] + if (!config) { + throw new Error("缺少配置项: login_session_ttl_hours") + } + await AdminService.updateSystemConfig("login_session_ttl_hours", { + value: value, + description: config.description, + }) + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] }) + toast.success("登录状态保持时间已更新") + }, + onError: (error: Error) => { + toast.error(error.message || "更新配置失败") + }, + }) + + const handleTTLChange = (val: string) => { + setSessionTTL(val) + if (val !== "custom") { + updateTTLMutation.mutate(val) + } + } + + const handleCustomBlur = () => { + const parsed = parseInt(customHours, 10) + if (isNaN(parsed) || parsed <= 0) { + toast.error("请输入有效的过期小时数(正整数)") + // 重置为原本的值 + const originalVal = configs["login_session_ttl_hours"]?.value || "0" + setCustomHours(originalVal === "custom" || ["0", "168", "720", "-1"].includes(originalVal) ? "" : originalVal) + return + } + updateTTLMutation.mutate(parsed.toString()) + } + const updateConfigMutation = useMutation({ mutationFn: async ({ key, value }: { key: string; value: boolean }) => { const config = configs[key] @@ -183,7 +249,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
- 系统安全与注册控制 + 系统登录/注册设置 配置系统的登录限制与用户自主注册权限
@@ -214,6 +280,58 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) { ) })} + + {/* 登录状态保持时间 (选择后立即更改) */} +
+
+
+ + 登录状态保持时间 +
+

+ 配置用户登录会话在浏览器中的保持期限。设置为“关闭”则在浏览器关闭后自动退登。 +

+
+ +
+ + + {sessionTTL === "custom" && ( + setCustomHours(e.target.value)} + onBlur={handleCustomBlur} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleCustomBlur() + } + }} + placeholder="小时" + disabled={updateTTLMutation.isPending} + className="w-20 bg-card border-dashed text-xs h-8 px-2" + /> + )} +
+
diff --git a/internal/apps/admin/system_config/routers_test.go b/internal/apps/admin/system_config/routers_test.go index 6c6686ce..5f4dc6d0 100644 --- a/internal/apps/admin/system_config/routers_test.go +++ b/internal/apps/admin/system_config/routers_test.go @@ -23,6 +23,8 @@ import ( "github.com/gin-gonic/gin" ) +const expectedDefaultConfigsCount = 28 + func setupTestRouter(authUser *model.User) *gin.Engine { gin.SetMode(gin.TestMode) r := gin.New() @@ -137,9 +139,9 @@ func TestListSystemConfigs(t *testing.T) { var configs []model.SystemConfig _ = json.Unmarshal(dataBytes, &configs) - // Defaults seed 27 configurations - if len(configs) != 27 { - t.Errorf("expected 27 default configs, got %d", len(configs)) + // Defaults seed configurations + if len(configs) != expectedDefaultConfigsCount { + t.Errorf("expected %d default configs, got %d", expectedDefaultConfigsCount, len(configs)) } }) diff --git a/internal/apps/oauth/sources.go b/internal/apps/oauth/sources.go index d395514e..8371ff8c 100644 --- a/internal/apps/oauth/sources.go +++ b/internal/apps/oauth/sources.go @@ -165,10 +165,24 @@ func containsScope(scopes []string, scope string) bool { return false } -func setLoginSession(c *gin.Context, user *model.User) error { +func setLoginSession(ctx context.Context, c *gin.Context, user *model.User) error { session := sessions.Default(c) session.Set(UserIDKey, user.ID) session.Set(UserNameKey, user.Username) + + // 根据系统配置动态设置 Session 过期时间 + maxAge := 0 + ttlHours, err := model.GetIntByKey(ctx, model.ConfigKeyLoginSessionTTLHours) + if err == nil { + if ttlHours == -1 { + // 永不过期,设置为 10 年 + maxAge = 10 * 365 * 24 * 3600 + } else if ttlHours > 0 { + maxAge = ttlHours * 3600 + } + } + session.Options(util.GetSessionOptions(maxAge)) + return session.Save() } @@ -508,7 +522,7 @@ func handleCallbackLogin(ctx context.Context, c *gin.Context, source *model.Auth user.LastLoginAt = time.Now() _ = db.DB(ctx).Model(&user).Update("last_login_at", user.LastLoginAt).Error - if err := setLoginSession(c, &user); err != nil { + if err := setLoginSession(ctx, c, &user); err != nil { c.JSON(http.StatusInternalServerError, util.Err(err.Error())) return } diff --git a/internal/apps/user/routers.go b/internal/apps/user/routers.go index 279b43be..032562de 100644 --- a/internal/apps/user/routers.go +++ b/internal/apps/user/routers.go @@ -58,10 +58,24 @@ func isRegistrationEnabled() bool { return enabled } -func setLoginSession(c *gin.Context, user *model.User) error { +func setLoginSession(ctx context.Context, c *gin.Context, user *model.User) error { session := sessions.Default(c) session.Set(oauth.UserIDKey, user.ID) session.Set(oauth.UserNameKey, user.Username) + + // 根据系统配置动态设置 Session 过期时间 + maxAge := 0 + ttlHours, err := model.GetIntByKey(ctx, model.ConfigKeyLoginSessionTTLHours) + if err == nil { + if ttlHours == -1 { + // 永不过期,设置为 10 年 + maxAge = 10 * 365 * 24 * 3600 + } else if ttlHours > 0 { + maxAge = ttlHours * 3600 + } + } + session.Options(util.GetSessionOptions(maxAge)) + if err := session.Save(); err != nil { return err } @@ -134,7 +148,7 @@ func Login(c *gin.Context) { c.JSON(http.StatusOK, util.Err(err.Error())) return } - if err := setLoginSession(c, &user); err != nil { + if err := setLoginSession(ctx, c, &user); err != nil { c.JSON(http.StatusOK, util.Err(errSaveSessionFailed)) return } @@ -218,7 +232,7 @@ func Register(c *gin.Context) { return } - if err := setLoginSession(c, &user); err != nil { + if err := setLoginSession(ctx, c, &user); err != nil { c.JSON(http.StatusOK, util.Err(errSaveSessionFailed)) return } diff --git a/internal/db/migrator/goose/postgres/202606120001_add_login_session_ttl_config.sql b/internal/db/migrator/goose/postgres/202606120001_add_login_session_ttl_config.sql new file mode 100644 index 00000000..4300c4c4 --- /dev/null +++ b/internal/db/migrator/goose/postgres/202606120001_add_login_session_ttl_config.sql @@ -0,0 +1,8 @@ +-- +goose Up +INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at) +VALUES + ('login_session_ttl_hours', '0', 'system', 0, '登录会话过期时间 (小时,0表示浏览器关闭后自动退出,-1表示永不过期)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT (key) DO NOTHING; + +-- +goose Down +DELETE FROM w_system_configs WHERE key = 'login_session_ttl_hours'; diff --git a/internal/db/migrator/goose/sqlite/202606120001_add_login_session_ttl_config.sql b/internal/db/migrator/goose/sqlite/202606120001_add_login_session_ttl_config.sql new file mode 100644 index 00000000..4300c4c4 --- /dev/null +++ b/internal/db/migrator/goose/sqlite/202606120001_add_login_session_ttl_config.sql @@ -0,0 +1,8 @@ +-- +goose Up +INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at) +VALUES + ('login_session_ttl_hours', '0', 'system', 0, '登录会话过期时间 (小时,0表示浏览器关闭后自动退出,-1表示永不过期)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT (key) DO NOTHING; + +-- +goose Down +DELETE FROM w_system_configs WHERE key = 'login_session_ttl_hours'; diff --git a/internal/db/migrator/migrator_test.go b/internal/db/migrator/migrator_test.go index 1d0bea1f..01435bb3 100644 --- a/internal/db/migrator/migrator_test.go +++ b/internal/db/migrator/migrator_test.go @@ -16,6 +16,8 @@ import ( "gorm.io/gorm" ) +const expectedMigratedSystemConfigCount = 28 + func TestMigrateInitializesSQLiteDatabase(t *testing.T) { sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ DisableForeignKeyConstraintWhenMigrating: true, @@ -38,8 +40,8 @@ func TestMigrateInitializesSQLiteDatabase(t *testing.T) { if err := sqliteDB.Table("w_system_configs").Count(&systemConfigCount).Error; err != nil { t.Fatalf("Migrate() count w_system_configs error = %v", err) } - if systemConfigCount != 27 { - t.Errorf("Migrate() w_system_configs count = %d, want %d", systemConfigCount, 27) + if systemConfigCount != expectedMigratedSystemConfigCount { + t.Errorf("Migrate() w_system_configs count = %d, want %d", systemConfigCount, expectedMigratedSystemConfigCount) } var adminCount int64 diff --git a/internal/model/system_configs.go b/internal/model/system_configs.go index db42b94b..22641192 100644 --- a/internal/model/system_configs.go +++ b/internal/model/system_configs.go @@ -47,6 +47,7 @@ const ( ConfigKeyDiskCacheMaxSizeMB = "disk_cache_max_size_mb" // 磁盘缓存最大空间大小 (MB) ConfigKeyDiskCacheTTLMinutes = "disk_cache_ttl_minutes" // 磁盘缓存默认有效期 (分钟) ConfigKeyDiskCacheLRUEnabled = "disk_cache_lru_enabled" // 是否启用 LRU 淘汰机制 + ConfigKeyLoginSessionTTLHours = "login_session_ttl_hours" // 登录会话过期时间 (小时,0表示浏览器关闭后自动退出登录,-1表示永不过期) ) const ( diff --git a/internal/testhelper/test_helper.go b/internal/testhelper/test_helper.go index 23f19027..c6f2723c 100644 --- a/internal/testhelper/test_helper.go +++ b/internal/testhelper/test_helper.go @@ -249,6 +249,12 @@ func getSeedConfigsPart2() []model.SystemConfig { Type: configTypeSystem, Description: "是否启用 LRU 淘汰机制", }, + { + Key: model.ConfigKeyLoginSessionTTLHours, + Value: "0", + Type: configTypeSystem, + Description: "登录会话过期时间 (小时,0表示浏览器关闭后自动退出,-1表示永不过期)", + }, } }