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表示永不过期)",
+ },
}
}