From 295cd38b22f01d2dbad704fdd519a2e8fb5f2ff7 Mon Sep 17 00:00:00 2001 From: Jiang Chaoqun Date: Wed, 28 Oct 2026 01:02:27 +0800 Subject: [PATCH 1/2] feat: raise user content quotas and offer oldest-session replacement --- README.md | 2 +- api/accounts/handlers.go | 13 +- api/accounts/session_replacement_test.go | 123 ++++++++++++++++++ config/config_example.yaml | 2 +- config/registry.go | 2 +- config/runtime_test.go | 10 +- deploy/pi/README.md | 10 ++ docs/accounts-migration.md | 4 +- docs/specs/account-center/design.md | 6 +- docs/specs/manual-management/design.md | 2 +- domain/service/accounts/service.go | 30 ++++- domain/service/accounts/session_limit_test.go | 45 +++++++ domain/service/accounts/sessions.go | 5 +- domain/service/manuals/service.go | 2 +- domain/service/manuals/service_test.go | 12 ++ front_vue/src/api/browser_client.cjs | 1 + front_vue/src/views/Login.vue | 18 ++- front_vue/test/account-center.test.cjs | 42 ++++++ front_vue/test/site-visual-browser.cjs | 34 +++++ internal/accountsmigrate/flow.go | 5 + internal/accountsmigrate/flow_test.go | 3 + 21 files changed, 352 insertions(+), 19 deletions(-) create mode 100644 api/accounts/session_replacement_test.go diff --git a/README.md b/README.md index 7c40c7d..e838d74 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ storage_root/ 升级顺序为迁移、后端和同源代理、最后前端。模块关闭时仍应保留同源 API 代理,由后端返回明确的 404,兼容旧登录;不能让登录同步请求落到静态页面 fallback。 -默认上传包上限为 50 MiB,解压后 200 MiB、5,000 个文件、16 层目录;每项目默认 1 GiB、最多 10 个成功版本。完整参数见 `config/config_example.yaml`。增大上传限制时同时调整 Nginx 的 `client_max_body_size`,当前版本不会被历史清理删除。 +默认上传包上限为 50 MiB,解压后 200 MiB、5,000 个文件、16 层目录;每用户默认可创建 100,000 个网页项目,每项目默认 1 GiB、最多 10 个成功版本。说明书默认每用户 100,000 份。数量增加不改变存储容量、上传体积或访问权限;完整参数见 `config/config_example.yaml`。增大上传限制时同时调整 Nginx 的 `client_max_body_size`,当前版本不会被历史清理删除。 ### 前端产物约定 diff --git a/api/accounts/handlers.go b/api/accounts/handlers.go index a195785..d95caa8 100644 --- a/api/accounts/handlers.go +++ b/api/accounts/handlers.go @@ -120,8 +120,9 @@ func view(user *model.UserAccount, token string) accountView { // 数据库模式交由账号服务签发受版本约束的会话;配置模式保留旧用户和令牌兼容。 func login(c *gin.Context) { var input struct { - UserName string `json:"user_name"` - Password string `json:"password"` + UserName string `json:"user_name"` + Password string `json:"password"` + ReplaceOldestSession bool `json:"replace_oldest_session"` } if !bind(c, &input, 16<<10) { return @@ -130,7 +131,15 @@ func login(c *gin.Context) { ctx := ginfmt.RPCContext(c) if accountservice.DatabaseMode() { metadata := accountservice.NewSessionMetadata(middleware.TrustedClientIP(c.Request), c.Request.UserAgent(), "web") + metadata.ReplaceOldestSession = input.ReplaceOldestSession user, token, session, err := accountservice.Login(ctx, input.UserName, input.Password, metadata) + if err == accountservice.ErrSessionLimit { + response := ginfmt.NewErrorResponse(int(apperrors.ErrRateLimited.Code), err.Error()) + response.Data = gin.H{"can_replace_oldest_session": true} + c.Header("Cache-Control", "no-store") + c.JSON(http.StatusTooManyRequests, response) + return + } if err != nil { respond(c, nil, err) return diff --git a/api/accounts/session_replacement_test.go b/api/accounts/session_replacement_test.go new file mode 100644 index 0000000..6ad8f0a --- /dev/null +++ b/api/accounts/session_replacement_test.go @@ -0,0 +1,123 @@ +package accounts_test + +import ( + "crypto/sha256" + "net/http" + "strconv" + "testing" + "time" +) + +func TestHTTPLoginReplacementRequiresConsentAndRemovesOldestSessions(t *testing.T) { + for _, limit := range []int{5, 2} { + t.Run(strconv.Itoa(limit), func(t *testing.T) { + f := newSessionLimitHTTPFixture(t) + admin := f.login(f.owner, integrationPassword) + old := make([]*browserSession, 5) + for i := range old { + old[i] = f.login(f.member, integrationPassword) + digest := sha256.Sum256([]byte(old[i].Cookie.Value)) + stamp := time.Now().UTC().Add(time.Duration(i-5) * time.Minute).Truncate(time.Second) + if _, err := f.database.Exec("UPDATE login_token SET create_time=?,authenticated_at=? WHERE token_digest=?", stamp, stamp, digest[:]); err != nil { + t.Fatal(err) + } + } + current := requireSuccess(t, f.request("GET", "/api/admin/config/account_policy", admin, nil, nil)) + values := object(current["values"]) + values["max_active_sessions"] = limit + if limit != 5 { + requireSuccess(t, f.publish(admin, "account_policy", values, "lower-session-replacement-limit")) + } + input := map[string]interface{}{"user_name": f.member, "password": integrationPassword} + denied := f.request("POST", "/api/auth/login", nil, input, nil) + if denied.Status != http.StatusTooManyRequests || denied.Data["can_replace_oldest_session"] != true || len(denied.Cookies) != 0 { + t.Fatalf("full login must offer replacement without issuing credentials: status=%d data=%v", denied.Status, denied.Data) + } + input["replace_oldest_session"] = true + input["password"] = "incorrect-password" + wrong := f.request("POST", "/api/auth/login", nil, input, nil) + requireDenied(t, wrong) + if wrong.Data["can_replace_oldest_session"] == true { + t.Fatal("invalid credentials exposed session replacement availability") + } + for _, session := range old { + f.me(session) + } + input["password"] = integrationPassword + replaced := f.request("POST", "/api/auth/login", nil, input, nil) + identity := requireSuccess(t, replaced) + var issued *http.Cookie + for _, cookie := range replaced.Cookies { + if cookie.MaxAge > 0 { + if issued != nil { + t.Fatal("replacement issued multiple live cookies") + } + issued = cookie + } + } + if issued == nil || !issued.Secure || !issued.HttpOnly { + t.Fatal("replacement must establish the usual secure browser session") + } + f.me(&browserSession{Cookie: issued, CSRF: identity["csrf_token"].(string)}) + for i, session := range old { + response := f.request("GET", "/api/auth/me", session, nil, nil) + if i < len(old)-limit+1 { + requireDenied(t, response) + } else { + requireSuccess(t, response) + } + } + f.me(admin) + var active int + if err := f.database.QueryRow("SELECT COUNT(*) FROM login_token WHERE user_id=? AND is_expired=0", old[0].ID).Scan(&active); err != nil || active != limit { + t.Fatalf("replacement did not converge to the limit: active=%d limit=%d error=%v", active, limit, err) + } + }) + } +} + +func TestHTTPLoginReplacementRollsBackWhenIssuanceFails(t *testing.T) { + f := newSessionLimitHTTPFixture(t) + old := make([]*browserSession, 5) + for i := range old { + old[i] = f.login(f.member, integrationPassword) + } + if _, err := f.database.Exec("CREATE TRIGGER fail_replacement_insert BEFORE INSERT ON login_token FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='synthetic insert failure'"); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if _, err := f.database.Exec("DROP TRIGGER IF EXISTS fail_replacement_insert"); err != nil { + t.Error(err) + } + }) + response := f.request("POST", "/api/auth/login", nil, map[string]interface{}{"user_name": f.member, "password": integrationPassword, "replace_oldest_session": true}, nil) + if response.Status != http.StatusServiceUnavailable || len(response.Cookies) != 0 { + t.Fatalf("failed issuance must not create a browser session: status=%d", response.Status) + } + for _, session := range old { + f.me(session) + } +} + +func TestHTTPConcurrentLoginReplacementKeepsSessionCap(t *testing.T) { + f := newSessionLimitHTTPFixture(t) + for i := 0; i < 5; i++ { + f.login(f.member, integrationPassword) + } + start := make(chan struct{}) + results := make(chan apiResponse, 8) + for i := 0; i < cap(results); i++ { + go func() { + <-start + results <- f.request("POST", "/api/auth/login", nil, map[string]interface{}{"user_name": f.member, "password": integrationPassword, "replace_oldest_session": true}, nil) + }() + } + close(start) + for i := 0; i < cap(results); i++ { + requireSuccess(t, <-results) + } + var active int + if err := f.database.QueryRow("SELECT COUNT(*) FROM login_token WHERE is_expired=0").Scan(&active); err != nil || active != 5 { + t.Fatalf("concurrent replacement exceeded the cap: active=%d error=%v", active, err) + } +} diff --git a/config/config_example.yaml b/config/config_example.yaml index d867dc8..bca40dc 100644 --- a/config/config_example.yaml +++ b/config/config_example.yaml @@ -46,7 +46,7 @@ manuals: max_file_bytes: 52428800 max_items_per_manual: 100 max_manual_bytes: 524288000 - max_manuals_per_user: 1000 + max_manuals_per_user: 100000 max_user_bytes: 10737418240 min_free_disk_bytes: 2147483648 pdftoppm_path: /usr/bin/pdftoppm diff --git a/config/registry.go b/config/registry.go index 3d2107e..bbc8387 100644 --- a/config/registry.go +++ b/config/registry.go @@ -76,7 +76,7 @@ func Registry(conf Config) []NamespaceSchema { {"web_projects", "max_file_count", "单次文件数量上限", "个", 1, 100000, 5000}, {"web_projects", "max_directory_depth", "目录深度上限", "层", 1, 64, 16}, {"web_projects", "max_project_bytes", "单项目体积上限", "bytes", 1, 1 << 40, int64(1 << 30)}, - {"web_projects", "max_projects_per_user", "每人可用项目数量", "个", 1, 100, 10}, + {"web_projects", "max_projects_per_user", "每人可用项目数量", "个", 1, 100000, 100000}, {"web_projects", "max_user_bytes", "每人网页存储上限", "bytes", 1, 1 << 40, int64(10 << 30)}, {"web_projects", "min_free_disk_bytes", "网页磁盘保留空间", "bytes", 1 << 30, 1 << 40, int64(20 << 30)}, {"web_projects", "max_releases", "保留版本数量", "个", 1, 1000, 10}, diff --git a/config/runtime_test.go b/config/runtime_test.go index a14fe49..a38bd44 100644 --- a/config/runtime_test.go +++ b/config/runtime_test.go @@ -124,9 +124,17 @@ func TestBootstrapChangeInvalidatesPreviousSnapshot(t *testing.T) { func TestResourceQuotaSchemaDefaultsAndRelationships(t *testing.T) { conf := Config{} values := DefaultRuntimeValues(conf)["web_projects"] - if values["max_projects_per_user"] != 10 || values["max_user_bytes"] != int64(10<<30) || values["min_free_disk_bytes"] != int64(20<<30) { + if values["max_projects_per_user"] != 100000 || values["max_user_bytes"] != int64(10<<30) || values["min_free_disk_bytes"] != int64(20<<30) { t.Fatal("resource quota defaults are missing") } + for _, limit := range []int{1, 100000, 0, 100001} { + values["max_projects_per_user"] = limit + _, err := ValidateValues(conf, "web_projects", values) + if (err == nil) != (limit >= 1 && limit <= 100000) { + t.Fatalf("project count limit %d has unexpected validation result: %v", limit, err) + } + } + values["max_projects_per_user"] = 100000 values["max_user_bytes"] = int64(1) if _, err := ValidateValues(conf, "web_projects", values); err == nil { t.Fatal("per-user quota accepted below per-project quota") diff --git a/deploy/pi/README.md b/deploy/pi/README.md index 0d0c492..cbcb2d1 100644 --- a/deploy/pi/README.md +++ b/deploy/pi/README.md @@ -21,6 +21,16 @@ 新网页存储根使用 `/var/lib/home_server/web-share`,归服务用户所有、权限 0700,并保持在 WebDAV 共享树之外。 +## 用户数量与登录替换升级 + +| 配置 | 新站点默认值 | 既有站点升级 | +| --- | --- | --- | +| `web_projects.max_projects_per_user` | 100000,允许 1–100000 | 发布现有动态配置的新 revision,仅调整该字段;保留其他字段、历史、校验和及 generation 语义 | +| `manuals.max_manuals_per_user` | 100000 | 私有 YAML 有显式值时改为 100000,重启后端生效 | +| `account_policy.max_active_sessions` | 5,允许 1–100 | 保留既有值;网页登录满额时由用户明确确认是否退出最早会话并继续 | + +部署前备份 YAML、动态配置及完整旧 release。前后端使用同一提交,验证满额取消不改变旧会话、确认替换后数量不超过策略、其他账号不受影响及新会话签发失败时撤销回滚。该版本不需要新增 DDL。网页数量超过 100 后,旧程序的配置范围校验会拒绝启动;回滚后端前须先用兼容新范围的程序发布旧范围内的数量,不能直接切回旧二进制。说明书、网页文件、存储容量与权限配置沿用原值。 + ## 账号接口的同源代理 账号版本上线时,同时更新 `/etc/home_server/locations/api_auth_locations.conf`,让站点引导、个人账号和管理中心请求进入后端。`frontend-nginx.conf` 保留未知 `/api/` 的 404 兜底;新接口由更具体的 `^~` 前缀匹配,管理员预览中的 HTML、JS、CSS 也经过后端鉴权。 diff --git a/docs/accounts-migration.md b/docs/accounts-migration.md index 9873dbb..5376fff 100644 --- a/docs/accounts-migration.md +++ b/docs/accounts-migration.md @@ -39,7 +39,9 @@ WebDAV Basic 是持续支持的客户端认证协议。`/webdav/` 和 `/webdav_d 每月 3 个邀请码、有效 7 天、`Asia/Singapore` 月份计算是固定规则,管理页面只读展示。 -新增资源策略默认每用户 10 个项目、10 GiB 总配额、至少保留 20 GiB 磁盘空间。总配额必须不小于单项目上限;这些新策略会进入迁移 plan 并由管理员审核。配额收紧不会直接删除存量内容,限制由创建/上传流程执行。对应存量版本查询新增 `(uploaded_by,status,id)` 索引,不搬迁文件。 +资源策略默认每用户 100000 个项目,`web_projects.max_projects_per_user` 可配置 1–100000;存储仍为每用户 10 GiB、至少保留 20 GiB 磁盘空间。总配额必须不小于单项目上限;新建站点的策略会进入迁移 plan 并由管理员审核。已有数据库配置不会随默认值改变,升级时通过配置发布将实际数量调整为 100000,并保留其他字段、历史及校验和。配额收紧不会直接删除存量内容,限制由创建/上传流程执行。对应存量版本查询使用 `(uploaded_by,status,id)` 索引,不搬迁文件。 + +历史 `accounts-import-v1` 保留缺省每用户 10 个项目及原导入校验和。新执行或重跑该历史迁移后,通过常规配置发布提升至 100000;不能修改旧迁移标记或历史 JSON 来追随当前默认值。 数据库连接、监听端口、存储路径、可信 Origin/代理、外部服务密钥及部署上传硬上限保留在受限 YAML。动态上传限制不能超过 `upload_hard_limit_bytes`,也不能代替代理入口的 body limit 调整。 diff --git a/docs/specs/account-center/design.md b/docs/specs/account-center/design.md index c268e31..c025046 100644 --- a/docs/specs/account-center/design.md +++ b/docs/specs/account-center/design.md @@ -96,9 +96,11 @@ 管理员在“站点设置 → 账号策略”配置 `account_policy.max_active_sessions`,默认 5,允许 1–100 个有效网站会话。普通网站登录与旧 RSA 登录共享上限;受限改密会话也占用名额。应用 Token、WebDAV Basic 与同一 Token 的 Cookie 兑换不产生名额。 -达到上限时拒绝新登录,保留已有会话;网站登录返回 429,提示从已登录设备退出其他登录或联系管理员。旧 RSA 入口保留原 HTTP 200 响应封装,通过错误码表达同一限制,不返回新 Token。退出或自然过期后可再次登录。调高上限对下一次签发生效;调低不主动退出已有会话,在数量降至新上限以下前拒绝继续签发。登录管理显示有效数量与网站上限。 +达到上限时,未确认替换的登录保留已有会话并返回 429,错误 `data.can_replace_oldest_session=true` 表示可以选择替换。网站显示“退出最早登录并继续”确认框;取消不重试,确认后才在 `POST /api/auth/login` 中发送 `replace_oldest_session: true`。其他限流响应不提供此标志,错误密码不提供替换能力。旧 RSA 入口保留原 HTTP 200 响应封装,通过错误码表达同一限制,不返回新 Token。 -签发先锁同一用户行,再以当前锁定读读取最多 `max_active_sessions` 条有效会话,足额即拒绝,不生成凭据、不修改最近登录时间。不得使用旧事务快照判断名额;并发网站/旧入口登录不能超过上限。 +调高上限对下一次签发生效;调低不主动退出已有会话。确认替换时,按 `create_time ASC, id ASC` 撤销最早的有效会话,撤销数量为 `当前有效数 - 上限 + 1`,使新登录完成后的数量等于上限;未满额时不撤销。这里的“设备”表示对应登录凭据,复制同一凭据的访问会一起失效,不能独立识别物理设备。 + +签发先锁同一用户行,再用当前锁定读读取有效会话,普通登录最多读取 `max_active_sessions` 条,确认替换时读取完整有效集合。凭据校验、撤销旧会话、写入新会话及最近登录时间共用原有事务;写入失败全部回滚。过期、已撤销、旧认证版本和其他账号会话不参与替换。不得使用旧事务快照判断名额;并发网站/旧入口登录不能突破上限。登录管理继续显示有效数量与网站上限。 | 字段 | 来源与展示规则 | |---|---| diff --git a/docs/specs/manual-management/design.md b/docs/specs/manual-management/design.md index be1caf2..3320be8 100644 --- a/docs/specs/manual-management/design.md +++ b/docs/specs/manual-management/design.md @@ -92,7 +92,7 @@ ID 通过 JSON 字符串传输,避免 JavaScript 精度丢失。revision 是 图片缩略图先应用 JPEG EXIF 方向,再缩放重新编码,避免手机竖拍图片侧倒。预览处理要限制图片像素数(40 MP)、输出尺寸、图片与 PDF 共用的预览处理并发(全站 1)及 PDF 超时(10 秒);外部程序使用参数数组,不拼 shell 命令,并使用 pi 已有 prlimit 限制内存、CPU、输出文件和文件描述符。运行资源限制需在部署验证中确认。PDF 原件保存和预览成功分别报告。 -默认限制:名称 120 字、每份 20 个分类且每个 100 字、描述 2000 字;每段文本 100000 字符、URL 2048 字节;单文件 50 MiB、每份 100 条资料和 500 MiB、每用户 1000 份说明书及 10 GiB,磁盘保留 2 GiB。后端拒绝超限,代理正文限制留 multipart 余量(52 MiB)。计数及字节配额必须在事务内串行检查,磁盘保护包含处理中上传的预留量。 +默认限制:名称 120 字、每份 20 个分类且每个 100 字、描述 2000 字;每段文本 100000 字符、URL 2048 字节;单文件 50 MiB、每份 100 条资料和 500 MiB、每用户 100000 份说明书及 10 GiB,磁盘保留 2 GiB。每用户说明书数量由部署配置 `manuals.max_manuals_per_user` 控制,显式配置保留原值;已有环境需把该值更新为 100000 才能采用新数量。后端拒绝超限,代理正文限制留 multipart 余量(52 MiB)。计数及字节配额必须在事务内串行检查,磁盘保护包含处理中上传的预留量。 ### 5.3 列表与查询 diff --git a/domain/service/accounts/service.go b/domain/service/accounts/service.go index a32b133..3b833e1 100644 --- a/domain/service/accounts/service.go +++ b/domain/service/accounts/service.go @@ -23,6 +23,7 @@ import ( ) var ErrCredentials = apperrors.WithMessage(apperrors.ErrUnauthorized, "账号或密码错误") +var ErrSessionLimit = apperrors.WithMessage(apperrors.ErrRateLimited, "有效登录会话已达到网站上限,请从已登录设备退出其他登录或联系管理员") func DatabaseMode() bool { return config.Global().IdentitySource == "database" @@ -155,6 +156,7 @@ func Login(ctx context.Context, key, password string, metadata ...SessionMetadat info := NewSessionMetadata("", "", "unknown") if len(metadata) > 0 { info = NewSessionMetadata(metadata[0].LoginIP, metadata[0].UserAgent, metadata[0].LoginSource) + info.ReplaceOldestSession = metadata[0].ReplaceOldestSession } // 锁定账号后确认凭据快照仍有效,必要时升级 bcrypt 成本,再原子写入新会话。 err = database.Transaction(func(tx *gorm.DB) error { @@ -183,7 +185,8 @@ func Login(ctx context.Context, key, password string, metadata ...SessionMetadat } // issueSessionTx requires the user row lock. It checks the effective-session -// limit with a current locking read before issuing a token or changing login time. +// limit with a current locking read. An explicit replacement revokes the oldest +// excess sessions in this same transaction before issuing the new token. // Temporary passwords only receive ten-minute password-change sessions. func issueSessionTx(tx *gorm.DB, user *model.UserAccount, metadata SessionMetadata) (string, *model.AccountSession, error) { policy := config.Runtime().AccountPolicy @@ -194,15 +197,32 @@ func issueSessionTx(tx *gorm.DB, user *model.UserAccount, metadata SessionMetada var active []struct{ ID int64 } // A locking read sees sessions committed while this transaction waited for // the user lock, even if an earlier consistent read established a snapshot. - err := activeSessionQuery(tx, now).Select("s.id"). + query := activeSessionQuery(tx, now).Select("s.id"). Where("s.user_id = ? AND s.auth_version = ?", user.ID, user.AuthVersion). - Limit(policy.MaxActiveSessions). - Clauses(clause.Locking{Strength: "UPDATE"}).Find(&active).Error + Clauses(clause.Locking{Strength: "UPDATE"}) + if !metadata.ReplaceOldestSession { + query = query.Limit(policy.MaxActiveSessions) + } else { + query = query.Order("s.create_time ASC, s.id ASC") + } + err := query.Find(&active).Error if err != nil { return "", nil, err } if len(active) >= policy.MaxActiveSessions { - return "", nil, apperrors.WithMessage(apperrors.ErrRateLimited, "有效登录会话已达到网站上限,请从已登录设备退出其他登录或联系管理员") + if !metadata.ReplaceOldestSession { + return "", nil, ErrSessionLimit + } + // A lowered policy can require more than one revocation. Keep the newest + // limit-1 sessions so the replacement also fits the current policy. + ids := make([]int64, len(active)-policy.MaxActiveSessions+1) + for i := range ids { + ids[i] = active[i].ID + } + if err := tx.Table(dal.TableUserToken).Where("user_id = ? AND id IN ?", user.ID, ids). + Updates(map[string]interface{}{"is_expired": 1, "update_time": now}).Error; err != nil { + return "", nil, err + } } token, err := randomSecret("us_cq_", 32) if err != nil { diff --git a/domain/service/accounts/session_limit_test.go b/domain/service/accounts/session_limit_test.go index 2fc3148..438435d 100644 --- a/domain/service/accounts/session_limit_test.go +++ b/domain/service/accounts/session_limit_test.go @@ -246,3 +246,48 @@ func TestSessionLimitInvalidRuntimeFailsClosed(t *testing.T) { }) } } + +func TestSessionReplacementUsesOnlyEffectiveRowsInLoginOrder(t *testing.T) { + for _, full := range []bool{false, true} { + t.Run(strconv.FormatBool(full), func(t *testing.T) { + database := sessionLimitDatabase(t) + setSessionLimit(t, 3) + now := time.Now().UTC().Truncate(time.Second) + sessionTestInsert(t, database, 9901, 8101, model.SessionUser, now.Add(-time.Minute), now.Add(time.Hour)) + if full { + // The smaller ID is newer; tied older timestamps must use ID order. + sessionTestInsert(t, database, 9902, 8101, model.SessionUser, now.Add(-time.Hour), now.Add(time.Hour)) + sessionTestInsert(t, database, 9903, 8101, model.SessionUser, now.Add(-time.Hour), now.Add(time.Hour)) + } + sessionTestInsert(t, database, 9910, 8102, model.SessionUser, now.Add(-time.Hour), now.Add(time.Hour)) + sessionTestInsert(t, database, 9911, 8101, model.SessionUser, now.Add(-time.Hour), now.Add(-time.Second)) + sessionTestInsert(t, database, 9912, 8101, "unknown_purpose", now.Add(-time.Hour), now.Add(time.Hour)) + for id, fields := range map[int64]map[string]interface{}{9913: {"auth_version": 0}, 9914: {"token_digest": nil}, 9915: {"is_expired": 1}} { + sessionTestInsert(t, database, id, 8101, model.SessionUser, now.Add(-time.Hour), now.Add(time.Hour)) + if err := database.Table(dal.TableUserToken).Where("id = ?", id).Updates(fields).Error; err != nil { + t.Fatal(err) + } + } + _, token, _, err := Login(context.Background(), "session8101", reviewPassword, SessionMetadata{ReplaceOldestSession: true}) + if err != nil || token == "" { + t.Fatal("explicit replacement login failed:", err) + } + var rows []struct { + ID int64 + IsExpired int + } + if err := database.Table(dal.TableUserToken).Select("id,is_expired").Where("id BETWEEN ? AND ?", 9901, 9915).Find(&rows).Error; err != nil { + t.Fatal(err) + } + for _, row := range rows { + expected := 0 + if row.ID == 9915 || full && row.ID == 9902 { + expected = 1 + } + if row.IsExpired != expected { + t.Fatalf("replacement touched the wrong history or user: id=%d expired=%d want=%d", row.ID, row.IsExpired, expected) + } + } + }) + } +} diff --git a/domain/service/accounts/sessions.go b/domain/service/accounts/sessions.go index 2e188a9..54f332d 100644 --- a/domain/service/accounts/sessions.go +++ b/domain/service/accounts/sessions.go @@ -26,10 +26,11 @@ import ( const SessionWarningThreshold = 10 -// SessionMetadata describes issuance, never the device presenting a token later. -// These values are display hints and are not used for authentication. +// SessionMetadata describes issuance and the user's explicit replacement choice. +// Client values are display hints, never authentication or a device identity. type SessionMetadata struct { LoginIP, UserAgent, ClientName, OSName, DeviceType, LoginSource string + ReplaceOldestSession bool } // NewSessionMetadata parses a bounded UTF-8 UA using the pinned parser. Unknown diff --git a/domain/service/manuals/service.go b/domain/service/manuals/service.go index 8bbf93b..55317f5 100644 --- a/domain/service/manuals/service.go +++ b/domain/service/manuals/service.go @@ -90,7 +90,7 @@ func applyDefaults(conf *config.ManualsConfig) { conf.MaxManualBytes = 500 << 20 } if conf.MaxManualsPerUser <= 0 { - conf.MaxManualsPerUser = 1000 + conf.MaxManualsPerUser = 100000 } if conf.MaxUserBytes <= 0 { conf.MaxUserBytes = 10 << 30 diff --git a/domain/service/manuals/service_test.go b/domain/service/manuals/service_test.go index 938bcb3..f9c9753 100644 --- a/domain/service/manuals/service_test.go +++ b/domain/service/manuals/service_test.go @@ -19,6 +19,18 @@ import ( "github.com/stretchr/testify/require" ) +func TestManualCountDefaultAndExplicitLimit(t *testing.T) { + for _, limit := range []int{0, 30, 100000} { + conf := config.ManualsConfig{MaxManualsPerUser: limit} + require.NoError(t, Init(&conf, "", "")) + if limit == 0 { + require.Equal(t, 100000, conf.MaxManualsPerUser) + } else { + require.Equal(t, limit, conf.MaxManualsPerUser) + } + } +} + func TestValidateManualFieldsUsesDocumentedCharacterLimits(t *testing.T) { name, description, access, err := ValidateManualFields(strings.Repeat("说", 120), strings.Repeat("明", 2000), "") require.NoError(t, err) diff --git a/front_vue/src/api/browser_client.cjs b/front_vue/src/api/browser_client.cjs index 2d49e49..82001d8 100644 --- a/front_vue/src/api/browser_client.cjs +++ b/front_vue/src/api/browser_client.cjs @@ -18,6 +18,7 @@ function responseError(response) { const error = new Error(body.message || body.msg || statusMessage) error.code = body.code error.status = response && response.status + error.data = body.data return error } diff --git a/front_vue/src/views/Login.vue b/front_vue/src/views/Login.vue index f6e2168..b433614 100644 --- a/front_vue/src/views/Login.vue +++ b/front_vue/src/views/Login.vue @@ -28,6 +28,7 @@ v-model="ruleForm.username" placeholder="请输入用户名" autocomplete="username" + :disabled="loading" :prefix-icon="User" size="large" /> @@ -38,6 +39,7 @@ v-model="ruleForm.password" placeholder="请输入密码" autocomplete="current-password" + :disabled="loading" :prefix-icon="Lock" show-password size="large" @@ -90,7 +92,21 @@ export default { this.loading = true this.error = '' try { - const user = await accountsApi.login({user_name: this.ruleForm.username, password: this.ruleForm.password}) + const credentials = {user_name: this.ruleForm.username, password: this.ruleForm.password} + let user + try { user = await accountsApi.login(credentials) } + catch (error) { + if (error.status !== 429 || error.data?.can_replace_oldest_session !== true) throw error + this.error = error.message + try { + await this.$confirm('有效登录会话已达到网站上限。继续后将退出最早登录的会话(对应浏览器或设备);若已超过当前上限,将按登录时间退出足够数量的旧会话。', '登录数量已达上限', {confirmButtonText: '退出最早登录并继续', cancelButtonText: '取消', type: 'warning'}) + } catch (action) { + if (action === 'cancel' || action === 'close') return + throw action + } + this.error = '' + user = await accountsApi.login({...credentials, replace_oldest_session: true}) + } this.$store.commit('SET_USERINFO', user) this.ruleForm.password = '' if (user.must_change_password) { await this.$router.replace('/account/security'); return } diff --git a/front_vue/test/account-center.test.cjs b/front_vue/test/account-center.test.cjs index 63ffbd4..3726954 100644 --- a/front_vue/test/account-center.test.cjs +++ b/front_vue/test/account-center.test.cjs @@ -34,6 +34,48 @@ function component(file, api = {}, props = {}, globals = {}) { return {view, definition, commits, confirmations} } +test('full login retries replacement only after explicit confirmation', async () => { + const calls = [] + const {view, confirmations, commits} = component('views/Login.vue', {login: async data => { + calls.push({...data}) + if (calls.length === 1) throw Object.assign(new Error('有效登录会话已达到网站上限'), {status: 429, data: {can_replace_oldest_session: true}}) + return {id: '42'} + }}, {$refs: {ruleForm: {validate: async () => true}}}, {URLSearchParams}) + view.ruleForm = {username: 'friend', password: 'test-password'} + await view.submitForm() + assert.equal(confirmations.length, 1) + assert.match(confirmations[0][0], /最早/) + assert.equal(calls.length, 2) + assert.equal(calls[0].replace_oldest_session, undefined) + assert.equal(calls[1].replace_oldest_session, true) + assert.equal(commits.length, 1) + assert.equal(view.ruleForm.password, '') + assert.equal(view.loading, false) +}) + +test('cancelled replacement and unrelated rate limits never retry login', async () => { + for (const offer of [true, false]) { + let calls = 0, confirmations = 0 + const {view, commits} = component('views/Login.vue', {login: async () => { + calls++ + throw Object.assign(new Error('登录受限'), {status: 429, data: offer ? {can_replace_oldest_session: true} : null}) + }}, {$refs: {ruleForm: {validate: async () => true}}, $confirm: async () => {confirmations++; throw 'cancel'}}, {URLSearchParams}) + view.ruleForm = {username: 'friend', password: 'test-password'} + await view.submitForm() + assert.equal(calls, 1) + assert.equal(confirmations, offer ? 1 : 0) + assert.equal(commits.length, 0) + assert.equal(view.ruleForm.password, 'test-password') + assert.equal(view.error, '登录受限') + assert.equal(view.loading, false) + } +}) + +test('browser errors preserve the session replacement capability', async () => { + const api = createAccountsApi({async request() { return {status: 429, data: {code: 429, message: '登录已满', data: {can_replace_oldest_session: true}}} }}) + await assert.rejects(api.login({user_name: 'friend', password: 'test-password'}), error => error.status === 429 && error.data?.can_replace_oldest_session === true) +}) + test('empty nickname uses the username and profile patches contain only changed fields', () => { const saved = {display_name: '朋友', contact_email: 'friend@example.com', contact_mobile: ''} assert.equal(behavior.profileError({...saved, display_name: ' '}), '') diff --git a/front_vue/test/site-visual-browser.cjs b/front_vue/test/site-visual-browser.cjs index 3e12e58..1062bcd 100644 --- a/front_vue/test/site-visual-browser.cjs +++ b/front_vue/test/site-visual-browser.cjs @@ -132,3 +132,37 @@ test('登录页在320下无水平溢出并生成截图', async () => { await page.screenshot({path: path.join(visualOutput, 'login-mobile-320.png'), fullPage: true}) } finally { await context.close() } }) + +test('满额登录取消不重试,确认后才请求替换旧会话', async () => { + const {page, context} = await setup('/login', 320) + const requests = [] + try { + await page.route('**/api/auth/login', async route => { + const data = route.request().postDataJSON() + requests.push(data) + if (data.replace_oldest_session !== true) { + await route.fulfill({status: 429, json: {code: 429, message: '有效登录会话已达到网站上限', data: {can_replace_oldest_session: true}}}) + } else { + await route.fulfill({json: {code: 0, data: {id: '42', role: 'user', csrf_token: 'replacement-csrf'}}}) + } + }) + await page.getByPlaceholder('请输入用户名').fill('friend') + await page.getByPlaceholder('请输入密码').fill('test-password') + await page.getByRole('button', {name: '登录', exact: true}).click() + const dialog = page.getByRole('dialog') + await dialog.getByText('登录数量已达上限', {exact: true}).waitFor() + assert.equal(requests.length, 1) + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false) + await dialog.getByRole('button', {name: '取消', exact: true}).click() + await dialog.waitFor({state: 'hidden'}) + assert.equal(requests.length, 1) + assert.equal(await page.evaluate(() => window.fixtureStore.state.userInfo), null) + await page.getByRole('button', {name: '登录', exact: true}).click() + await dialog.getByRole('button', {name: '退出最早登录并继续', exact: true}).click() + await page.waitForFunction(() => window.fixtureStore.state.userInfo?.id === '42') + assert.equal(requests.length, 3) + assert.equal(requests[0].replace_oldest_session, undefined) + assert.equal(requests[1].replace_oldest_session, undefined) + assert.equal(requests[2].replace_oldest_session, true) + } finally { await context.close() } +}) diff --git a/internal/accountsmigrate/flow.go b/internal/accountsmigrate/flow.go index 9853e45..f397dad 100644 --- a/internal/accountsmigrate/flow.go +++ b/internal/accountsmigrate/flow.go @@ -77,6 +77,11 @@ func BuildPlan(ctx context.Context, db *sql.DB, source *Source, opts Options) (* return nil, errors.New("connected database differs from requested target") } values := config.DefaultRuntimeValues(source.Config) + // The immutable v1 import used ten projects when no quota was supplied. + // Increasing runtime defaults must not invalidate an already imported marker. + if source.Config.WebProjects.MaxProjectsPerUser == 0 { + values["web_projects"]["max_projects_per_user"] = 10 + } if len(values) != 7 { return nil, errors.New("all seven runtime namespaces are required") } diff --git a/internal/accountsmigrate/flow_test.go b/internal/accountsmigrate/flow_test.go index 89409e0..3a02812 100644 --- a/internal/accountsmigrate/flow_test.go +++ b/internal/accountsmigrate/flow_test.go @@ -164,6 +164,9 @@ func TestV1ImportChecksumSurvivesNewSessionPolicy(t *testing.T) { if plan.ImportSHA256 != original { t.Fatalf("published v1 import changed: got %s, want %s", plan.ImportSHA256, original) } + if plan.RuntimeValues["web_projects"]["max_projects_per_user"] != int64(10) { + t.Fatal("v1 import must retain its original project count default") + } if _, ok := plan.RuntimeValues["account_policy"]["max_active_sessions"]; ok { t.Fatal("v1 import contains a later runtime policy") } From 1e66c134c866dc15b2ada54ef1cc5a172d05e45e Mon Sep 17 00:00:00 2001 From: Jiang Chaoqun Date: Sun, 1 Nov 2026 18:53:26 +0800 Subject: [PATCH 2/2] fix: replace oldest sessions by default at login limit --- README.md | 2 +- api/accounts/handlers.go | 14 +-- api/accounts/session_limit_test.go | 51 +++++----- api/accounts/session_replacement_test.go | 38 +++++-- deploy/pi/README.md | 4 +- docs/accounts-migration.md | 2 +- docs/specs/account-center/design.md | 8 +- domain/service/accounts/service.go | 18 +--- domain/service/accounts/session_limit_test.go | 99 ++++++++++++------- domain/service/accounts/sessions.go | 3 +- front_vue/src/api/browser_client.cjs | 1 - front_vue/src/views/Login.vue | 18 +--- front_vue/test/account-center.test.cjs | 18 +--- front_vue/test/site-visual-browser.cjs | 22 +---- 14 files changed, 143 insertions(+), 155 deletions(-) diff --git a/README.md b/README.md index e838d74..91f2cc9 100644 --- a/README.md +++ b/README.md @@ -247,7 +247,7 @@ go build -o output/bin/web-projects-storage-migrate ./cmd/web-projects-storage-m | --- | --- | | 邀请注册 | 存量用户每个 Asia/Singapore 自然月最多生成3个单次邀请码,固定168小时有效;新用户次月获得资格。关闭注册会永久废弃旧未用码。 | | 个人中心 | 编辑昵称和联系资料、裁剪上传或移除头像;全站优先显示当前昵称与头像。改密验证当前密码后退出旧会话、停用原应用,联系资料不改变登录别名。 | -| 登录管理 | 查看有效会话的登录 IP、UA、客户端、系统、登录时间、到期及剩余时间;可退出选中或其他全部登录,当前登录与应用凭证保持。默认最多同时 5 个有效会话,管理员在账号策略调整上限;满额拒绝新登录。数量不代表物理设备数或在线人数。 | +| 登录管理 | 查看有效会话的登录 IP、UA、客户端、系统、登录时间、到期及剩余时间;可退出选中或其他全部登录,当前登录与应用凭证保持。默认最多同时 5 个有效会话,管理员在账号策略调整上限;满额成功登录默认退出最早会话。数量不代表物理设备数或在线人数。 | | 管理员 | 查看用户有效会话数量及详情,达到 10 个提示;可人工封禁或重置昵称、头像。重置需要当前密码、原因与版本校验并记录审计;封禁/删除阻断会话、应用和 WebDAV,恢复不复活旧凭证。不能操作自己的封禁/删除/撤销管理员。 | | 功能授权 | 家庭藏书和WebDAV分别授权,默认关闭;管理员也需显式授权。应用权限是用户能力与scope的交集。藏书继续共用库存,WebDAV保留现有共享根。 | | 网页审核 | 管理员可审核所有可见性和保留版本,预览入口记录审计;下架/删除带审核锁,用户不能自行重新发布,恢复不会自动上线。 | diff --git a/api/accounts/handlers.go b/api/accounts/handlers.go index d95caa8..77fba03 100644 --- a/api/accounts/handlers.go +++ b/api/accounts/handlers.go @@ -120,9 +120,9 @@ func view(user *model.UserAccount, token string) accountView { // 数据库模式交由账号服务签发受版本约束的会话;配置模式保留旧用户和令牌兼容。 func login(c *gin.Context) { var input struct { - UserName string `json:"user_name"` - Password string `json:"password"` - ReplaceOldestSession bool `json:"replace_oldest_session"` + UserName string `json:"user_name"` + Password string `json:"password"` + LegacyReplacement bool `json:"replace_oldest_session"` // 兼容已上线旧请求,仅解析,不控制登录策略。 } if !bind(c, &input, 16<<10) { return @@ -131,15 +131,7 @@ func login(c *gin.Context) { ctx := ginfmt.RPCContext(c) if accountservice.DatabaseMode() { metadata := accountservice.NewSessionMetadata(middleware.TrustedClientIP(c.Request), c.Request.UserAgent(), "web") - metadata.ReplaceOldestSession = input.ReplaceOldestSession user, token, session, err := accountservice.Login(ctx, input.UserName, input.Password, metadata) - if err == accountservice.ErrSessionLimit { - response := ginfmt.NewErrorResponse(int(apperrors.ErrRateLimited.Code), err.Error()) - response.Data = gin.H{"can_replace_oldest_session": true} - c.Header("Cache-Control", "no-store") - c.JSON(http.StatusTooManyRequests, response) - return - } if err != nil { respond(c, nil, err) return diff --git a/api/accounts/session_limit_test.go b/api/accounts/session_limit_test.go index 3b9983c..92795b8 100644 --- a/api/accounts/session_limit_test.go +++ b/api/accounts/session_limit_test.go @@ -8,13 +8,11 @@ import ( "encoding/base64" "encoding/pem" "net/http" - "strings" "testing" passportapi "github.com/mcoder2014/home_server/api/passport" "github.com/mcoder2014/home_server/config" "github.com/mcoder2014/home_server/domain/service/passport" - apperrors "github.com/mcoder2014/home_server/errors" ) // newSessionLimitHTTPFixture opts into the production default rather than the @@ -34,30 +32,16 @@ func newSessionLimitHTTPFixture(t *testing.T) *httpFixture { return f } -func requireSessionLimitDenied(t *testing.T, response apiResponse, legacy bool) { - t.Helper() - status := http.StatusTooManyRequests - if legacy { - status = http.StatusOK - } - if response.Status != status || response.Code != int(apperrors.ErrorCodeRateLimited) || !strings.Contains(string(response.Raw), "网站上限") || response.Data["token"] != nil { - t.Fatalf("full session limit must preserve the response contract and explain how to recover: status=%d code=%d body=%s", response.Status, response.Code, response.Raw) - } - for _, cookie := range response.Cookies { - if cookie.MaxAge > 0 { - t.Fatal("a rejected login issued a live browser cookie") - } - } -} - -func TestHTTPSessionLimitDefaultRejectsSixthAndExemptsApplicationsAndBasic(t *testing.T) { +func TestHTTPSessionLimitDefaultReplacesSixthAndExemptsApplicationsAndBasic(t *testing.T) { f := newSessionLimitHTTPFixture(t) logins := []*browserSession{} for i := 0; i < 5; i++ { logins = append(logins, f.login(f.member, integrationPassword)) } - failed := f.request(http.MethodPost, "/api/auth/login", nil, map[string]string{"user_name": f.member, "password": integrationPassword}, nil) - requireSessionLimitDenied(t, failed, false) + oldest := logins[0] + latest := f.login(f.member, integrationPassword) + requireDenied(t, f.request(http.MethodGet, "/api/auth/me", oldest, nil, nil)) + logins = append(logins[1:], latest) for _, login := range logins { f.me(login) } @@ -91,7 +75,7 @@ func TestHTTPSessionLimitDefaultRejectsSixthAndExemptsApplicationsAndBasic(t *te requireSuccess(t, f.request(http.MethodGet, "/api/web-share", nil, nil, bearer)) } -func TestHTTPSessionLimitLegacyCannotBypassAndCookieExchangeDoesNotConsumeSlots(t *testing.T) { +func TestHTTPSessionLimitLegacyReplacesOldestAndCookieExchangeDoesNotConsumeSlots(t *testing.T) { f := newSessionLimitHTTPFixture(t) f.router.POST("/passport/login", passportapi.Login) public, _, err := passport.GetLoginRsa(context.Background()) @@ -116,8 +100,10 @@ func TestHTTPSessionLimitLegacyCannotBypassAndCookieExchangeDoesNotConsumeSlots( for i := 0; i < 4; i++ { f.login(f.member, integrationPassword) } - requireSessionLimitDenied(t, f.request(http.MethodPost, "/passport/login", nil, payload, nil), true) - requireSessionLimitDenied(t, f.request(http.MethodPost, "/api/auth/login", nil, map[string]string{"user_name": f.member, "password": integrationPassword}, nil), false) + legacy = requireSuccess(t, f.request(http.MethodPost, "/passport/login", nil, payload, nil)) + requireDenied(t, f.request(http.MethodGet, "/api/account/sessions", nil, nil, map[string]string{"passport": token})) + token = legacy["token"].(string) + f.login(f.member, integrationPassword) page := requireSuccess(t, f.request(http.MethodGet, "/api/account/sessions", nil, nil, map[string]string{"passport": token})) currentID := object(page["current_session"])["id"] for _, path := range []string{"/api/auth/browser-login", "/api/web-share/browser-login", "/api/web-projects/browser-login"} { @@ -159,7 +145,6 @@ func TestHTTPSessionLimitAdministratorChangesApplyToNextLoginWithoutRevokingOld( for _, login := range logins { f.me(login) } - requireSessionLimitDenied(t, f.request(http.MethodPost, "/api/auth/login", nil, map[string]string{"user_name": f.member, "password": integrationPassword}, nil), false) for _, request := range []struct { path string session *browserSession @@ -169,10 +154,20 @@ func TestHTTPSessionLimitAdministratorChangesApplyToNextLoginWithoutRevokingOld( t.Fatal("lowered session limit hid or invalidated existing logins") } } - result := requireSuccess(t, f.request(http.MethodPost, "/api/account/sessions/revoke-others", logins[0], nil, nil)) - if number(result["revoked_count"]) != 5 { + latest := f.login(f.member, integrationPassword) + requireDenied(t, f.request(http.MethodGet, "/api/auth/me", logins[0], nil, nil)) + page := requireSuccess(t, f.request(http.MethodGet, "/api/account/sessions", latest, nil, nil)) + if number(page["total_count"]) != 2 { + t.Fatal("next login did not replace enough old sessions after lowering the cap") + } + result := requireSuccess(t, f.request(http.MethodPost, "/api/account/sessions/revoke-others", latest, nil, nil)) + if number(result["revoked_count"]) != 1 { t.Fatal("could not release slots after lowering the session limit") } f.login(f.member, integrationPassword) - requireSessionLimitDenied(t, f.request(http.MethodPost, "/api/auth/login", nil, map[string]string{"user_name": f.member, "password": integrationPassword}, nil), false) + latest = f.login(f.member, integrationPassword) + page = requireSuccess(t, f.request(http.MethodGet, "/api/account/sessions", latest, nil, nil)) + if number(page["total_count"]) != 2 { + t.Fatal("repeated login exceeded the lowered cap") + } } diff --git a/api/accounts/session_replacement_test.go b/api/accounts/session_replacement_test.go index 6ad8f0a..3f12dcb 100644 --- a/api/accounts/session_replacement_test.go +++ b/api/accounts/session_replacement_test.go @@ -8,7 +8,7 @@ import ( "time" ) -func TestHTTPLoginReplacementRequiresConsentAndRemovesOldestSessions(t *testing.T) { +func TestHTTPLoginReplacementDefaultsToRemovingOldestSessions(t *testing.T) { for _, limit := range []int{5, 2} { t.Run(strconv.Itoa(limit), func(t *testing.T) { f := newSessionLimitHTTPFixture(t) @@ -29,11 +29,6 @@ func TestHTTPLoginReplacementRequiresConsentAndRemovesOldestSessions(t *testing. requireSuccess(t, f.publish(admin, "account_policy", values, "lower-session-replacement-limit")) } input := map[string]interface{}{"user_name": f.member, "password": integrationPassword} - denied := f.request("POST", "/api/auth/login", nil, input, nil) - if denied.Status != http.StatusTooManyRequests || denied.Data["can_replace_oldest_session"] != true || len(denied.Cookies) != 0 { - t.Fatalf("full login must offer replacement without issuing credentials: status=%d data=%v", denied.Status, denied.Data) - } - input["replace_oldest_session"] = true input["password"] = "incorrect-password" wrong := f.request("POST", "/api/auth/login", nil, input, nil) requireDenied(t, wrong) @@ -76,6 +71,33 @@ func TestHTTPLoginReplacementRequiresConsentAndRemovesOldestSessions(t *testing. } } +func TestHTTPLoginLegacyReplacementParameterDoesNotControlPolicy(t *testing.T) { + f := newSessionLimitHTTPFixture(t) + old := make([]*browserSession, 5) + for i := range old { + old[i] = f.login(f.member, integrationPassword) + } + for i, value := range []bool{false, true} { + response := f.request("POST", "/api/auth/login", nil, map[string]interface{}{"user_name": f.member, "password": integrationPassword, "replace_oldest_session": value}, nil) + requireSuccess(t, response) + var issued *http.Cookie + for _, cookie := range response.Cookies { + if cookie.MaxAge > 0 { + issued = cookie + } + } + if issued == nil { + t.Fatal("legacy request did not establish a browser session") + } + f.me(&browserSession{Cookie: issued}) + requireDenied(t, f.request("GET", "/api/auth/me", old[i], nil, nil)) + var active int + if err := f.database.QueryRow("SELECT COUNT(*) FROM login_token WHERE is_expired=0").Scan(&active); err != nil || active != 5 { + t.Fatalf("legacy parameter changed the fixed replacement policy: active=%d error=%v", active, err) + } + } +} + func TestHTTPLoginReplacementRollsBackWhenIssuanceFails(t *testing.T) { f := newSessionLimitHTTPFixture(t) old := make([]*browserSession, 5) @@ -90,7 +112,7 @@ func TestHTTPLoginReplacementRollsBackWhenIssuanceFails(t *testing.T) { t.Error(err) } }) - response := f.request("POST", "/api/auth/login", nil, map[string]interface{}{"user_name": f.member, "password": integrationPassword, "replace_oldest_session": true}, nil) + response := f.request("POST", "/api/auth/login", nil, map[string]interface{}{"user_name": f.member, "password": integrationPassword}, nil) if response.Status != http.StatusServiceUnavailable || len(response.Cookies) != 0 { t.Fatalf("failed issuance must not create a browser session: status=%d", response.Status) } @@ -109,7 +131,7 @@ func TestHTTPConcurrentLoginReplacementKeepsSessionCap(t *testing.T) { for i := 0; i < cap(results); i++ { go func() { <-start - results <- f.request("POST", "/api/auth/login", nil, map[string]interface{}{"user_name": f.member, "password": integrationPassword, "replace_oldest_session": true}, nil) + results <- f.request("POST", "/api/auth/login", nil, map[string]interface{}{"user_name": f.member, "password": integrationPassword}, nil) }() } close(start) diff --git a/deploy/pi/README.md b/deploy/pi/README.md index cbcb2d1..b3ffe7b 100644 --- a/deploy/pi/README.md +++ b/deploy/pi/README.md @@ -27,9 +27,9 @@ | --- | --- | --- | | `web_projects.max_projects_per_user` | 100000,允许 1–100000 | 发布现有动态配置的新 revision,仅调整该字段;保留其他字段、历史、校验和及 generation 语义 | | `manuals.max_manuals_per_user` | 100000 | 私有 YAML 有显式值时改为 100000,重启后端生效 | -| `account_policy.max_active_sessions` | 5,允许 1–100 | 保留既有值;网页登录满额时由用户明确确认是否退出最早会话并继续 | +| `account_policy.max_active_sessions` | 5,允许 1–100 | 保留既有值;网站及旧 RSA 登录满额时默认退出最早有效会话并继续 | -部署前备份 YAML、动态配置及完整旧 release。前后端使用同一提交,验证满额取消不改变旧会话、确认替换后数量不超过策略、其他账号不受影响及新会话签发失败时撤销回滚。该版本不需要新增 DDL。网页数量超过 100 后,旧程序的配置范围校验会拒绝启动;回滚后端前须先用兼容新范围的程序发布旧范围内的数量,不能直接切回旧二进制。说明书、网页文件、存储容量与权限配置沿用原值。 +部署前备份 YAML、动态配置及完整旧 release。前后端使用同一提交,验证满额登录默认退出最早会话、旧客户端参数不改变策略、错误密码和其他账号不受影响,以及新会话签发失败时撤销回滚。该版本不需要新增 DDL。网页数量超过 100 后,旧程序的配置范围校验会拒绝启动;回滚后端前须先用兼容新范围的程序发布旧范围内的数量,不能直接切回旧二进制。说明书、网页文件、存储容量与权限配置沿用原值。 ## 账号接口的同源代理 diff --git a/docs/accounts-migration.md b/docs/accounts-migration.md index 5376fff..9bde55f 100644 --- a/docs/accounts-migration.md +++ b/docs/accounts-migration.md @@ -35,7 +35,7 @@ WebDAV Basic 是持续支持的客户端认证协议。`/webdav/` 和 `/webdav_d | `web_projects` | 网页模块、上传限制、并发、版本数量、每用户项目/总字节配额、磁盘保留空间、清理开关、删除保留天数 | 请求或清理批次读取;已有删除期限不随配置缩短 | | `library` / `webdav` | 全站模块开关 | 与每个用户的能力授权共同检查 | | `site` | 网站标题、纯文本公告 | 普通快照刷新,允许白名单公开展示 | -| `account_policy` | 会话 TTL、同时有效登录上限(默认 5、可配 1–100)、临时密码期限、密码最低长度、bcrypt 成本 | 用于新签发会话与新设置密码;调低上限保留已有会话,满额拒绝新签发 | +| `account_policy` | 会话 TTL、同时有效登录上限(默认 5、可配 1–100)、临时密码期限、密码最低长度、bcrypt 成本 | 用于新签发会话与新设置密码;调低上限保留已有会话,下次成功登录时默认退出最早会话以满足上限 | 每月 3 个邀请码、有效 7 天、`Asia/Singapore` 月份计算是固定规则,管理页面只读展示。 diff --git a/docs/specs/account-center/design.md b/docs/specs/account-center/design.md index c025046..d3f52a5 100644 --- a/docs/specs/account-center/design.md +++ b/docs/specs/account-center/design.md @@ -96,11 +96,11 @@ 管理员在“站点设置 → 账号策略”配置 `account_policy.max_active_sessions`,默认 5,允许 1–100 个有效网站会话。普通网站登录与旧 RSA 登录共享上限;受限改密会话也占用名额。应用 Token、WebDAV Basic 与同一 Token 的 Cookie 兑换不产生名额。 -达到上限时,未确认替换的登录保留已有会话并返回 429,错误 `data.can_replace_oldest_session=true` 表示可以选择替换。网站显示“退出最早登录并继续”确认框;取消不重试,确认后才在 `POST /api/auth/login` 中发送 `replace_oldest_session: true`。其他限流响应不提供此标志,错误密码不提供替换能力。旧 RSA 入口保留原 HTTP 200 响应封装,通过错误码表达同一限制,不返回新 Token。 +达到上限时,凭据验证成功的登录默认退出最早有效会话,再建立新会话。网站和旧 RSA 入口使用同一固定替换策略,网站直接完成登录。旧 `replace_oldest_session` 请求字段仅作兼容解析,省略、传 `true` 或 `false` 均不改变替换行为;错误密码和其他限流不会撤销旧会话。旧 RSA 入口保留原 HTTP 200 响应封装。 -调高上限对下一次签发生效;调低不主动退出已有会话。确认替换时,按 `create_time ASC, id ASC` 撤销最早的有效会话,撤销数量为 `当前有效数 - 上限 + 1`,使新登录完成后的数量等于上限;未满额时不撤销。这里的“设备”表示对应登录凭据,复制同一凭据的访问会一起失效,不能独立识别物理设备。 +调高上限对下一次签发生效;调低不主动退出已有会话。下一次成功登录时,按 `create_time ASC, id ASC` 撤销最早的有效会话,撤销数量为 `当前有效数 - 上限 + 1`,使新登录完成后的数量等于上限;未满额时不撤销。这里的“设备”表示对应登录凭据,复制同一凭据的访问会一起失效,不能独立识别物理设备。 -签发先锁同一用户行,再用当前锁定读读取有效会话,普通登录最多读取 `max_active_sessions` 条,确认替换时读取完整有效集合。凭据校验、撤销旧会话、写入新会话及最近登录时间共用原有事务;写入失败全部回滚。过期、已撤销、旧认证版本和其他账号会话不参与替换。不得使用旧事务快照判断名额;并发网站/旧入口登录不能突破上限。登录管理继续显示有效数量与网站上限。 +签发先锁同一用户行,再用当前锁定读读取按创建时间排序的完整有效会话集合。凭据校验、撤销旧会话、写入新会话及最近登录时间共用原有事务;写入失败全部回滚。过期、已撤销、旧认证版本和其他账号会话不参与替换。不得使用旧事务快照判断名额;并发网站/旧入口登录不能突破上限。登录管理继续显示有效数量与网站上限。 | 字段 | 来源与展示规则 | |---|---| @@ -391,7 +391,7 @@ sequenceDiagram | 已选定的设计决策 | 原因 | |---|---| | 以会话计数,不以物理设备计数 | 可从现有服务端数据准确判断;不误导风险判断 | -| 默认 5 个有效登录,管理员可调整;满额拒绝新登录 | 限制凭据数量,保留已有登录;并发签发在用户锁内校验 | +| 默认 5 个有效登录,管理员可调整;满额登录自动退出最早会话 | 保持新登录可用与数量有界;并发替换和签发在用户锁内完成 | | 首版头像入独立DB表,最多128 KiB/用户 | 小规模场景下原子重置与运维成本更低;正常账号查询不读图像 | | 数量达到10仅提示;人工封禁 | 缺乏真实基线,不能以数量代替风险证据 | | 指定/其他会话退出不影响应用;原全量退出保留行为 | 满足细粒度控制,同时避免破坏现有契约 | diff --git a/domain/service/accounts/service.go b/domain/service/accounts/service.go index 3b833e1..12a21e6 100644 --- a/domain/service/accounts/service.go +++ b/domain/service/accounts/service.go @@ -23,7 +23,6 @@ import ( ) var ErrCredentials = apperrors.WithMessage(apperrors.ErrUnauthorized, "账号或密码错误") -var ErrSessionLimit = apperrors.WithMessage(apperrors.ErrRateLimited, "有效登录会话已达到网站上限,请从已登录设备退出其他登录或联系管理员") func DatabaseMode() bool { return config.Global().IdentitySource == "database" @@ -156,7 +155,6 @@ func Login(ctx context.Context, key, password string, metadata ...SessionMetadat info := NewSessionMetadata("", "", "unknown") if len(metadata) > 0 { info = NewSessionMetadata(metadata[0].LoginIP, metadata[0].UserAgent, metadata[0].LoginSource) - info.ReplaceOldestSession = metadata[0].ReplaceOldestSession } // 锁定账号后确认凭据快照仍有效,必要时升级 bcrypt 成本,再原子写入新会话。 err = database.Transaction(func(tx *gorm.DB) error { @@ -185,7 +183,7 @@ func Login(ctx context.Context, key, password string, metadata ...SessionMetadat } // issueSessionTx requires the user row lock. It checks the effective-session -// limit with a current locking read. An explicit replacement revokes the oldest +// limit with a current locking read. A full account always replaces its oldest // excess sessions in this same transaction before issuing the new token. // Temporary passwords only receive ten-minute password-change sessions. func issueSessionTx(tx *gorm.DB, user *model.UserAccount, metadata SessionMetadata) (string, *model.AccountSession, error) { @@ -197,22 +195,14 @@ func issueSessionTx(tx *gorm.DB, user *model.UserAccount, metadata SessionMetada var active []struct{ ID int64 } // A locking read sees sessions committed while this transaction waited for // the user lock, even if an earlier consistent read established a snapshot. - query := activeSessionQuery(tx, now).Select("s.id"). + err := activeSessionQuery(tx, now).Select("s.id"). Where("s.user_id = ? AND s.auth_version = ?", user.ID, user.AuthVersion). - Clauses(clause.Locking{Strength: "UPDATE"}) - if !metadata.ReplaceOldestSession { - query = query.Limit(policy.MaxActiveSessions) - } else { - query = query.Order("s.create_time ASC, s.id ASC") - } - err := query.Find(&active).Error + Order("s.create_time ASC, s.id ASC"). + Clauses(clause.Locking{Strength: "UPDATE"}).Find(&active).Error if err != nil { return "", nil, err } if len(active) >= policy.MaxActiveSessions { - if !metadata.ReplaceOldestSession { - return "", nil, ErrSessionLimit - } // A lowered policy can require more than one revocation. Keep the newest // limit-1 sessions so the replacement also fits the current policy. ids := make([]int64, len(active)-policy.MaxActiveSessions+1) diff --git a/domain/service/accounts/session_limit_test.go b/domain/service/accounts/session_limit_test.go index 438435d..956e088 100644 --- a/domain/service/accounts/session_limit_test.go +++ b/domain/service/accounts/session_limit_test.go @@ -6,7 +6,6 @@ import ( "encoding/json" "errors" "strconv" - "strings" "testing" "time" @@ -39,7 +38,7 @@ func sessionLimitDatabase(t *testing.T) *gorm.DB { return database } -func TestSessionLimitDefaultRejectsSixthLoginAndKeepsExistingSessions(t *testing.T) { +func TestSessionLimitDefaultReplacesOldestForEveryLoginEntry(t *testing.T) { database := sessionLimitDatabase(t) ctx := context.Background() tokens := make([]string, 0, 5) @@ -51,22 +50,32 @@ func TestSessionLimitDefaultRejectsSixthLoginAndKeepsExistingSessions(t *testing tokens = append(tokens, token) } _, token, session, err := Login(ctx, "session8101", reviewPassword) - if !errors.Is(err, apperrors.ErrRateLimited) || token != "" || session != nil || !strings.Contains(err.Error(), "网站上限") { - t.Fatalf("sixth login must explain the session limit without issuing a token: token_issued=%t session_issued=%t error=%v", token != "", session != nil, err) + if err != nil || token == "" || session == nil { + t.Fatalf("sixth login must replace the oldest session: token_issued=%t session_issued=%t error=%v", token != "", session != nil, err) } - if token, err := IssueVerifiedSession(ctx, 8101, 1); token != "" || !errors.Is(err, apperrors.ErrRateLimited) { - t.Fatalf("verified legacy issuance bypassed the limit: token_issued=%t error=%v", token != "", err) + if _, _, err := CheckSession(ctx, tokens[0], false); !errors.Is(err, apperrors.ErrUnauthorized) { + t.Fatal("sixth login did not revoke the oldest session:", err) } - var rows int64 - if err := database.Table(dal.TableUserToken).Where("user_id = ?", 8101).Count(&rows).Error; err != nil || rows != 5 { - t.Fatalf("rejected login changed session rows: count=%d error=%v", rows, err) - } - for _, token := range tokens { - if _, _, err := CheckSession(ctx, token, false); err != nil { - t.Fatal("limit rejection invalidated an existing login:", err) + for _, existing := range tokens[1:] { + if _, _, err := CheckSession(ctx, existing, false); err != nil { + t.Fatal("replacement revoked a newer session:", err) } } - page, err := ListSessions(ctx, tokens[0], SessionFilter{}) + legacy, err := IssueVerifiedSession(ctx, 8101, 1) + if err != nil || legacy == "" { + t.Fatal("verified legacy login did not use the same replacement policy:", err) + } + if _, _, err := CheckSession(ctx, tokens[1], false); !errors.Is(err, apperrors.ErrUnauthorized) { + t.Fatal("legacy login did not revoke the next oldest session:", err) + } + if _, _, err := CheckSession(ctx, token, false); err != nil { + t.Fatal("legacy login revoked the newer website session:", err) + } + var active int64 + if err := database.Table(dal.TableUserToken).Where("user_id = ? AND is_expired = 0", 8101).Count(&active).Error; err != nil || active != 5 { + t.Fatalf("default replacement exceeded the cap: active=%d error=%v", active, err) + } + page, err := ListSessions(ctx, legacy, SessionFilter{}) if err != nil || page.TotalCount != 5 { t.Fatalf("session page omitted the applied limit: page=%v error=%v", page, err) } @@ -96,20 +105,18 @@ func TestSessionLimitConcurrentMixedIssuanceNeverExceedsCap(t *testing.T) { }(i%2 == 0) } close(start) - issued, limited := 0, 0 + issued := 0 for i := 0; i < parallelism; i++ { switch err := <-results; { case err == nil: issued++ - case errors.Is(err, apperrors.ErrRateLimited): - limited++ default: t.Errorf("concurrent issuance returned an unexpected error: %v", err) } } - var rows int64 - if err := database.Table(dal.TableUserToken).Where("user_id = ?", 8101).Count(&rows).Error; err != nil || issued != 5 || limited != parallelism-5 || rows != 5 { - t.Fatalf("concurrent login exceeded the cap: issued=%d limited=%d rows=%d error=%v", issued, limited, rows, err) + var active int64 + if err := database.Table(dal.TableUserToken).Where("user_id = ? AND is_expired = 0", 8101).Count(&active).Error; err != nil || issued != parallelism || active != 5 { + t.Fatalf("concurrent replacement violated the policy: issued=%d active=%d error=%v", issued, active, err) } }) } @@ -120,6 +127,7 @@ func TestSessionLimitUsesCurrentReadAfterAnEarlierTransactionSnapshot(t *testing setSessionLimit(t, 1) ready, resume := make(chan struct{}), make(chan struct{}) result := make(chan error, 1) + var replacement string go func() { result <- database.Transaction(func(tx *gorm.DB) error { var rows int64 @@ -132,7 +140,7 @@ func TestSessionLimitUsesCurrentReadAfterAnEarlierTransactionSnapshot(t *testing if err != nil { return err } - _, _, err = issueSessionTx(tx, user, SessionMetadata{}) + replacement, _, err = issueSessionTx(tx, user, SessionMetadata{}) return err }, &sql.TxOptions{Isolation: sql.LevelRepeatableRead}) }() @@ -144,18 +152,25 @@ func TestSessionLimitUsesCurrentReadAfterAnEarlierTransactionSnapshot(t *testing case <-time.After(5 * time.Second): t.Fatal("transaction did not establish its initial snapshot") } - if _, err := IssueVerifiedSession(context.Background(), 8101, 1); err != nil { + first, err := IssueVerifiedSession(context.Background(), 8101, 1) + if err != nil { t.Fatal(err) } resume <- struct{}{} select { case err := <-result: - if !errors.Is(err, apperrors.ErrRateLimited) { - t.Fatalf("an earlier snapshot hid the newly committed session: %v", err) + if err != nil || replacement == "" { + t.Fatalf("replacement after an earlier snapshot failed: %v", err) } case <-time.After(5 * time.Second): t.Fatal("session check did not finish") } + if _, _, err := CheckSession(context.Background(), first, false); !errors.Is(err, apperrors.ErrUnauthorized) { + t.Fatal("an earlier snapshot hid the committed session from replacement:", err) + } + if _, _, err := CheckSession(context.Background(), replacement, false); err != nil { + t.Fatal("replacement session is unusable:", err) + } } func TestSessionLimitInactiveRowsDoNotConsumeSlotsAndRestrictedRowsDo(t *testing.T) { @@ -188,11 +203,15 @@ func TestSessionLimitInactiveRowsDoNotConsumeSlotsAndRestrictedRowsDo(t *testing if err != nil { t.Fatal(err) } - if _, err := IssueVerifiedSession(context.Background(), 8102, 1); !errors.Is(err, apperrors.ErrRateLimited) { - t.Fatalf("valid initial-password sessions did not consume a slot: %v", err) + replacement, err := IssueVerifiedSession(context.Background(), 8102, 1) + if err != nil { + t.Fatal("initial-password session replacement failed:", err) + } + if _, _, err := CheckSession(context.Background(), restricted, true); !errors.Is(err, apperrors.ErrUnauthorized) { + t.Fatal("valid initial-password session did not consume a replacement slot:", err) } - if _, _, err := CheckSession(context.Background(), restricted, true); err != nil { - t.Fatal("limited login was revoked by a rejected new login:", err) + if _, _, err := CheckSession(context.Background(), replacement, true); err != nil { + t.Fatal("replacement lost its password-change session:", err) } } @@ -213,17 +232,27 @@ func TestSessionLimitLoweringKeepsOldLoginsAndRevocationReleasesSlots(t *testing t.Fatal("lowering the cap invalidated an existing login:", err) } } - if _, err := IssueVerifiedSession(ctx, 8101, 1); !errors.Is(err, apperrors.ErrRateLimited) { - t.Fatalf("lowered cap was not applied to the next login: %v", err) + replacement, err := IssueVerifiedSession(ctx, 8101, 1) + if err != nil { + t.Fatal("login after lowering the cap failed:", err) + } + page, err := ListSessions(ctx, replacement, SessionFilter{}) + if err != nil || page.TotalCount != 2 { + t.Fatalf("lowered cap was not applied to replacement: page=%v error=%v", page, err) } - if result, err := RevokeOtherSessions(ctx, tokens[0]); err != nil || result.RevokedCount != 4 { + if result, err := RevokeOtherSessions(ctx, replacement); err != nil || result.RevokedCount != 1 { t.Fatalf("could not release old session slots: result=%v error=%v", result, err) } if _, err := IssueVerifiedSession(ctx, 8101, 1); err != nil { t.Fatal("revocation did not release a session slot:", err) } - if _, err := IssueVerifiedSession(ctx, 8101, 1); !errors.Is(err, apperrors.ErrRateLimited) { - t.Fatalf("second newly issued login exceeded the lowered cap: %v", err) + newest, err := IssueVerifiedSession(ctx, 8101, 1) + if err != nil { + t.Fatal("subsequent login did not replace the oldest session:", err) + } + page, err = ListSessions(ctx, newest, SessionFilter{}) + if err != nil || page.TotalCount != 2 { + t.Fatalf("subsequent replacement exceeded the cap: page=%v error=%v", page, err) } setSessionLimit(t, 3) if _, err := IssueVerifiedSession(ctx, 8101, 1); err != nil { @@ -268,9 +297,9 @@ func TestSessionReplacementUsesOnlyEffectiveRowsInLoginOrder(t *testing.T) { t.Fatal(err) } } - _, token, _, err := Login(context.Background(), "session8101", reviewPassword, SessionMetadata{ReplaceOldestSession: true}) + _, token, _, err := Login(context.Background(), "session8101", reviewPassword) if err != nil || token == "" { - t.Fatal("explicit replacement login failed:", err) + t.Fatal("default replacement login failed:", err) } var rows []struct { ID int64 diff --git a/domain/service/accounts/sessions.go b/domain/service/accounts/sessions.go index 54f332d..1858028 100644 --- a/domain/service/accounts/sessions.go +++ b/domain/service/accounts/sessions.go @@ -26,11 +26,10 @@ import ( const SessionWarningThreshold = 10 -// SessionMetadata describes issuance and the user's explicit replacement choice. +// SessionMetadata describes issuance, never the device presenting a token later. // Client values are display hints, never authentication or a device identity. type SessionMetadata struct { LoginIP, UserAgent, ClientName, OSName, DeviceType, LoginSource string - ReplaceOldestSession bool } // NewSessionMetadata parses a bounded UTF-8 UA using the pinned parser. Unknown diff --git a/front_vue/src/api/browser_client.cjs b/front_vue/src/api/browser_client.cjs index 82001d8..2d49e49 100644 --- a/front_vue/src/api/browser_client.cjs +++ b/front_vue/src/api/browser_client.cjs @@ -18,7 +18,6 @@ function responseError(response) { const error = new Error(body.message || body.msg || statusMessage) error.code = body.code error.status = response && response.status - error.data = body.data return error } diff --git a/front_vue/src/views/Login.vue b/front_vue/src/views/Login.vue index b433614..f6e2168 100644 --- a/front_vue/src/views/Login.vue +++ b/front_vue/src/views/Login.vue @@ -28,7 +28,6 @@ v-model="ruleForm.username" placeholder="请输入用户名" autocomplete="username" - :disabled="loading" :prefix-icon="User" size="large" /> @@ -39,7 +38,6 @@ v-model="ruleForm.password" placeholder="请输入密码" autocomplete="current-password" - :disabled="loading" :prefix-icon="Lock" show-password size="large" @@ -92,21 +90,7 @@ export default { this.loading = true this.error = '' try { - const credentials = {user_name: this.ruleForm.username, password: this.ruleForm.password} - let user - try { user = await accountsApi.login(credentials) } - catch (error) { - if (error.status !== 429 || error.data?.can_replace_oldest_session !== true) throw error - this.error = error.message - try { - await this.$confirm('有效登录会话已达到网站上限。继续后将退出最早登录的会话(对应浏览器或设备);若已超过当前上限,将按登录时间退出足够数量的旧会话。', '登录数量已达上限', {confirmButtonText: '退出最早登录并继续', cancelButtonText: '取消', type: 'warning'}) - } catch (action) { - if (action === 'cancel' || action === 'close') return - throw action - } - this.error = '' - user = await accountsApi.login({...credentials, replace_oldest_session: true}) - } + const user = await accountsApi.login({user_name: this.ruleForm.username, password: this.ruleForm.password}) this.$store.commit('SET_USERINFO', user) this.ruleForm.password = '' if (user.must_change_password) { await this.$router.replace('/account/security'); return } diff --git a/front_vue/test/account-center.test.cjs b/front_vue/test/account-center.test.cjs index 3726954..e9f61e5 100644 --- a/front_vue/test/account-center.test.cjs +++ b/front_vue/test/account-center.test.cjs @@ -34,26 +34,23 @@ function component(file, api = {}, props = {}, globals = {}) { return {view, definition, commits, confirmations} } -test('full login retries replacement only after explicit confirmation', async () => { +test('login submits credentials once without a replacement option', async () => { const calls = [] const {view, confirmations, commits} = component('views/Login.vue', {login: async data => { calls.push({...data}) - if (calls.length === 1) throw Object.assign(new Error('有效登录会话已达到网站上限'), {status: 429, data: {can_replace_oldest_session: true}}) return {id: '42'} }}, {$refs: {ruleForm: {validate: async () => true}}}, {URLSearchParams}) view.ruleForm = {username: 'friend', password: 'test-password'} await view.submitForm() - assert.equal(confirmations.length, 1) - assert.match(confirmations[0][0], /最早/) - assert.equal(calls.length, 2) + assert.equal(confirmations.length, 0) + assert.equal(calls.length, 1) assert.equal(calls[0].replace_oldest_session, undefined) - assert.equal(calls[1].replace_oldest_session, true) assert.equal(commits.length, 1) assert.equal(view.ruleForm.password, '') assert.equal(view.loading, false) }) -test('cancelled replacement and unrelated rate limits never retry login', async () => { +test('login errors never offer or retry a configurable session replacement', async () => { for (const offer of [true, false]) { let calls = 0, confirmations = 0 const {view, commits} = component('views/Login.vue', {login: async () => { @@ -63,7 +60,7 @@ test('cancelled replacement and unrelated rate limits never retry login', async view.ruleForm = {username: 'friend', password: 'test-password'} await view.submitForm() assert.equal(calls, 1) - assert.equal(confirmations, offer ? 1 : 0) + assert.equal(confirmations, 0) assert.equal(commits.length, 0) assert.equal(view.ruleForm.password, 'test-password') assert.equal(view.error, '登录受限') @@ -71,11 +68,6 @@ test('cancelled replacement and unrelated rate limits never retry login', async } }) -test('browser errors preserve the session replacement capability', async () => { - const api = createAccountsApi({async request() { return {status: 429, data: {code: 429, message: '登录已满', data: {can_replace_oldest_session: true}}} }}) - await assert.rejects(api.login({user_name: 'friend', password: 'test-password'}), error => error.status === 429 && error.data?.can_replace_oldest_session === true) -}) - test('empty nickname uses the username and profile patches contain only changed fields', () => { const saved = {display_name: '朋友', contact_email: 'friend@example.com', contact_mobile: ''} assert.equal(behavior.profileError({...saved, display_name: ' '}), '') diff --git a/front_vue/test/site-visual-browser.cjs b/front_vue/test/site-visual-browser.cjs index 1062bcd..78a1e99 100644 --- a/front_vue/test/site-visual-browser.cjs +++ b/front_vue/test/site-visual-browser.cjs @@ -133,36 +133,22 @@ test('登录页在320下无水平溢出并生成截图', async () => { } finally { await context.close() } }) -test('满额登录取消不重试,确认后才请求替换旧会话', async () => { +test('登录成功后直接进入页面,不提供替换会话配置或二次确认', async () => { const {page, context} = await setup('/login', 320) const requests = [] try { await page.route('**/api/auth/login', async route => { const data = route.request().postDataJSON() requests.push(data) - if (data.replace_oldest_session !== true) { - await route.fulfill({status: 429, json: {code: 429, message: '有效登录会话已达到网站上限', data: {can_replace_oldest_session: true}}}) - } else { - await route.fulfill({json: {code: 0, data: {id: '42', role: 'user', csrf_token: 'replacement-csrf'}}}) - } + await route.fulfill({json: {code: 0, data: {id: '42', role: 'user', csrf_token: 'replacement-csrf'}}}) }) await page.getByPlaceholder('请输入用户名').fill('friend') await page.getByPlaceholder('请输入密码').fill('test-password') await page.getByRole('button', {name: '登录', exact: true}).click() - const dialog = page.getByRole('dialog') - await dialog.getByText('登录数量已达上限', {exact: true}).waitFor() + await page.waitForFunction(() => window.fixtureStore.state.userInfo?.id === '42') assert.equal(requests.length, 1) + assert.equal(await page.getByRole('dialog').count(), 0) assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false) - await dialog.getByRole('button', {name: '取消', exact: true}).click() - await dialog.waitFor({state: 'hidden'}) - assert.equal(requests.length, 1) - assert.equal(await page.evaluate(() => window.fixtureStore.state.userInfo), null) - await page.getByRole('button', {name: '登录', exact: true}).click() - await dialog.getByRole('button', {name: '退出最早登录并继续', exact: true}).click() - await page.waitForFunction(() => window.fixtureStore.state.userInfo?.id === '42') - assert.equal(requests.length, 3) assert.equal(requests[0].replace_oldest_session, undefined) - assert.equal(requests[1].replace_oldest_session, undefined) - assert.equal(requests[2].replace_oldest_session, true) } finally { await context.close() } })