diff --git a/README.md b/README.md index 7c40c7d..91f2cc9 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`,当前版本不会被历史清理删除。 ### 前端产物约定 @@ -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 a195785..77fba03 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"` + LegacyReplacement bool `json:"replace_oldest_session"` // 兼容已上线旧请求,仅解析,不控制登录策略。 } if !bind(c, &input, 16<<10) { 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 new file mode 100644 index 0000000..3f12dcb --- /dev/null +++ b/api/accounts/session_replacement_test.go @@ -0,0 +1,145 @@ +package accounts_test + +import ( + "crypto/sha256" + "net/http" + "strconv" + "testing" + "time" +) + +func TestHTTPLoginReplacementDefaultsToRemovingOldestSessions(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} + 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 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) + 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}, 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}, 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..b3ffe7b 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 | 保留既有值;网站及旧 RSA 登录满额时默认退出最早有效会话并继续 | + +部署前备份 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..9bde55f 100644 --- a/docs/accounts-migration.md +++ b/docs/accounts-migration.md @@ -35,11 +35,13 @@ 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` 月份计算是固定规则,管理页面只读展示。 -新增资源策略默认每用户 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..d3f52a5 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。退出或自然过期后可再次登录。调高上限对下一次签发生效;调低不主动退出已有会话,在数量降至新上限以下前拒绝继续签发。登录管理显示有效数量与网站上限。 +达到上限时,凭据验证成功的登录默认退出最早有效会话,再建立新会话。网站和旧 RSA 入口使用同一固定替换策略,网站直接完成登录。旧 `replace_oldest_session` 请求字段仅作兼容解析,省略、传 `true` 或 `false` 均不改变替换行为;错误密码和其他限流不会撤销旧会话。旧 RSA 入口保留原 HTTP 200 响应封装。 -签发先锁同一用户行,再以当前锁定读读取最多 `max_active_sessions` 条有效会话,足额即拒绝,不生成凭据、不修改最近登录时间。不得使用旧事务快照判断名额;并发网站/旧入口登录不能超过上限。 +调高上限对下一次签发生效;调低不主动退出已有会话。下一次成功登录时,按 `create_time ASC, id ASC` 撤销最早的有效会话,撤销数量为 `当前有效数 - 上限 + 1`,使新登录完成后的数量等于上限;未满额时不撤销。这里的“设备”表示对应登录凭据,复制同一凭据的访问会一起失效,不能独立识别物理设备。 + +签发先锁同一用户行,再用当前锁定读读取按创建时间排序的完整有效会话集合。凭据校验、撤销旧会话、写入新会话及最近登录时间共用原有事务;写入失败全部回滚。过期、已撤销、旧认证版本和其他账号会话不参与替换。不得使用旧事务快照判断名额;并发网站/旧入口登录不能突破上限。登录管理继续显示有效数量与网站上限。 | 字段 | 来源与展示规则 | |---|---| @@ -389,7 +391,7 @@ sequenceDiagram | 已选定的设计决策 | 原因 | |---|---| | 以会话计数,不以物理设备计数 | 可从现有服务端数据准确判断;不误导风险判断 | -| 默认 5 个有效登录,管理员可调整;满额拒绝新登录 | 限制凭据数量,保留已有登录;并发签发在用户锁内校验 | +| 默认 5 个有效登录,管理员可调整;满额登录自动退出最早会话 | 保持新登录可用与数量有界;并发替换和签发在用户锁内完成 | | 首版头像入独立DB表,最多128 KiB/用户 | 小规模场景下原子重置与运维成本更低;正常账号查询不读图像 | | 数量达到10仅提示;人工封禁 | 缺乏真实基线,不能以数量代替风险证据 | | 指定/其他会话退出不影响应用;原全量退出保留行为 | 满足细粒度控制,同时避免破坏现有契约 | 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..12a21e6 100644 --- a/domain/service/accounts/service.go +++ b/domain/service/accounts/service.go @@ -183,7 +183,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. 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) { policy := config.Runtime().AccountPolicy @@ -196,13 +197,22 @@ func issueSessionTx(tx *gorm.DB, user *model.UserAccount, metadata SessionMetada // the user lock, even if an earlier consistent read established a snapshot. err := activeSessionQuery(tx, now).Select("s.id"). Where("s.user_id = ? AND s.auth_version = ?", user.ID, user.AuthVersion). - Limit(policy.MaxActiveSessions). + 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 { - return "", nil, apperrors.WithMessage(apperrors.ErrRateLimited, "有效登录会话已达到网站上限,请从已登录设备退出其他登录或联系管理员") + // 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..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 { @@ -246,3 +275,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) + if err != nil || token == "" { + t.Fatal("default 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..1858028 100644 --- a/domain/service/accounts/sessions.go +++ b/domain/service/accounts/sessions.go @@ -27,7 +27,7 @@ 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. +// Client values are display hints, never authentication or a device identity. type SessionMetadata struct { LoginIP, UserAgent, ClientName, OSName, DeviceType, LoginSource string } 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/test/account-center.test.cjs b/front_vue/test/account-center.test.cjs index 63ffbd4..e9f61e5 100644 --- a/front_vue/test/account-center.test.cjs +++ b/front_vue/test/account-center.test.cjs @@ -34,6 +34,40 @@ function component(file, api = {}, props = {}, globals = {}) { return {view, definition, commits, confirmations} } +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}) + return {id: '42'} + }}, {$refs: {ruleForm: {validate: async () => true}}}, {URLSearchParams}) + view.ruleForm = {username: 'friend', password: 'test-password'} + await view.submitForm() + assert.equal(confirmations.length, 0) + assert.equal(calls.length, 1) + assert.equal(calls[0].replace_oldest_session, undefined) + assert.equal(commits.length, 1) + assert.equal(view.ruleForm.password, '') + assert.equal(view.loading, false) +}) + +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 () => { + 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, 0) + assert.equal(commits.length, 0) + assert.equal(view.ruleForm.password, 'test-password') + assert.equal(view.error, '登录受限') + assert.equal(view.loading, false) + } +}) + 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..78a1e99 100644 --- a/front_vue/test/site-visual-browser.cjs +++ b/front_vue/test/site-visual-browser.cjs @@ -132,3 +132,23 @@ 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) + 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() + 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) + assert.equal(requests[0].replace_oldest_session, undefined) + } 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") }