diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 794c72c..bda7c1d 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -37,10 +37,16 @@ jobs: run: | docker exec -i ${{ job.services.mariadb.id }} mariadb -uroot -pci-isolated-root <<'SQL' CREATE DATABASE home_server_accounts_http_test_ci; + CREATE DATABASE home_server_accounts_migration_test_ci; + CREATE DATABASE home_server_account_sessions_test_ci; + CREATE DATABASE home_server_accounts_runtime_test; CREATE DATABASE home_server_analytics_test_ci; CREATE DATABASE home_server_readcache_test_ci; CREATE USER 'home_server_ci'@'%' IDENTIFIED BY 'ci-isolated-tests'; GRANT ALL ON home_server_accounts_http_test_ci.* TO 'home_server_ci'@'%'; + GRANT ALL ON home_server_accounts_migration_test_ci.* TO 'home_server_ci'@'%'; + GRANT ALL ON home_server_account_sessions_test_ci.* TO 'home_server_ci'@'%'; + GRANT ALL ON home_server_accounts_runtime_test.* TO 'home_server_ci'@'%'; GRANT ALL ON home_server_analytics_test_ci.* TO 'home_server_ci'@'%'; GRANT ALL ON home_server_readcache_test_ci.* TO 'home_server_ci'@'%'; SQL @@ -61,9 +67,12 @@ jobs: HOME_SERVER_TEST_REDIS_ADDR: '127.0.0.1:16389' HOME_SERVER_CACHE_HTTP_TEST: '1' ACCOUNTS_HTTP_TEST_DSN: 'home_server_ci:ci-isolated-tests@tcp(127.0.0.1:3306)/home_server_accounts_http_test_ci?parseTime=true' + ACCOUNTS_MIGRATION_TEST_DSN: 'home_server_ci:ci-isolated-tests@tcp(127.0.0.1:3306)/home_server_accounts_migration_test_ci?parseTime=true' + ACCOUNTS_SESSIONS_TEST_DSN: 'home_server_ci:ci-isolated-tests@tcp(127.0.0.1:3306)/home_server_account_sessions_test_ci?parseTime=true' + HOME_SERVER_RUNTIME_TEST_DSN: 'home_server_ci:ci-isolated-tests@tcp(127.0.0.1:3306)/home_server_accounts_runtime_test?parseTime=true' HOME_SERVER_ANALYTICS_TEST_DSN: 'home_server_ci:ci-isolated-tests@tcp(127.0.0.1:3306)/home_server_analytics_test_ci?parseTime=true' HOME_SERVER_READ_CACHE_TEST_DSN: 'home_server_ci:ci-isolated-tests@tcp(127.0.0.1:3306)/home_server_readcache_test_ci?parseTime=true' - run: go test -v ./config ./api/accounts ./api/webprojects ./api/applications ./api/auth ./api/middleware ./api/passport ./api/webdav ./app/... ./domain/model ./domain/repository/... ./domain/service/accounts ./domain/service/webprojects ./domain/service/webcomments ./domain/service/applications ./domain/service/passport ./domain/service/rsa ./internal/accountsmigrate ./cmd/accounts-migrate ./utils ./utils/md ./utils/log ./utils/ginfmt ./utils/cache ./domain/service/webanalytics + run: go test -v ./config ./api/accounts ./api/webprojects ./api/applications ./api/auth ./api/middleware ./api/passport ./api/webdav ./app/... ./domain/model ./domain/repository/... ./domain/service/accounts ./domain/service/webprojects ./domain/service/webcomments ./domain/service/applications ./domain/service/passport ./domain/service/rsa ./internal/accountsmigrate ./cmd/accounts-migrate ./cmd/accounts-maintain ./utils ./utils/md ./utils/log ./utils/ginfmt ./utils/cache ./domain/service/webanalytics - name: Test isolated DAL cache bindings env: diff --git a/README.md b/README.md index b5b49de..d00fe8b 100644 --- a/README.md +++ b/README.md @@ -226,13 +226,14 @@ go build -o output/bin/web-projects-storage-migrate ./cmd/web-projects-storage-m ## 邀请注册、个人中心与管理员 -数据库账号模式支持受邀注册、个人资料与改密、邀请记录、用户管理、网页审核和通用动态配置。迁移流程与回滚边界见 [账号迁移和配置运维](docs/accounts-migration.md),页面与交互见 [前端说明](front_vue/README.md)。 +数据库账号模式支持受邀注册、个人资料与改密、邀请记录、用户管理、网页审核和通用动态配置。迁移流程与回滚边界见 [账号迁移和配置运维](docs/accounts-migration.md),个人中心增量升级与会话描述清理见 [运维说明](docs/account-center-operations.md),页面与交互见 [前端说明](front_vue/README.md)。 | 能力 | 行为 | | --- | --- | | 邀请注册 | 存量用户每个 Asia/Singapore 自然月最多生成3个单次邀请码,固定168小时有效;新用户次月获得资格。关闭注册会永久废弃旧未用码。 | -| 个人中心 | 编辑本人显示名称和联系资料,修改密码必须验证当前密码;密码修改后退出旧会话、停用原应用,联系资料不会自动改变旧登录别名。 | -| 管理员 | 管理用户、角色、初始密码与功能授权;封禁/删除阻断用户会话、应用和WebDAV,恢复不复活旧凭证。不能在网页端操作自己的封禁/删除/撤销管理员。 | +| 个人中心 | 编辑昵称和联系资料、裁剪上传或移除头像;全站优先显示当前昵称与头像。改密验证当前密码后退出旧会话、停用原应用,联系资料不改变登录别名。 | +| 登录管理 | 查看有效会话的登录 IP、UA、客户端、系统、登录时间、到期及剩余时间;可退出选中或其他全部登录,当前登录与应用凭证保持。默认最多同时 5 个有效会话,管理员在账号策略调整上限;满额拒绝新登录。数量不代表物理设备数或在线人数。 | +| 管理员 | 查看用户有效会话数量及详情,达到 10 个提示;可人工封禁或重置昵称、头像。重置需要当前密码、原因与版本校验并记录审计;封禁/删除阻断会话、应用和 WebDAV,恢复不复活旧凭证。不能操作自己的封禁/删除/撤销管理员。 | | 功能授权 | 家庭藏书和WebDAV分别授权,默认关闭;管理员也需显式授权。应用权限是用户能力与scope的交集。藏书继续共用库存,WebDAV保留现有共享根。 | | 网页审核 | 管理员可审核所有可见性和保留版本,预览入口记录审计;下架/删除带审核锁,用户不能自行重新发布,恢复不会自动上线。 | | 动态配置 | 代码定义schema,按分组发布并保留历史;显示已保存与已加载版本,支持校验、冲突保护和回滚;数据库连接、存储根与密钥仍是启动配置。 | diff --git a/api/accounts/account_center_test.go b/api/accounts/account_center_test.go new file mode 100644 index 0000000..b0f382e --- /dev/null +++ b/api/accounts/account_center_test.go @@ -0,0 +1,167 @@ +package accounts_test + +import ( + "bytes" + "image" + "image/jpeg" + "image/png" + "mime/multipart" + "strconv" + "strings" + "testing" +) + +func avatarMultipart(t *testing.T, raw []byte) ([]byte, string) { + t.Helper() + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, err := writer.CreateFormFile("file", "avatar.png") + if err != nil { + t.Fatal(err) + } + if _, err = part.Write(raw); err != nil { + t.Fatal(err) + } + if err = writer.Close(); err != nil { + t.Fatal(err) + } + return body.Bytes(), writer.FormDataContentType() +} + +func TestHTTPAvatarVersionsResetAndExistingAuthentication(t *testing.T) { + f := newHTTPFixture(t) + admin := f.login(f.owner, integrationPassword) + member := f.login(f.member, integrationPassword) + guest := f.login(f.guest, integrationPassword) + var imageBytes bytes.Buffer + if err := png.Encode(&imageBytes, image.NewNRGBA(image.Rect(0, 0, 64, 32))); err != nil { + t.Fatal(err) + } + body, kind := avatarMultipart(t, imageBytes.Bytes()) + url := "/api/account/avatar" + updated := requireSuccess(t, f.request("POST", url, member, body, map[string]string{"Content-Type": kind, "If-Match": strconv.FormatInt(member.Revision, 10)})) + if updated["csrf_token"] != member.CSRF || number(updated["avatar_version"]) != member.Revision+1 { + t.Fatal("upload lost csrf or did not advance the image version") + } + avatarURL := updated["avatar_url"].(string) + response := f.request("GET", avatarURL, guest, nil, nil) + if response.Status != 200 || response.Header.Get("Content-Type") != "image/jpeg" || response.Header.Get("X-Content-Type-Options") != "nosniff" || !strings.Contains(response.Header.Get("Cache-Control"), "no-store") { + t.Fatal("image response missing authorization-safe headers") + } + decoded, err := jpeg.Decode(bytes.NewReader(response.Raw)) + if err != nil || decoded.Bounds().Dx() != 256 { + t.Fatal("image was not normalized") + } + requireDenied(t, f.request("GET", avatarURL, nil, nil, nil)) + requireDenied(t, f.request("POST", url, member, body, map[string]string{"Content-Type": kind, "If-Match": strconv.FormatInt(member.Revision, 10)})) + currentRev := number(updated["revision"]) + nick := requireSuccess(t, f.request("PATCH", "/api/account/profile", member, map[string]string{"display_name": "自定义名称"}, map[string]string{"If-Match": strconv.FormatInt(currentRev, 10)})) + reset := map[string]interface{}{"reset_display_name": true, "reset_avatar": true} + requireSuccess(t, f.adminAction(admin, member.ID, "reset-profile", "POST", reset)) + latest := f.me(member) + if latest["display_name"] != "" || latest["avatar_url"] != "" || number(latest["avatar_version"]) != 0 || number(latest["revision"]) != number(nick["revision"])+1 { + t.Fatal("reset did not atomically restore default identity") + } + requireDenied(t, f.request("GET", avatarURL, guest, nil, nil)) + deleteResult := requireSuccess(t, f.request("DELETE", url, member, nil, map[string]string{"If-Match": strconv.FormatInt(number(latest["revision"]), 10)})) + if number(deleteResult["revision"]) != number(latest["revision"]) { + t.Fatal("removing a missing avatar must be idempotent") + } + audit := requireSuccess(t, f.request("GET", "/api/admin/audit-logs?target_type=user&target_id="+member.ID, admin, nil, nil)) + logs, _ := audit["items"].([]interface{}) + if len(logs) == 0 || object(logs[0])["action"] != "reset-profile" || object(object(logs[0])["before"])["display_name"] != "自定义名称" { + t.Fatal("reset profile audit lacks the actual prior identity") + } +} + +func TestHTTPAvatarAndResetRejectInvalidAndUnavailableAccounts(t *testing.T) { + f := newHTTPFixture(t) + admin := f.login(f.owner, integrationPassword) + member := f.login(f.member, integrationPassword) + for _, raw := range [][]byte{[]byte(""), bytes.Repeat([]byte("x"), (2<<20)+1)} { + body, kind := avatarMultipart(t, raw) + requireDenied(t, f.request("POST", "/api/account/avatar", member, body, map[string]string{"Content-Type": kind, "If-Match": strconv.FormatInt(member.Revision, 10)})) + } + requireDenied(t, f.adminAction(admin, member.ID, "reset-profile", "POST", map[string]interface{}{})) + requireDenied(t, f.request("POST", "/api/admin/users/"+admin.ID+"/reset-profile", member, map[string]interface{}{"reset_display_name": true, "reason": "fixture", "current_password": integrationPassword}, map[string]string{"If-Match": "1"})) + requireSuccess(t, f.adminAction(admin, member.ID, "ban", "POST", map[string]interface{}{})) + requireDenied(t, f.request("GET", "/api/account/sessions", member, nil, nil)) + page := requireSuccess(t, f.request("GET", "/api/admin/users/"+member.ID+"/sessions", admin, nil, nil)) + if number(page["total_count"]) != 0 { + t.Fatal("banned credentials were counted as usable sessions") + } +} + +func TestHTTPSessionsPreserveCurrentAndRejectCrossUserBatchAtomically(t *testing.T) { + f := newHTTPFixture(t) + admin := f.login(f.owner, integrationPassword) + current := f.login(f.member, integrationPassword) + other := f.login(f.member, integrationPassword) + guest := f.login(f.guest, integrationPassword) + page := requireSuccess(t, f.request("GET", "/api/account/sessions", current, nil, nil)) + items, _ := page["items"].([]interface{}) + if number(page["total_count"]) != 2 || len(items) != 1 || page["server_time"] == nil { + t.Fatal("session page/count did not separate the current login") + } + currentID := object(page["current_session"])["id"].(string) + otherID := object(items[0])["id"].(string) + if object(items[0])["login_source"] != "web" || object(items[0])["login_ip"] != strings.Split(f.ip, ":")[0] { + t.Fatal("issuance metadata missing or not the trusted login IP") + } + guestPage := requireSuccess(t, f.request("GET", "/api/account/sessions", guest, nil, nil)) + guestID := object(guestPage["current_session"])["id"].(string) + for _, ids := range [][]string{{currentID, otherID}, {otherID, guestID}} { + requireDenied(t, f.request("POST", "/api/account/sessions/revoke", current, map[string]interface{}{"session_ids": ids}, nil)) + f.me(other) + } + result := requireSuccess(t, f.request("POST", "/api/account/sessions/revoke", current, map[string]interface{}{"session_ids": []string{otherID}}, nil)) + if number(result["revoked_count"]) != 1 { + t.Fatal("chosen session was not revoked") + } + requireDenied(t, f.request("GET", "/api/auth/me", other, nil, nil)) + f.me(current) + f.me(guest) + result = requireSuccess(t, f.request("POST", "/api/account/sessions/revoke", current, map[string]interface{}{"session_ids": []string{otherID}}, nil)) + if number(result["revoked_count"]) != 0 || number(result["already_inactive_count"]) != 1 { + t.Fatal("retrying revocation is not idempotent") + } + third := f.login(f.member, integrationPassword) + requireSuccess(t, f.request("POST", "/api/account/sessions/revoke-others", current, nil, nil)) + requireDenied(t, f.request("GET", "/api/auth/me", third, nil, nil)) + f.me(current) + userPage := requireSuccess(t, f.request("GET", "/api/admin/users", admin, nil, nil)) + rows, _ := userPage["items"].([]interface{}) + for _, row := range rows { + if object(row)["id"] == current.ID && number(object(row)["active_session_count"]) != 1 { + t.Fatal("admin aggregate counts rejected sessions as active") + } + } + requireDenied(t, f.request("GET", "/api/admin/users/"+current.ID+"/sessions", guest, nil, nil)) +} + +func TestHTTPProfileResetAuditFailureRollsBackNameImageAndRevision(t *testing.T) { + f := newHTTPFixture(t) + admin := f.login(f.owner, integrationPassword) + member := f.login(f.member, integrationPassword) + var raw bytes.Buffer + if err := png.Encode(&raw, image.NewNRGBA(image.Rect(0, 0, 8, 8))); err != nil { + t.Fatal(err) + } + body, kind := avatarMultipart(t, raw.Bytes()) + uploaded := requireSuccess(t, f.request("POST", "/api/account/avatar", member, body, map[string]string{"Content-Type": kind, "If-Match": strconv.FormatInt(member.Revision, 10)})) + named := requireSuccess(t, f.request("PATCH", "/api/account/profile", member, map[string]string{"display_name": "保留名字"}, map[string]string{"If-Match": strconv.FormatInt(number(uploaded["revision"]), 10)})) + if _, err := f.database.Exec("DROP TABLE admin_audit_log"); err != nil { + t.Fatal(err) + } + response := f.adminAction(admin, member.ID, "reset-profile", "POST", map[string]interface{}{"reset_display_name": true, "reset_avatar": true}) + if response.Status < 500 { + t.Fatal("missing audit must reject reset") + } + latest := f.me(member) + if latest["display_name"] != "保留名字" || latest["avatar_url"] != uploaded["avatar_url"] || number(latest["revision"]) != number(named["revision"]) { + t.Fatal("failed audit committed profile reset") + } + if f.request("GET", latest["avatar_url"].(string), admin, nil, nil).Status != 200 { + t.Fatal("failed audit removed image bytes") + } +} diff --git a/api/accounts/admin.go b/api/accounts/admin.go index a2ff77d..ec2fa97 100644 --- a/api/accounts/admin.go +++ b/api/accounts/admin.go @@ -7,6 +7,7 @@ import ( "github.com/mcoder2014/home_server/domain/dal" accountservice "github.com/mcoder2014/home_server/domain/service/accounts" apperrors "github.com/mcoder2014/home_server/errors" + "github.com/mcoder2014/home_server/utils" "github.com/mcoder2014/home_server/utils/ginfmt" ) @@ -65,6 +66,9 @@ func changeUser(c *gin.Context, action string) { if !bind(c, &input, 16<<10) { return } + if action == "reset-profile" { + input.ActingToken = c.GetString(utils.CtxKeyLoginToken) + } actor := currentUser(c) result, err := accountservice.AdminChange(ginfmt.RPCContext(c), actor.ID, actor.AuthVersion, id, rev, action, input) if err != nil { diff --git a/api/accounts/avatar_session_guard_test.go b/api/accounts/avatar_session_guard_test.go new file mode 100644 index 0000000..5c0897b --- /dev/null +++ b/api/accounts/avatar_session_guard_test.go @@ -0,0 +1,115 @@ +package accounts_test + +import ( + "bytes" + "encoding/json" + "image" + "image/png" + "net/http" + "strconv" + "testing" + "time" +) + +// beginPausedAccountRequest waits for the handler to read an authenticated +// browser request body, so session revocation deterministically precedes writing. +func (f *httpFixture) beginPausedAccountRequest(path string, session *browserSession, payload []byte, headers map[string]string) (*blockedRequestBody, <-chan apiResponse) { + f.t.Helper() + body := &blockedRequestBody{reader: bytes.NewReader(payload), entered: make(chan struct{}), release: make(chan struct{}), finished: make(chan struct{})} + done := make(chan apiResponse, 1) + go func() { + defer close(body.finished) + done <- f.request(http.MethodPost, path, session, body, headers) + }() + f.t.Cleanup(func() { + body.releaseOnce.Do(func() { close(body.release) }) + select { + case <-body.finished: + case <-time.After(5 * time.Second): + f.t.Error("paused account request did not exit before fixture cleanup") + } + }) + select { + case <-body.entered: + case response := <-done: + f.t.Fatalf("request ended before authenticated body read: status=%d code=%d", response.Status, response.Code) + case <-time.After(5 * time.Second): + f.t.Fatal("account request did not reach authenticated body read") + } + return body, done +} + +func TestHTTPAvatarWriteRechecksRevokedActingSession(t *testing.T) { + f := newHTTPFixture(t) + acting := f.login(f.member, integrationPassword) + surviving := f.login(f.member, integrationPassword) + page := requireSuccess(t, f.request(http.MethodGet, "/api/account/sessions", acting, nil, nil)) + actingID := object(page["current_session"])["id"].(string) + var beforeRevision, beforeAuthVersion int64 + if err := f.database.QueryRow("SELECT revision,auth_version FROM user_account WHERE id=?", acting.ID).Scan(&beforeRevision, &beforeAuthVersion); err != nil { + t.Fatal(err) + } + var raw bytes.Buffer + if err := png.Encode(&raw, image.NewNRGBA(image.Rect(0, 0, 8, 8))); err != nil { + t.Fatal(err) + } + payload, kind := avatarMultipart(t, raw.Bytes()) + blocked, done := f.beginPausedAccountRequest("/api/account/avatar", acting, payload, map[string]string{"Content-Type": kind, "If-Match": strconv.FormatInt(beforeRevision, 10)}) + result := requireSuccess(t, f.request(http.MethodPost, "/api/account/sessions/revoke", surviving, map[string]interface{}{"session_ids": []string{actingID}}, nil)) + if number(result["revoked_count"]) != 1 { + t.Fatal("acting session was not selectively revoked") + } + response := f.finishPausedRequest(blocked, done) + var revision, authVersion, avatarVersion, avatarRows int64 + if err := f.database.QueryRow("SELECT revision,auth_version,avatar_version FROM user_account WHERE id=?", acting.ID).Scan(&revision, &authVersion, &avatarVersion); err != nil { + t.Fatal(err) + } + if err := f.database.QueryRow("SELECT COUNT(*) FROM user_avatar WHERE user_id=?", acting.ID).Scan(&avatarRows); err != nil { + t.Fatal(err) + } + if response.Status != http.StatusUnauthorized || revision != beforeRevision || authVersion != beforeAuthVersion || avatarVersion != 0 || avatarRows != 0 { + t.Fatalf("revoked avatar write must leave the account/image unchanged: status=%d revision=%d/%d auth_version=%d/%d avatar_version=%d rows=%d", response.Status, revision, beforeRevision, authVersion, beforeAuthVersion, avatarVersion, avatarRows) + } + f.me(surviving) +} + +func TestHTTPProfileResetRechecksRevokedActingSession(t *testing.T) { + f := newHTTPFixture(t) + acting := f.login(f.owner, integrationPassword) + surviving := f.login(f.owner, integrationPassword) + member := f.login(f.member, integrationPassword) + page := requireSuccess(t, f.request(http.MethodGet, "/api/account/sessions", acting, nil, nil)) + actingID := object(page["current_session"])["id"].(string) + var raw bytes.Buffer + if err := png.Encode(&raw, image.NewNRGBA(image.Rect(0, 0, 8, 8))); err != nil { + t.Fatal(err) + } + upload, kind := avatarMultipart(t, raw.Bytes()) + imageUser := requireSuccess(t, f.request(http.MethodPost, "/api/account/avatar", member, upload, map[string]string{"Content-Type": kind, "If-Match": strconv.FormatInt(member.Revision, 10)})) + before := requireSuccess(t, f.request(http.MethodPatch, "/api/account/profile", member, map[string]string{"display_name": "必须保留的资料"}, map[string]string{"If-Match": strconv.FormatInt(number(imageUser["revision"]), 10)})) + avatarURL := before["avatar_url"].(string) + beforeImage := f.request(http.MethodGet, avatarURL, surviving, nil, nil) + if beforeImage.Status != http.StatusOK { + t.Fatal("fixture avatar was not readable") + } + payload, err := json.Marshal(map[string]interface{}{"reset_display_name": true, "reset_avatar": true, "reason": "isolated revoked-session verification", "current_password": integrationPassword}) + if err != nil { + t.Fatal(err) + } + blocked, done := f.beginPausedAccountRequest("/api/admin/users/"+member.ID+"/reset-profile", acting, payload, map[string]string{"If-Match": strconv.FormatInt(number(before["revision"]), 10)}) + result := requireSuccess(t, f.request(http.MethodPost, "/api/account/sessions/revoke", surviving, map[string]interface{}{"session_ids": []string{actingID}}, nil)) + if number(result["revoked_count"]) != 1 { + t.Fatal("acting administrator session was not selectively revoked") + } + response := f.finishPausedRequest(blocked, done) + latest := f.me(member) + var audits int64 + if err := f.database.QueryRow("SELECT COUNT(*) FROM admin_audit_log WHERE action=? AND target_id=?", "reset-profile", member.ID).Scan(&audits); err != nil { + t.Fatal(err) + } + imageAfter := f.request(http.MethodGet, avatarURL, surviving, nil, nil) + if response.Status != http.StatusUnauthorized || latest["display_name"] != before["display_name"] || latest["avatar_url"] != avatarURL || number(latest["avatar_version"]) != number(before["avatar_version"]) || number(latest["revision"]) != number(before["revision"]) || audits != 0 || imageAfter.Status != http.StatusOK || !bytes.Equal(imageAfter.Raw, beforeImage.Raw) { + t.Fatalf("revoked profile reset must preserve identity, image, revision and audit: status=%d before=%v after=%v audits=%d image_status=%d", response.Status, before, latest, audits, imageAfter.Status) + } + f.me(surviving) +} diff --git a/api/accounts/avatars.go b/api/accounts/avatars.go new file mode 100644 index 0000000..b3e3cef --- /dev/null +++ b/api/accounts/avatars.go @@ -0,0 +1,74 @@ +package accounts + +import ( + "io" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/mcoder2014/home_server/domain/service/accounts" + apperrors "github.com/mcoder2014/home_server/errors" + "github.com/mcoder2014/home_server/utils" + "github.com/mcoder2014/home_server/utils/ginfmt" +) + +func updateAvatar(c *gin.Context) { + rev, ok := revision(c) + if !ok { + return + } + remove := c.Request.Method == http.MethodDelete + var raw []byte + if !remove { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, accounts.AvatarInputLimit+(64<<10)) + reader, err := c.Request.MultipartReader() + if err != nil { + respond(c, nil, apperrors.ErrInvalid) + return + } + for { + part, e := reader.NextPart() + if e == io.EOF { + break + } + if e != nil || raw != nil || part.FormName() != "file" || part.FileName() == "" { + respond(c, nil, apperrors.ErrInvalid) + return + } + raw, e = io.ReadAll(io.LimitReader(part, accounts.AvatarInputLimit+1)) + part.Close() + if e != nil || len(raw) == 0 || len(raw) > accounts.AvatarInputLimit { + respond(c, nil, apperrors.ErrInvalid) + return + } + } + if raw == nil { + respond(c, nil, apperrors.ErrInvalid) + return + } + } + updated, err := accounts.UpdateAvatar(ginfmt.RPCContext(c), c.GetString(utils.CtxKeyLoginToken), rev, raw, remove) + if err != nil { + respond(c, nil, err) + return + } + respond(c, view(updated, c.GetString(utils.CtxKeyLoginToken)), nil) +} + +func readAvatar(c *gin.Context) { + c.Header("Cache-Control", "private, no-store") + c.Header("X-Content-Type-Options", "nosniff") + id, ok := positiveID(c, "user_id") + if !ok { + return + } + version, ok := positiveID(c, "version") + if !ok { + return + } + raw, err := accounts.ReadAvatar(ginfmt.RPCContext(c), id, version) + if err != nil { + respond(c, nil, err) + return + } + c.Data(http.StatusOK, "image/jpeg", raw) +} diff --git a/api/accounts/comment_identity_test.go b/api/accounts/comment_identity_test.go new file mode 100644 index 0000000..16212ee --- /dev/null +++ b/api/accounts/comment_identity_test.go @@ -0,0 +1,203 @@ +package accounts_test + +import ( + "strconv" + "testing" + + "github.com/mcoder2014/home_server/config" + "github.com/mcoder2014/home_server/domain/service/passport" +) + +func publicCommentIdentityPage(t *testing.T, f *httpFixture, owner *browserSession) (string, string) { + t.Helper() + project, release := f.privatePage(owner) + base := "/api/web-share/" + project["id"].(string) + requireSuccess(t, f.request("PATCH", base, owner, map[string]interface{}{"access_mode": "public"}, map[string]string{"If-Match": strconv.FormatInt(number(project["revision"]), 10)})) + return base + "/comment-threads", release +} + +func requireCommentIdentity(t *testing.T, value map[string]interface{}, prefix, id, name, snapshot string) { + t.Helper() + identity := object(value[prefix+"_user"]) + current := identity["display_name"] + if current == "" { + current = identity["user_name"] + } + if identity["user_id"] != id || current != name || value[prefix+"_name_snapshot"] != snapshot { + t.Fatalf("comment %s must expose current identity %s/%s and display snapshot %s: %v", prefix, id, name, snapshot, value) + } + if name == "已注销用户" && identity["avatar_url"] != "" { + t.Fatal("deleted comment identity exposed an avatar") + } +} + +func requireStoredCommentIdentity(t *testing.T, f *httpFixture, threadID, name string) { + t.Helper() + var threadName, eventName string + if err := f.database.QueryRow("SELECT author_name_snapshot FROM web_comment_thread WHERE id=?", threadID).Scan(&threadName); err != nil { + t.Fatal(err) + } + if err := f.database.QueryRow("SELECT actor_name_snapshot FROM web_comment_event WHERE thread_id=? AND sequence=1", threadID).Scan(&eventName); err != nil { + t.Fatal(err) + } + if threadName != name || eventName != name { + t.Fatalf("current identity projection rewrote historical evidence: thread=%q event=%q", threadName, eventName) + } +} + +func TestHTTPCommentIdentityResetUsesCurrentNamesInEveryResponse(t *testing.T) { + f := newHTTPFixture(t) + owner := f.login(f.owner, integrationPassword) + author := f.login(f.member, integrationPassword) + reader := f.login(f.guest, integrationPassword) + base, release := publicCommentIdentityPage(t, f, owner) + oldName := "需重置的历史昵称" + requireSuccess(t, f.request("PATCH", "/api/account/profile", author, map[string]string{"display_name": oldName}, map[string]string{"If-Match": strconv.FormatInt(author.Revision, 10)})) + input := map[string]interface{}{"request_id": "identity-before-reset", "release_id": release, "page_key": "path:index.html", "page_path": "index.html", "anchor": map[string]interface{}{"kind": "page"}, "body": "Synthetic identity comment"} + created := requireSuccess(t, f.request("POST", base, author, input, nil)) + id := created["id"].(string) + path := base + "/" + id + requireSuccess(t, f.adminAction(owner, author.ID, "reset-profile", "POST", map[string]interface{}{"reset_display_name": true})) + for _, endpoint := range []string{base, path} { + t.Run(endpoint, func(t *testing.T) { + value := requireSuccess(t, f.request("GET", endpoint, reader, nil, nil)) + if endpoint == base { + value = object(value["items"].([]interface{})[0]) + } + requireCommentIdentity(t, value, "author", author.ID, f.member, f.member) + }) + } + t.Run("events", func(t *testing.T) { + value := requireSuccess(t, f.request("GET", path+"/events", reader, nil, nil)) + requireCommentIdentity(t, object(value["items"].([]interface{})[0]), "actor", author.ID, f.member, f.member) + }) + t.Run("idempotent comment", func(t *testing.T) { + value := requireSuccess(t, f.request("POST", base, author, input, nil)) + requireCommentIdentity(t, value, "author", author.ID, f.member, f.member) + if value["id"] != id { + t.Fatal("identity refresh duplicated the comment") + } + }) + for i := 0; i < 2; i++ { + t.Run("reply "+strconv.Itoa(i), func(t *testing.T) { + value := requireSuccess(t, f.request("POST", path+"/replies", reader, map[string]interface{}{"request_id": "identity-after-reset", "body": "Synthetic reply"}, nil)) + requireCommentIdentity(t, value, "author", author.ID, f.member, f.member) + }) + } + requireStoredCommentIdentity(t, f, id, oldName) +} + +func TestHTTPCommentIdentityDeletedAuthorHidesNamesButKeepsEvidence(t *testing.T) { + f := newHTTPFixture(t) + owner := f.login(f.owner, integrationPassword) + author := f.login(f.member, integrationPassword) + reader := f.login(f.guest, integrationPassword) + base, release := publicCommentIdentityPage(t, f, owner) + oldName := "已删除账号的历史昵称" + requireSuccess(t, f.request("PATCH", "/api/account/profile", author, map[string]string{"display_name": oldName}, map[string]string{"If-Match": strconv.FormatInt(author.Revision, 10)})) + created := requireSuccess(t, f.request("POST", base, author, map[string]interface{}{"request_id": "identity-before-delete", "release_id": release, "page_key": "path:index.html", "page_path": "index.html", "anchor": map[string]interface{}{"kind": "page"}, "body": "Synthetic identity comment"}, nil)) + id := created["id"].(string) + path := base + "/" + id + reply := map[string]interface{}{"request_id": "identity-reply-before-delete", "body": "Synthetic reply"} + requireSuccess(t, f.request("POST", path+"/replies", reader, reply, nil)) + requireSuccess(t, f.adminAction(owner, author.ID, "delete", "POST", map[string]interface{}{})) + for _, endpoint := range []string{base, path} { + t.Run(endpoint, func(t *testing.T) { + value := requireSuccess(t, f.request("GET", endpoint, reader, nil, nil)) + if endpoint == base { + value = object(value["items"].([]interface{})[0]) + } + requireCommentIdentity(t, value, "author", author.ID, "已注销用户", "已注销用户") + }) + } + t.Run("events", func(t *testing.T) { + value := requireSuccess(t, f.request("GET", path+"/events", reader, nil, nil)) + requireCommentIdentity(t, object(value["items"].([]interface{})[0]), "actor", author.ID, "已注销用户", "已注销用户") + }) + t.Run("idempotent reply", func(t *testing.T) { + value := requireSuccess(t, f.request("POST", path+"/replies", reader, reply, nil)) + requireCommentIdentity(t, value, "author", author.ID, "已注销用户", "已注销用户") + }) + t.Run("new reply", func(t *testing.T) { + value := requireSuccess(t, f.request("POST", path+"/replies", reader, map[string]interface{}{"request_id": "identity-reply-after-delete", "body": "New synthetic reply"}, nil)) + requireCommentIdentity(t, value, "author", author.ID, "已注销用户", "已注销用户") + }) + requireStoredCommentIdentity(t, f, id, oldName) +} + +func TestHTTPCommentIdentityApplicationsKeepTheirOwnNamesAfterReset(t *testing.T) { + f := newHTTPFixture(t) + owner := f.login(f.owner, integrationPassword) + author := f.login(f.member, integrationPassword) + reader := f.login(f.guest, integrationPassword) + base, release := publicCommentIdentityPage(t, f, owner) + token := f.applicationToken(author, []string{"web-comments:write"}) + headers := map[string]string{"Authorization": "Bearer " + token} + input := map[string]interface{}{"request_id": "application-identity-comment", "release_id": release, "page_key": "path:index.html", "page_path": "index.html", "anchor": map[string]interface{}{"kind": "page"}, "body": "Synthetic application comment"} + created := requireSuccess(t, f.request("POST", base, nil, input, headers)) + id := created["id"].(string) + appName := "HTTP fixture application" + requireSuccess(t, f.adminAction(owner, author.ID, "reset-profile", "POST", map[string]interface{}{"reset_display_name": true})) + for _, endpoint := range []string{base, base + "/" + id} { + value := requireSuccess(t, f.request("GET", endpoint, reader, nil, nil)) + if endpoint == base { + value = object(value["items"].([]interface{})[0]) + } + requireCommentIdentity(t, value, "author", author.ID, f.member, appName) + if value["author_application_id"] == "0" { + t.Fatal("application author lost its application marker") + } + } + events := requireSuccess(t, f.request("GET", base+"/"+id+"/events", reader, nil, nil)) + requireCommentIdentity(t, object(events["items"].([]interface{})[0]), "actor", author.ID, f.member, appName) + idempotent := requireSuccess(t, f.request("POST", base, nil, input, headers)) + requireCommentIdentity(t, idempotent, "author", author.ID, f.member, appName) + requireStoredCommentIdentity(t, f, id, appName) +} + +func TestHTTPCommentIdentityLegacyConfigurationUsesRealUserName(t *testing.T) { + f := newHTTPFixture(t) + databaseOwner := f.login(f.owner, integrationPassword) + base, release := publicCommentIdentityPage(t, f, databaseOwner) + before := config.Global() + legacy := before + legacy.IdentitySource = "file" + config.SetGlobalConfig(legacy) + if err := passport.Init(&legacy); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { config.SetGlobalConfig(before) }) + author := f.login(f.owner, integrationPassword) + reader := f.login(f.guest, integrationPassword) + input := map[string]interface{}{"request_id": "legacy-identity-comment", "release_id": release, "page_key": "path:index.html", "page_path": "index.html", "anchor": map[string]interface{}{"kind": "page"}, "body": "Synthetic legacy comment"} + created := requireSuccess(t, f.request("POST", base, author, input, nil)) + id := created["id"].(string) + t.Run("create", func(t *testing.T) { + requireCommentIdentity(t, created, "author", author.ID, f.owner, f.owner) + }) + for _, endpoint := range []string{base, base + "/" + id} { + t.Run(endpoint, func(t *testing.T) { + value := requireSuccess(t, f.request("GET", endpoint, reader, nil, nil)) + if endpoint == base { + value = object(value["items"].([]interface{})[0]) + } + requireCommentIdentity(t, value, "author", author.ID, f.owner, f.owner) + if object(value["author_user"])["avatar_url"] != "" { + t.Fatal("config identity unexpectedly exposed a database avatar") + } + }) + } + t.Run("events", func(t *testing.T) { + value := requireSuccess(t, f.request("GET", base+"/"+id+"/events", reader, nil, nil)) + requireCommentIdentity(t, object(value["items"].([]interface{})[0]), "actor", author.ID, f.owner, f.owner) + }) + t.Run("idempotent comment", func(t *testing.T) { + value := requireSuccess(t, f.request("POST", base, author, input, nil)) + requireCommentIdentity(t, value, "author", author.ID, f.owner, f.owner) + }) + t.Run("new reply", func(t *testing.T) { + value := requireSuccess(t, f.request("POST", base+"/"+id+"/replies", reader, map[string]interface{}{"request_id": "legacy-identity-reply", "body": "Synthetic legacy reply"}, nil)) + requireCommentIdentity(t, value, "author", author.ID, f.owner, f.owner) + }) + requireStoredCommentIdentity(t, f, id, "用户 "+author.ID) +} diff --git a/api/accounts/handlers.go b/api/accounts/handlers.go index adc5de8..d5661e4 100644 --- a/api/accounts/handlers.go +++ b/api/accounts/handlers.go @@ -108,11 +108,12 @@ type accountView struct { Capabilities map[string]bool `json:"capabilities"` PasswordPolicy map[string]int `json:"password_policy"` ApplicationPolicy map[string]int `json:"application_policy"` + SessionPolicy map[string]int `json:"session_policy"` } func view(user *model.UserAccount, token string) accountView { runtime := config.Runtime() - return accountView{UserAccount: user, CSRFToken: middleware.CSRFToken(token), Capabilities: map[string]bool{"library": user.LibraryEnabled && runtime.LibraryEnabled, "webdav": user.WebDAVPermission != model.WebDAVNone && runtime.WebDAVEnabled, "applications": runtime.Auth.ApplicationsEnabled, "web_projects": runtime.WebProjects.Enabled}, PasswordPolicy: map[string]int{"min_length": runtime.AccountPolicy.MinPasswordLength}, ApplicationPolicy: map[string]int{"default_credential_ttl_days": runtime.Auth.DefaultCredentialTTLDays, "max_credential_ttl_days": runtime.Auth.MaxCredentialTTLDays, "max_applications_per_user": runtime.Auth.MaxApplicationsPerUser}} + return accountView{UserAccount: user, CSRFToken: middleware.CSRFToken(token), Capabilities: map[string]bool{"library": user.LibraryEnabled && runtime.LibraryEnabled, "webdav": user.WebDAVPermission != model.WebDAVNone && runtime.WebDAVEnabled, "applications": runtime.Auth.ApplicationsEnabled, "web_projects": runtime.WebProjects.Enabled}, PasswordPolicy: map[string]int{"min_length": runtime.AccountPolicy.MinPasswordLength}, ApplicationPolicy: map[string]int{"default_credential_ttl_days": runtime.Auth.DefaultCredentialTTLDays, "max_credential_ttl_days": runtime.Auth.MaxCredentialTTLDays, "max_applications_per_user": runtime.Auth.MaxApplicationsPerUser}, SessionPolicy: map[string]int{"max_active_sessions": runtime.AccountPolicy.MaxActiveSessions}} } // login 处理 POST /api/auth/login:验证用户名和密码,建立 HttpOnly 浏览器会话并返回本人资料与 CSRF 信息。 @@ -128,7 +129,8 @@ func login(c *gin.Context) { c.Set(accountservice.PasswordSourceIPKey, middleware.TrustedClientIP(c.Request)) ctx := ginfmt.RPCContext(c) if accountservice.DatabaseMode() { - user, token, session, err := accountservice.Login(ctx, input.UserName, input.Password) + metadata := accountservice.NewSessionMetadata(middleware.TrustedClientIP(c.Request), c.Request.UserAgent(), "web") + user, token, session, err := accountservice.Login(ctx, input.UserName, input.Password, metadata) if err != nil { respond(c, nil, err) return diff --git a/api/accounts/integration_test.go b/api/accounts/integration_test.go index dd7e25b..decf2c2 100644 --- a/api/accounts/integration_test.go +++ b/api/accounts/integration_test.go @@ -212,6 +212,17 @@ func newHTTPFixture(t *testing.T, initialApplications ...bool) *httpFixture { if err := accountsmigrate.Apply(context.Background(), sqlDB, source, opts, plan.SHA256, parsed.DBName); err != nil { t.Fatal(err) } + centerOptions, err := accountsmigrate.AccountCenterOptions(parsed.DBName) + if err != nil { + t.Fatal(err) + } + centerPlan, err := accountsmigrate.BuildAdditivePlan(context.Background(), sqlDB, centerOptions) + if err != nil { + t.Fatal(err) + } + if err := accountsmigrate.ApplyAdditive(context.Background(), sqlDB, centerOptions, centerPlan.SHA256, parsed.DBName, true); err != nil { + t.Fatal(err) + } config.SetGlobalConfig(fixture.conf) if err := db.InitDatabase(parsed.FormatDSN()); err != nil { t.Fatal(err) @@ -231,6 +242,12 @@ func newHTTPFixture(t *testing.T, initialApplications ...bool) *httpFixture { if err := runtimeService.Initialize(context.Background()); err != nil { t.Fatal(err) } + // General HTTP regressions opt into a larger synthetic-only session limit. + snapshot := config.Runtime() + snapshot.AccountPolicy.MaxActiveSessions = 100 + if err := config.StoreRuntimeSnapshot(snapshot); err != nil { + t.Fatal(err) + } accounts.Configure(runtimeService) gin.SetMode(gin.TestMode) logrus.SetOutput(io.Discard) diff --git a/api/accounts/live_fixture_test.go b/api/accounts/live_fixture_test.go new file mode 100644 index 0000000..6ddc4ae --- /dev/null +++ b/api/accounts/live_fixture_test.go @@ -0,0 +1,51 @@ +package accounts_test + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +// TestExportAccountCenterLiveFixture explicitly exports only synthetic identities +// and a protected configuration for an isolated loopback HTTP/browser smoke test. +func TestExportAccountCenterLiveFixture(t *testing.T) { + dir := os.Getenv("ACCOUNTS_LIVE_FIXTURE_DIR") + if dir == "" { + t.Skip("opt-in synthetic live fixture") + } + dir = filepath.Clean(dir) + if !strings.HasPrefix(dir, "/tmp/home-server-account-center-") { + t.Fatal("unsafe live fixture path") + } + f := newHTTPFixture(t) + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + conf := f.conf + conf.Auth.SiteOrigin = "https://localhost:18443" + conf.Auth.TrustedProxyCIDRs = []string{"127.0.0.1/32"} + conf.WebProjects.SiteOrigin = conf.Auth.SiteOrigin + conf.WebProjects.StorageRoot = filepath.Join(dir, "web") + conf.WebDAV.SharePath = filepath.Join(dir, "dav") + conf.Passport.RedirectLoginPath = conf.Auth.SiteOrigin + "/login" + for _, path := range []string{conf.WebProjects.StorageRoot, conf.WebDAV.SharePath} { + if err := os.MkdirAll(path, 0700); err != nil { + t.Fatal(err) + } + } + raw, err := yaml.Marshal(conf) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "server.yaml"), raw, 0600); err != nil { + t.Fatal(err) + } + identities, _ := json.Marshal(map[string]string{"admin": f.owner, "member": f.member, "guest": f.guest, "password": integrationPassword}) + if err := os.WriteFile(filepath.Join(dir, "identities.json"), identities, 0600); err != nil { + t.Fatal(err) + } +} diff --git a/api/accounts/routes.go b/api/accounts/routes.go index 64d702e..5937498 100644 --- a/api/accounts/routes.go +++ b/api/accounts/routes.go @@ -49,14 +49,21 @@ func InitAuthRouter() error { data.AddRoute(http.MethodPost, "/api/auth/logout-all", user, write, requireDatabase, logoutAll) data.AddRoute(http.MethodPost, "/api/auth/change-password", limited, write, requireDatabase, changePassword) data.AddRoute(http.MethodPatch, "/api/account/profile", user, write, requireDatabase, updateProfile) + data.AddRoute(http.MethodPost, "/api/account/avatar", user, write, requireDatabase, updateAvatar) + data.AddRoute(http.MethodDelete, "/api/account/avatar", user, write, requireDatabase, updateAvatar) + data.AddRoute(http.MethodGet, "/api/account/avatars/:user_id/:version", user, requireDatabase, readAvatar) + data.AddRoute(http.MethodGet, "/api/account/sessions", user, requireDatabase, listSessions) + data.AddRoute(http.MethodPost, "/api/account/sessions/revoke", user, write, requireDatabase, revokeSessions) + data.AddRoute(http.MethodPost, "/api/account/sessions/revoke-others", user, write, requireDatabase, revokeOtherSessions) data.AddRoute(http.MethodGet, "/api/account/invitations", user, requireDatabase, listInvitations) data.AddRoute(http.MethodPost, "/api/account/invitations", user, write, requireDatabase, createInvitation) data.AddRoute(http.MethodPost, "/api/account/invitations/:id/revoke", user, write, requireDatabase, revokeInvitation) data.AddRoute(http.MethodGet, "/api/admin/users", admin, listUsers) data.AddRoute(http.MethodPost, "/api/admin/users", admin, write, createUser) data.AddRoute(http.MethodGet, "/api/admin/users/:id", admin, getUser) + data.AddRoute(http.MethodGet, "/api/admin/users/:id/sessions", admin, requireDatabase, adminUserSessions) // POST 动作依次提供封禁、恢复、删除、重置密码和踢出网站会话,均交由 changeUser 执行权限及版本校验。 - for _, action := range []string{"ban", "unban", "delete", "reset-password", "logout-all"} { + for _, action := range []string{"ban", "unban", "delete", "reset-password", "logout-all", "reset-profile"} { current := action data.AddRoute(http.MethodPost, "/api/admin/users/:id/"+current, admin, write, func(c *gin.Context) { changeUser(c, current) }) } diff --git a/api/accounts/session_limit_test.go b/api/accounts/session_limit_test.go new file mode 100644 index 0000000..3b9983c --- /dev/null +++ b/api/accounts/session_limit_test.go @@ -0,0 +1,178 @@ +package accounts_test + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "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 +// larger limit explicitly used by unrelated pagination/authentication fixtures. +func newSessionLimitHTTPFixture(t *testing.T) *httpFixture { + t.Helper() + f := newHTTPFixture(t) + defaults, err := config.BuildRuntimeSnapshot(f.conf, 0, nil, config.DefaultRuntimeValues(f.conf)) + if err != nil || defaults.AccountPolicy.MaxActiveSessions != 5 { + t.Fatalf("expected the production default of five sessions: policy=%v error=%v", defaults.AccountPolicy, err) + } + snapshot := config.Runtime() + snapshot.AccountPolicy.MaxActiveSessions = defaults.AccountPolicy.MaxActiveSessions + if err := config.StoreRuntimeSnapshot(snapshot); err != nil { + t.Fatal(err) + } + 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) { + 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) + for _, login := range logins { + f.me(login) + } + page := requireSuccess(t, f.request(http.MethodGet, "/api/account/sessions", logins[0], nil, nil)) + if number(page["total_count"]) != 5 || number(page["max_active_sessions"]) != 5 { + t.Fatal("session page did not expose the applied default limit") + } + admin := f.login(f.owner, integrationPassword) + requireSuccess(t, f.adminAction(admin, logins[0].ID, "webdav-permission", http.MethodPut, map[string]interface{}{"permission": "read"})) + token := f.applicationToken(logins[0], []string{"web-projects:read", "webdav:read"}) + bearer := map[string]string{"Authorization": "Bearer " + token} + basic := map[string]string{"Authorization": "Basic " + base64.StdEncoding.EncodeToString([]byte(f.member+":"+integrationPassword))} + for i := 0; i < 3; i++ { + requireSuccess(t, f.request(http.MethodGet, "/api/web-share", nil, nil, bearer)) + for _, path := range []string{"/webdav/fixture.txt", "/webdav_dev/fixture.txt"} { + response := f.request(http.MethodGet, path, nil, nil, basic) + if response.Status != http.StatusOK || string(response.Raw) != "private fixture file" { + t.Fatalf("session limit blocked authorized Basic access: status=%d", response.Status) + } + } + } + page = requireSuccess(t, f.request(http.MethodGet, "/api/account/sessions", logins[0], nil, nil)) + if number(page["total_count"]) != 5 { + t.Fatal("application/Basic access consumed browser session slots") + } + result := requireSuccess(t, f.request(http.MethodPost, "/api/account/sessions/revoke-others", logins[0], nil, nil)) + if number(result["revoked_count"]) != 4 { + t.Fatal("user could not release occupied session slots") + } + f.login(f.member, integrationPassword) + requireSuccess(t, f.request(http.MethodGet, "/api/web-share", nil, nil, bearer)) +} + +func TestHTTPSessionLimitLegacyCannotBypassAndCookieExchangeDoesNotConsumeSlots(t *testing.T) { + f := newSessionLimitHTTPFixture(t) + f.router.POST("/passport/login", passportapi.Login) + public, _, err := passport.GetLoginRsa(context.Background()) + if err != nil { + t.Fatal(err) + } + block, _ := pem.Decode(public) + if block == nil { + t.Fatal("legacy login public key was missing") + } + key, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + t.Fatal(err) + } + cipher, err := rsa.EncryptPKCS1v15(rand.Reader, key.(*rsa.PublicKey), []byte(integrationPassword)) + if err != nil { + t.Fatal(err) + } + payload := map[string]string{"user_name": f.member, "crypt_passwd": base64.StdEncoding.EncodeToString(cipher)} + legacy := requireSuccess(t, f.request(http.MethodPost, "/passport/login", nil, payload, nil)) + token := legacy["token"].(string) + 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) + 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"} { + response := f.request(http.MethodPost, path, nil, nil, map[string]string{"passport": token}) + requireSuccess(t, response) + var cookie *http.Cookie + for _, candidate := range response.Cookies { + if candidate.MaxAge > 0 { + cookie = candidate + } + } + if cookie == nil { + t.Fatal("cookie exchange failed at the session limit") + } + browser := &browserSession{Cookie: cookie} + page = requireSuccess(t, f.request(http.MethodGet, "/api/account/sessions", browser, nil, nil)) + if number(page["total_count"]) != 5 || object(page["current_session"])["id"] != currentID { + t.Fatal("cookie exchange created another session at the limit") + } + } +} + +func TestHTTPSessionLimitAdministratorChangesApplyToNextLoginWithoutRevokingOld(t *testing.T) { + f := newSessionLimitHTTPFixture(t) + admin := f.login(f.owner, integrationPassword) + logins := []*browserSession{} + for i := 0; i < 5; i++ { + logins = append(logins, f.login(f.member, integrationPassword)) + } + policy := requireSuccess(t, f.request(http.MethodGet, "/api/admin/config/account_policy", admin, nil, nil)) + values := object(policy["values"]) + values["max_active_sessions"] = 6 + requireSuccess(t, f.publish(admin, "account_policy", values, "session-limit-raise-six")) + logins = append(logins, f.login(f.member, integrationPassword)) + policy = requireSuccess(t, f.request(http.MethodGet, "/api/admin/config/account_policy", admin, nil, nil)) + values = object(policy["values"]) + values["max_active_sessions"] = 2 + requireSuccess(t, f.publish(admin, "account_policy", values, "session-limit-lower-two")) + 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 + }{{"/api/account/sessions", logins[0]}, {"/api/admin/users/" + logins[0].ID + "/sessions", admin}} { + page := requireSuccess(t, f.request(http.MethodGet, request.path, request.session, nil, nil)) + if number(page["total_count"]) != 6 || number(page["max_active_sessions"]) != 2 { + 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 { + 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) +} diff --git a/api/accounts/session_policy_test.go b/api/accounts/session_policy_test.go new file mode 100644 index 0000000..4535260 --- /dev/null +++ b/api/accounts/session_policy_test.go @@ -0,0 +1,124 @@ +package accounts_test + +import ( + "bytes" + "encoding/json" + "strconv" + "testing" +) + +func TestHTTPWebsiteSessionPolicyAppliesToViewsAndLegacyRollback(t *testing.T) { + f := newSessionLimitHTTPFixture(t) + admin := f.login(f.owner, integrationPassword) + member := f.login(f.member, integrationPassword) + current := requireSuccess(t, f.request("GET", "/api/admin/config/account_policy", admin, nil, nil)) + values := object(current["values"]) + if number(values["max_active_sessions"]) != 5 { + t.Fatal("a migrated v1 policy did not expose the website session default") + } + for _, session := range []*browserSession{admin, member} { + if number(object(f.me(session)["session_policy"])["max_active_sessions"]) != 5 { + t.Fatal("account view omitted the applied website session policy") + } + } + schema := requireSuccess(t, f.request("GET", "/api/admin/config/schema", admin, nil, nil)) + found := false + for _, namespace := range schema["namespaces"].([]interface{}) { + group := object(namespace) + if group["namespace"] != "account_policy" { + continue + } + for _, item := range group["fields"].([]interface{}) { + field := object(item) + if field["key"] == "max_active_sessions" { + found = field["type"] == "integer" && number(field["minimum"]) == 1 && number(field["maximum"]) == 100 && number(field["default_value"]) == 5 && field["effect"] == "new_operation" + } + } + } + if !found { + t.Fatal("admin configuration schema omitted the bounded website session policy") + } + values["max_active_sessions"] = 3 + requireSuccess(t, f.publish(admin, "account_policy", values, "website-session-policy-three")) + if number(object(f.me(member)["session_policy"])["max_active_sessions"]) != 3 { + t.Fatal("account view kept an outdated website session policy") + } + for _, path := range []string{"/api/account/sessions", "/api/admin/users/" + member.ID + "/sessions"} { + page := requireSuccess(t, f.request("GET", path, admin, nil, nil)) + if number(page["max_active_sessions"]) != 3 { + t.Fatal("session page omitted the current website session limit") + } + } + var legacyJSON string + var legacySHA []byte + if err := f.database.QueryRow("SELECT values_json,values_sha256 FROM site_config_history WHERE namespace='account_policy' AND revision=1").Scan(&legacyJSON, &legacySHA); err != nil { + t.Fatal(err) + } + var legacy map[string]interface{} + if err := json.Unmarshal([]byte(legacyJSON), &legacy); err != nil || len(legacy) != 4 || legacy["max_active_sessions"] != nil { + t.Fatalf("the imported v1 policy must retain its original four fields: %v %v", legacy, err) + } + current = requireSuccess(t, f.request("GET", "/api/admin/config/account_policy", admin, nil, nil)) + rolledBack := requireSuccess(t, f.request("POST", "/api/admin/config/account_policy/rollback", admin, map[string]interface{}{"target_revision": 1, "request_id": "website-session-policy-legacy-rollback", "reason": "isolated legacy rollback", "current_password": integrationPassword}, map[string]string{"If-Match": strconv.FormatInt(number(current["revision"]), 10)})) + if number(object(rolledBack["values"])["max_active_sessions"]) != 5 || number(object(f.me(member)["session_policy"])["max_active_sessions"]) != 5 { + t.Fatal("legacy rollback did not publish and apply the default website session limit") + } + var storedJSON string + var storedSHA []byte + if err := f.database.QueryRow("SELECT values_json,values_sha256 FROM site_config_history WHERE namespace='account_policy' AND revision=1").Scan(&storedJSON, &storedSHA); err != nil { + t.Fatal(err) + } + if storedJSON != legacyJSON || !bytes.Equal(storedSHA, legacySHA) { + t.Fatal("legacy rollback rewrote the imported historical document") + } +} + +func TestHTTPWebsiteSessionPolicyRejectsIncompleteAndInvalidPublications(t *testing.T) { + f := newHTTPFixture(t) + admin := f.login(f.owner, integrationPassword) + member := f.login(f.member, integrationPassword) + current := requireSuccess(t, f.request("GET", "/api/admin/config/account_policy", admin, nil, nil)) + values := object(current["values"]) + values["max_active_sessions"] = 3 + requireSuccess(t, f.publish(admin, "account_policy", values, "website-session-validation-three")) + current = requireSuccess(t, f.request("GET", "/api/admin/config/account_policy", admin, nil, nil)) + revision := number(current["revision"]) + for _, limit := range []int{1, 100} { + values["max_active_sessions"] = limit + requireSuccess(t, f.request("POST", "/api/admin/config/account_policy/validate", admin, map[string]interface{}{"values": values}, nil)) + } + for index, test := range []struct { + name string + value interface{} + missing bool + }{ + {"missing", nil, true}, + {"null", nil, false}, + {"zero", 0, false}, + {"over maximum", 101, false}, + {"fraction", 1.5, false}, + {"string", "5", false}, + {"boolean", true, false}, + } { + t.Run(test.name, func(t *testing.T) { + candidate := make(map[string]interface{}, len(values)) + for key, value := range values { + candidate[key] = value + } + if test.missing { + delete(candidate, "max_active_sessions") + } else { + candidate["max_active_sessions"] = test.value + } + requireDenied(t, f.request("POST", "/api/admin/config/account_policy/validate", admin, map[string]interface{}{"values": candidate}, nil)) + requireDenied(t, f.publish(admin, "account_policy", candidate, "website-session-invalid-"+strconv.Itoa(index))) + }) + } + values["max_active_sessions"] = 3 + requireDenied(t, f.request("GET", "/api/admin/config/schema", member, nil, nil)) + requireDenied(t, f.request("PUT", "/api/admin/config/account_policy", member, map[string]interface{}{"values": values, "request_id": "website-session-member-denied", "reason": "isolated unauthorized policy update", "current_password": integrationPassword}, map[string]string{"If-Match": strconv.FormatInt(revision, 10)})) + current = requireSuccess(t, f.request("GET", "/api/admin/config/account_policy", admin, nil, nil)) + if number(current["revision"]) != revision || number(object(current["values"])["max_active_sessions"]) != 3 { + t.Fatal("invalid or unauthorized publication changed the active policy") + } +} diff --git a/api/accounts/sessions.go b/api/accounts/sessions.go new file mode 100644 index 0000000..a88586b --- /dev/null +++ b/api/accounts/sessions.go @@ -0,0 +1,71 @@ +package accounts + +import ( + "strconv" + + "github.com/gin-gonic/gin" + accountservice "github.com/mcoder2014/home_server/domain/service/accounts" + apperrors "github.com/mcoder2014/home_server/errors" + "github.com/mcoder2014/home_server/utils" + "github.com/mcoder2014/home_server/utils/ginfmt" +) + +func sessionPagination(c *gin.Context) (accountservice.SessionFilter, bool) { + filter := accountservice.SessionFilter{Cursor: c.Query("cursor"), Limit: 20} + if raw := c.Query("limit"); raw != "" { + limit, err := strconv.Atoi(raw) + if err != nil || limit < 1 || limit > 100 { + respond(c, nil, apperrors.ErrInvalid) + return filter, false + } + filter.Limit = limit + } + return filter, true +} + +// listSessions reads effective personal logins; the authenticated token, rather +// than a client-supplied ID, determines the separately displayed current login. +func listSessions(c *gin.Context) { + filter, ok := sessionPagination(c) + if !ok { + return + } + page, err := accountservice.ListSessions(ginfmt.RPCContext(c), c.GetString(utils.CtxKeyLoginToken), filter) + respond(c, page, err) +} + +// revokeSessions rejects the whole selection if any target is not visible. +// BrowserWrite and RequireAccount run before this handler and the transaction +// rechecks the same acting token before changing owned session rows. +func revokeSessions(c *gin.Context) { + var input struct { + SessionIDs []string `json:"session_ids"` + } + if !bind(c, &input, 16<<10) { + return + } + result, err := accountservice.RevokeSessions(ginfmt.RPCContext(c), c.GetString(utils.CtxKeyLoginToken), input.SessionIDs) + respond(c, result, err) +} + +// revokeOtherSessions includes effective sessions beyond the loaded list page, +// preserving the acting login, account auth version and application credentials. +func revokeOtherSessions(c *gin.Context) { + result, err := accountservice.RevokeOtherSessions(ginfmt.RPCContext(c), c.GetString(utils.CtxKeyLoginToken)) + respond(c, result, err) +} + +// adminUserSessions is read-only. Current administrator role and acting session +// are revalidated inside the same snapshot as the target's list and counts. +func adminUserSessions(c *gin.Context) { + id, ok := positiveID(c, "id") + if !ok { + return + } + filter, ok := sessionPagination(c) + if !ok { + return + } + page, err := accountservice.AdminUserSessions(ginfmt.RPCContext(c), c.GetString(utils.CtxKeyLoginToken), id, filter) + respond(c, page, err) +} diff --git a/api/accounts/sessions_test.go b/api/accounts/sessions_test.go new file mode 100644 index 0000000..944daba --- /dev/null +++ b/api/accounts/sessions_test.go @@ -0,0 +1,222 @@ +package accounts_test + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "net/http" + "testing" + "time" + + passportapi "github.com/mcoder2014/home_server/api/passport" + passportservice "github.com/mcoder2014/home_server/domain/service/passport" +) + +// TestHTTPSessionAccessUsesBrowserGuards exercises browser policy separately +// from the selected-session ownership and idempotency transaction tests. +func TestHTTPSessionAccessUsesBrowserGuards(t *testing.T) { + f := newHTTPFixture(t) + admin := f.login(f.owner, integrationPassword) + current := f.login(f.member, integrationPassword) + other := f.login(f.member, integrationPassword) + otherPage := requireSuccess(t, f.request(http.MethodGet, "/api/account/sessions", other, nil, nil)) + otherID, _ := object(otherPage["current_session"])["id"].(string) + missingCSRF := f.request(http.MethodPost, "/api/account/sessions/revoke", current, map[string]interface{}{"session_ids": []string{otherID}}, map[string]string{"X-CSRF-Token": ""}) + if missingCSRF.Status != http.StatusForbidden { + t.Fatal("selected logout bypassed BrowserWrite") + } + requireDenied(t, f.request(http.MethodGet, "/api/account/sessions", current, nil, map[string]string{"Authorization": "Bearer synthetic-application-token"})) + requireDenied(t, f.request(http.MethodGet, "/api/admin/users/"+admin.ID+"/sessions", current, nil, nil)) + requireDenied(t, f.request(http.MethodPost, "/api/account/sessions/revoke", current, map[string]interface{}{"session_ids": []int64{1}}, nil)) + f.me(current) + f.me(other) + f.me(admin) +} + +// TestHTTPSessionRevokeOthersCoversUnloadedPages checks the visible list, user +// summary and detail after clearing more sessions than the first page contains. +func TestHTTPSessionRevokeOthersCoversUnloadedPages(t *testing.T) { + f := newHTTPFixture(t) + admin := f.login(f.owner, integrationPassword) + current := f.login(f.member, integrationPassword) + var others []*browserSession + for i := 0; i < 21; i++ { + others = append(others, f.login(f.member, integrationPassword)) + } + page := requireSuccess(t, f.request(http.MethodGet, "/api/account/sessions?limit=20", current, nil, nil)) + if number(page["total_count"]) != 22 || page["has_more"] != true || len(page["items"].([]interface{})) != 20 || page["next_cursor"] == "" { + t.Fatal("session page must expose a two-part continuation cursor") + } + last := requireSuccess(t, f.request(http.MethodGet, "/api/account/sessions?limit=20&cursor="+page["next_cursor"].(string), current, nil, nil)) + if len(last["items"].([]interface{})) != 1 || last["has_more"] != false { + t.Fatal("second page must contain the remaining effective login") + } + result := requireSuccess(t, f.request(http.MethodPost, "/api/account/sessions/revoke-others", current, map[string]interface{}{}, nil)) + if number(result["revoked_count"]) != 21 { + t.Fatal("logout others must cover effective sessions outside the loaded page") + } + for _, other := range others { + requireDenied(t, f.request(http.MethodGet, "/api/auth/me", other, nil, nil)) + } + f.me(current) + page = requireSuccess(t, f.request(http.MethodGet, "/api/account/sessions", current, nil, nil)) + if number(page["total_count"]) != 1 || len(page["items"].([]interface{})) != 0 { + t.Fatal("only the current login should remain effective") + } + detail := requireSuccess(t, f.request(http.MethodGet, "/api/admin/users/"+current.ID, admin, nil, nil)) + if number(detail["active_session_count"]) != 1 || number(detail["restricted_session_count"]) != 0 || number(detail["session_warning_threshold"]) != 10 || detail["server_time"] == nil { + t.Fatal("administrator detail must share session counts and include the warning threshold") + } + users := requireSuccess(t, f.request(http.MethodGet, "/api/admin/users", admin, nil, nil)) + for _, raw := range users["items"].([]interface{}) { + user := object(raw) + if user["id"] == current.ID && number(user["active_session_count"]) != 1 { + t.Fatal("administrator user page session aggregate disagrees with detail") + } + } +} + +func TestHTTPSessionLimitedPasswordCannotReachManagement(t *testing.T) { + f := newHTTPFixture(t) + admin := f.login(f.owner, integrationPassword) + created := requireSuccess(t, f.request(http.MethodPost, "/api/admin/users", admin, map[string]interface{}{"user_name": "sessions_initial_password", "generate_password": true}, nil)) + password := created["initial_password"].(string) + limited := f.login("sessions_initial_password", password) + for _, request := range []struct{ method, path string }{ + {http.MethodGet, "/api/account/sessions"}, + {http.MethodPost, "/api/account/sessions/revoke"}, + {http.MethodPost, "/api/account/sessions/revoke-others"}, + } { + response := f.request(request.method, request.path, limited, map[string]interface{}{"session_ids": []string{"1"}}, nil) + if response.Status != http.StatusForbidden { + t.Fatalf("limited password escaped through %s: status %d", request.path, response.Status) + } + } + page := requireSuccess(t, f.request(http.MethodGet, "/api/admin/users/"+limited.ID+"/sessions", admin, nil, nil)) + if number(page["total_count"]) != 1 || number(page["restricted_count"]) != 1 { + t.Fatal("administrator must count the valid restricted password-change login") + } + f.me(limited) +} + +func TestHTTPSessionSelectiveLogoutKeepsExistingApplicationToken(t *testing.T) { + f := newHTTPFixture(t) + current := f.login(f.member, integrationPassword) + other := f.login(f.member, integrationPassword) + token := f.applicationToken(other, []string{"web-projects:read"}) + bearer := map[string]string{"Authorization": "Bearer " + token} + requireSuccess(t, f.request(http.MethodGet, "/api/web-share", nil, nil, bearer)) + requireDenied(t, f.request(http.MethodGet, "/api/account/sessions", nil, nil, bearer)) + page := requireSuccess(t, f.request(http.MethodGet, "/api/account/sessions", current, nil, nil)) + if number(page["total_count"]) != 2 { + t.Fatal("application issuance must not add a device session") + } + otherPage := requireSuccess(t, f.request(http.MethodGet, "/api/account/sessions", other, nil, nil)) + otherID := object(otherPage["current_session"])["id"].(string) + requireSuccess(t, f.request(http.MethodPost, "/api/account/sessions/revoke", current, map[string]interface{}{"session_ids": []string{otherID}}, nil)) + requireDenied(t, f.request(http.MethodGet, "/api/auth/me", other, nil, nil)) + requireSuccess(t, f.request(http.MethodGet, "/api/web-share", nil, nil, bearer)) + another := f.login(f.member, integrationPassword) + requireSuccess(t, f.request(http.MethodPost, "/api/account/sessions/revoke-others", current, map[string]interface{}{}, nil)) + requireDenied(t, f.request(http.MethodGet, "/api/auth/me", another, nil, nil)) + requireSuccess(t, f.request(http.MethodGet, "/api/web-share", nil, nil, bearer)) + f.me(current) +} + +// TestHTTPSessionLegacyMetadataSurvivesCookieExchange covers the actual RSA +// login and all cookie-exchange aliases without creating a second session or +// replacing issuance metadata with the exchanging browser's UA/IP. +func TestHTTPSessionLegacyMetadataSurvivesCookieExchange(t *testing.T) { + f := newHTTPFixture(t, false) + f.ip = "198.51.100.216:45006" + f.router.POST("/passport/login", passportapi.Login) + public, _, err := passportservice.GetLoginRsa(context.Background()) + if err != nil { + t.Fatal(err) + } + block, _ := pem.Decode(public) + key, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + t.Fatal(err) + } + cipher, err := rsa.EncryptPKCS1v15(rand.Reader, key.(*rsa.PublicKey), []byte(integrationPassword)) + if err != nil { + t.Fatal(err) + } + ua := "curl/8.4.0" + login := requireSuccess(t, f.request(http.MethodPost, "/passport/login", nil, map[string]string{"user_name": f.member, "crypt_passwd": base64.StdEncoding.EncodeToString(cipher)}, map[string]string{"User-Agent": ua, "X-Forwarded-For": "203.0.113.99"})) + token := login["token"].(string) + headers := map[string]string{"passport": token} + page := requireSuccess(t, f.request(http.MethodGet, "/api/account/sessions", nil, nil, headers)) + current := object(page["current_session"]) + if number(page["total_count"]) != 1 || current["login_ip"] != "198.51.100.216" || current["user_agent"] != ua || current["login_source"] != "legacy" { + t.Fatal("legacy login lost issuance metadata or trusted a forged proxy header") + } + f.ip = "198.51.100.217:45007" + for _, path := range []string{"/api/auth/browser-login", "/api/web-share/browser-login", "/api/web-projects/browser-login"} { + response := f.request(http.MethodPost, path, nil, nil, map[string]string{"passport": token, "User-Agent": "Mozilla/5.0 (iPhone)"}) + requireSuccess(t, response) + var cookie *http.Cookie + for _, candidate := range response.Cookies { + if candidate.MaxAge > 0 { + cookie = candidate + } + } + if cookie == nil { + t.Fatal("cookie exchange did not preserve its browser login contract") + } + browser := &browserSession{Cookie: cookie} + after := requireSuccess(t, f.request(http.MethodGet, "/api/account/sessions", browser, nil, nil)) + value := object(after["current_session"]) + if number(after["total_count"]) != 1 || value["id"] != current["id"] || value["user_agent"] != ua || value["login_ip"] != current["login_ip"] || value["authenticated_at"] != current["authenticated_at"] || value["expire_time"] != current["expire_time"] { + t.Fatal("cookie exchange added a login or rewrote the original issuance metadata/deadline") + } + } +} + +// TestHTTPSessionRevokeRechecksTokenAfterAuthentication pauses JSON consumption +// after middleware authentication and withdraws the acting session before the +// handler may update anything. A different live login must remain untouched. +func TestHTTPSessionRevokeRechecksTokenAfterAuthentication(t *testing.T) { + f := newHTTPFixture(t) + current := f.login(f.member, integrationPassword) + other := f.login(f.member, integrationPassword) + page := requireSuccess(t, f.request(http.MethodGet, "/api/account/sessions", other, nil, nil)) + otherID := object(page["current_session"])["id"].(string) + payload, err := json.Marshal(map[string]interface{}{"session_ids": []string{otherID}}) + if err != nil { + t.Fatal(err) + } + body := &blockedRequestBody{reader: bytes.NewReader(payload), entered: make(chan struct{}), release: make(chan struct{}), finished: make(chan struct{})} + done := make(chan apiResponse, 1) + go func() { + defer close(body.finished) + done <- f.request(http.MethodPost, "/api/account/sessions/revoke", current, body, nil) + }() + t.Cleanup(func() { + body.releaseOnce.Do(func() { close(body.release) }) + select { + case <-body.finished: + case <-time.After(5 * time.Second): + t.Error("paused session request did not exit before fixture cleanup") + } + }) + select { + case <-body.entered: + case response := <-done: + t.Fatalf("request ended before its authenticated body read: %d", response.Status) + case <-time.After(5 * time.Second): + t.Fatal("session request did not reach its authenticated body read") + } + requireSuccess(t, f.request(http.MethodPost, "/api/auth/logout", current, nil, nil)) + response := f.finishPausedRequest(body, done) + if response.Status != http.StatusUnauthorized { + t.Fatalf("revoked acting token must be rejected at transaction time: %d", response.Status) + } + f.me(other) +} diff --git a/api/middleware/account.go b/api/middleware/account.go index 8cbced7..f5b7cf6 100644 --- a/api/middleware/account.go +++ b/api/middleware/account.go @@ -22,6 +22,7 @@ import ( ) const AccountContextKey = "account" +const AccountSessionContextKey = "account_session_id" // TrustedClientIP ignores forwarding headers from direct clients and walks a // trusted proxy chain from right to left. A caller-controlled left prefix never @@ -153,6 +154,10 @@ func RequireAccount(admin, allowPasswordChange bool) gin.HandlerFunc { user, session, err = accounts.CheckSession(ginfmt.RPCContext(c), token, allowPasswordChange) if session != nil { tokenExpiresAt = session.ExpireTime + if session.Purpose == model.SessionPasswordChange && user != nil && user.PasswordExpiresAt != nil && user.PasswordExpiresAt.Before(tokenExpiresAt) { + tokenExpiresAt = *user.PasswordExpiresAt + } + c.Set(AccountSessionContextKey, session.ID) } } else { var identity *model.UserIdentity diff --git a/api/passport/passport.go b/api/passport/passport.go index ea1eb8d..af8cb24 100644 --- a/api/passport/passport.go +++ b/api/passport/passport.go @@ -94,7 +94,8 @@ func Login(c *gin.Context) { return } - token, err := passport.GenToken(res) + metadata := accounts.NewSessionMetadata(source, c.Request.UserAgent(), "legacy") + token, err := passport.GenTokenWithContext(ctx, res, metadata) if err != nil { ginfmt.FormatWithError(c, err) return diff --git a/api/webprojects/comments.go b/api/webprojects/comments.go index b61c0a0..e41bfc6 100644 --- a/api/webprojects/comments.go +++ b/api/webprojects/comments.go @@ -57,6 +57,7 @@ func commentContext(c *gin.Context) { result["display_name"] = "用户 " + strconv.FormatInt(p.UserID, 10) if accounts.DatabaseMode() { if u, e := accounts.GetByID(c.Request.Context(), p.UserID); e == nil && u != nil { + result["avatar_url"] = accounts.DisplayUser(u).AvatarURL if u.DisplayName != "" { result["display_name"] = u.DisplayName } else { diff --git a/api/webprojects/container/browser.test.cjs b/api/webprojects/container/browser.test.cjs index 770eb95..1364884 100644 --- a/api/webprojects/container/browser.test.cjs +++ b/api/webprojects/container/browser.test.cjs @@ -40,6 +40,7 @@ async function setup(options = {}) { const page = await browser.newPage({viewport: {width: options.width || (options.mobile ? 390 : 1280), height: 850}}) if (options.moduleGlobal) await page.addInitScript(() => { window.module = {exports: {business: true}} }) page.setDefaultTimeout(10000) + await page.route('**/api/account/avatars/**', route => route.fulfill({contentType: 'image/png', body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScLbtAAAAABJRU5ErkJggg==', 'base64')})) const state = {identity: {...context, ...options.identity}, requests: [], writes: [], threads: options.threads || [], events: [{id: '1', sequence: 1, kind: 'comment', body: '原始讨论', actor_user_id: '42', actor_application_id: '8', created_at: '2026-09-16T00:00:00Z'}, {id: '2', sequence: 2, kind: 'reply', body: '历史回复', actor_user_id: '43', actor_application_id: '', created_at: '2026-09-16T00:01:00Z'}]} await page.route('**/api/web-share/7/**', async route => { const request = route.request(), url = new URL(request.url()) @@ -70,6 +71,43 @@ async function selectText(page, id, start = 0, end) { }, {id, start, end}) } +test('评论使用当前昵称和头像,重置后不回退到历史违规快照', async () => { + const user = {user_id: '42', user_name: 'friend', display_name: '👩🏽‍💻 当前昵称', avatar_url: '/api/account/avatars/42/9'} + const {page, state} = await setup({identity: {display_name: user.display_name, avatar_url: user.avatar_url}, threads: [thread('1', {author_application_id: '', author_name_snapshot: '旧违规昵称', author_user: user})]}) + try { + await page.getByRole('button', {name: '评论', exact: true}).click() + await page.getByRole('button', {name: '查看讨论 1'}).waitFor() + assert.equal(await page.getByText('旧违规昵称', {exact: true}).count(), 0) + assert.ok(await page.getByText('👩🏽‍💻 当前昵称', {exact: true}).count() >= 2) + const picture = page.locator('hs-web-container img').first() + const original = await picture.elementHandle() + await picture.evaluate(image => image.dispatchEvent(new Event('error'))) + assert.equal(await original.evaluate(image => image.isConnected), false) + state.threads[0].author_user = {...user, display_name: '', avatar_url: ''} + await page.getByRole('button', {name: '刷新列表'}).click() + await page.getByText('friend', {exact: true}).waitFor() + assert.equal(await page.getByText('旧违规昵称', {exact: true}).count(), 0) + } finally { await page.close() } +}) + +test('删除账号的评论使用注销占位,外链头像不会发起请求', async () => { + const {page, state} = await setup({threads: [thread('1', {author_application_id: '', author_name_snapshot: '历史名称', author_user: null}), thread('2', {author_application_id: '', author_user: {user_id: '43', user_name: 'safe', display_name: '', avatar_url: 'https://tracker.invalid/avatar.png'}})]}) + try { + let tracked = 0 + await page.route('https://tracker.invalid/**', route => {tracked++; route.abort()}) + await page.getByRole('button', {name: '评论', exact: true}).click() + await page.getByRole('button', {name: '查看讨论 2'}).waitFor() + await page.getByText('已注销用户', {exact: true}).waitFor() + await page.getByText('', {exact: true}).waitFor() + assert.equal(await page.getByText('历史名称', {exact: true}).count(), 0) + assert.equal(tracked, 0) + state.events[0].actor_user = {user_id: '42', user_name: 'friend', display_name: '现在的作者', avatar_url: ''} + state.events[1].actor_user = null + await page.getByRole('button', {name: '查看讨论 1'}).click() + await page.getByText('现在的作者', {exact: true}).waitFor() + } finally { await page.close() } +}) + test('匿名只有固定菜单且原网页按钮可用,无评论请求或可聚焦入口', async () => { const {page, state} = await setup({identity: {user_id: '', display_name: '', can_comment: false, csrf_token: ''}}) try { diff --git a/api/webprojects/container/runtime.js b/api/webprojects/container/runtime.js index 63adca4..38bda5c 100644 --- a/api/webprojects/container/runtime.js +++ b/api/webprojects/container/runtime.js @@ -33,6 +33,15 @@ return label } + function currentIdentity(user, missingName) { + const clean = text => String(text || '').replace(/[\p{Cc}\p{Cf}]/gu, character => character === '\u200d' ? character : '').trim() + const name = user ? clean(user.display_name) || clean(user.user_name) || '用户 ' + user.user_id : missingName || '已注销用户' + let initial = user ? Array.from(name)[0] || '' : '' + if (initial && typeof Intl.Segmenter === 'function') initial = new Intl.Segmenter('zh', {granularity: 'grapheme'}).segment(name)[Symbol.iterator]().next().value.segment + const avatar = user && /^\/api\/account\/avatars\/[1-9][0-9]*\/[1-9][0-9]*$/.test(user.avatar_url || '') ? user.avatar_url : '' + return {name, initial, avatar} + } + if (typeof document === 'undefined') { if (typeof module === 'object' && module.exports) module.exports = {pageIdentity, quoteOffset, samePage, authorLabel} return @@ -54,6 +63,7 @@ nav{position:fixed;right:12px;top:12px;max-width:calc(100vw - 24px);display:flex;align-items:center;flex-wrap:wrap;gap:10px; padding:9px 12px;background:#fff;border:1px solid #d6dfd4;border-radius:12px;box-shadow:0 3px 16px #0002;pointer-events:auto} nav .identity{max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#556553} + .user-identity{display:inline-flex;align-items:center;gap:6px;max-width:100%;vertical-align:middle}.identity-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.avatar{display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;flex-shrink:0;border-radius:50%;background:#edf0e7;color:#687447;font-size:12px;overflow:hidden}.avatar img{width:100%;height:100%;object-fit:cover}.author{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin:6px 0} .panel{position:fixed;right:12px;top:76px;width:370px;max-width:calc(100vw - 24px);max-height:calc(100dvh - 96px);overflow:auto; padding:16px;background:#fff;border:1px solid #d6dfd4;border-radius:12px;box-shadow:0 5px 24px #0002;pointer-events:auto} h2{font-size:17px;margin:0 0 12px}h3{font-size:14px;margin:12px 0 8px}p{margin:6px 0}small,.muted{color:#637365} @@ -81,6 +91,26 @@ return node } + function renderIdentity(user, missingName) { + const identity = currentIdentity(user, missingName) + const wrap = element('span', {class: 'user-identity'}), avatar = element('span', {class: 'avatar', 'aria-hidden': 'true'}, identity.initial || '●') + if (identity.avatar) { + const image = element('img', {src: identity.avatar, alt: ''}) + image.addEventListener('error', () => avatar.replaceChildren(document.createTextNode(identity.initial || '●')), {once: true}) + avatar.replaceChildren(image) + } + wrap.append(avatar, element('span', {class: 'identity-name'}, identity.name)) + return wrap + } + + function renderAuthor(userID, application, snapshot, user) { + const line = element('small', {class: 'author'}) + if (application && application !== '0') line.append(element('span', {}, authorLabel(userID, application, snapshot))) + if (userID && userID !== '0') line.append(renderIdentity(user)) + else if (!application || application === '0') line.append(renderIdentity(null)) + return line + } + function makeHost(footer) { const node = element('hs-web-container', {'data-hs-comment-ignore': '', 'data-hs-container': footer ? 'footer' : 'tools'}) node.style.cssText = footer ? 'all:initial!important;display:block!important;position:relative!important;' : @@ -111,7 +141,7 @@ function renderMenu() { menu.replaceChildren(element('a', {href: settings.origin + '/'}, '系统主页'), element('a', {href: settings.origin + '/web-share'}, '网页托管')) if (identity && identity.project_name) menu.append(element('span', {class: 'identity', title: identity.project_name}, identity.project_name)) - if (identity && identity.user_id) menu.append(element('span', {class: 'identity'}, identity.display_name || '用户 ' + identity.user_id)) + if (identity && identity.user_id) menu.append(renderIdentity({user_id: identity.user_id, display_name: identity.display_name, avatar_url: identity.avatar_url})) else menu.append(element('a', {href: settings.origin + '/web-share/open?target=' + encodeURIComponent(location.pathname + location.search + location.hash)}, '未登录')) menu.append(element('span', {class: 'status'}, mode === 'browse' ? '浏览' : '评论中')) if (identity && identity.user_id && identity.can_comment && identity.container_mode !== 'raw') { @@ -418,7 +448,7 @@ const item = element('article', {class: 'thread'}) const anchor = thread.anchor || {} item.append(element('strong', {}, '讨论 ' + thread.id), element('p', {class: 'status'}, thread.status === 'resolved' ? '已解决' : '未解决'), - element('small', {}, authorLabel(thread.author_user_id, thread.author_application_id, thread.author_name_snapshot)), + renderAuthor(thread.author_user_id, thread.author_application_id, thread.author_name_snapshot, thread.author_user), element('p', {class: 'quote'}, anchor.exact || anchor.label || '整页讨论')) if (!samePage(thread, page)) item.append(element('p', {class: 'muted'}, '其他页面:' + thread.page_path)) else if (!locate(thread)) item.append(element('p', {class: 'muted'}, '原文无法定位')) @@ -492,7 +522,7 @@ const kinds = {comment: '评论', reply: '回复', resolve: '已解决', reopen: '已重开', reanchor: '重新关联'} for (const event of events) { const item = element('div', {class: 'event'}) - item.append(element('small', {}, (kinds[event.kind] || event.kind) + ' · ' + authorLabel(event.actor_user_id, event.actor_application_id, event.actor_name_snapshot) + ' · ' + (event.created_at || '')), + item.append(element('small', {}, (kinds[event.kind] || event.kind) + ' · ' + (event.created_at || '')), renderAuthor(event.actor_user_id, event.actor_application_id, event.actor_name_snapshot, event.actor_user), element('p', {class: 'body'}, event.body || '')) details.append(item) } diff --git a/app/adminweb/adminweb.go b/app/adminweb/adminweb.go index e2415da..64c3d42 100644 --- a/app/adminweb/adminweb.go +++ b/app/adminweb/adminweb.go @@ -36,8 +36,9 @@ type MutationRequest struct { type ProjectView struct { *webprojects.ProjectView - OwnerUserID string `json:"owner_user_id"` - UserName string `json:"user_name"` + OwnerUserID string `json:"owner_user_id"` + UserName string `json:"user_name"` + OwnerUser accounts.UserDisplay `json:"owner_user"` } type Page struct { @@ -127,21 +128,15 @@ func List(ctx context.Context, actorID, version int64, filter Filter) (*Page, er for _, project := range projects { owners = append(owners, project.OwnerUserID) } - names := map[int64]string{} - if len(owners) > 0 { - var users []struct { - ID int64 - Username string - } - if err := db.MasterDB().WithContext(ctx).Table(dal.AccountTable).Select("id", "username").Where("id IN ?", owners).Find(&users).Error; err != nil { - return nil, apperrors.ErrDependency - } - for _, user := range users { - names[user.ID] = user.Username - } + identities, err := accounts.DisplayUsers(ctx, owners) + if err != nil { + return nil, err } for _, project := range projects { - result.Items = append(result.Items, projectView(project, names[project.OwnerUserID])) + identity := identities[project.OwnerUserID] + view := projectView(project, identity.UserName) + view.OwnerUser = identity + result.Items = append(result.Items, view) } if result.HasMore { result.NextCursor = strconv.FormatInt(projects[len(projects)-1].ID, 10) @@ -178,7 +173,9 @@ func Get(ctx context.Context, actorID, version, projectID int64) (*Detail, error if releases == nil { releases = []*model.WebProjectRelease{} } - return &Detail{Project: projectView(project, owner.Username), Releases: releases}, nil + view := projectView(project, owner.Username) + view.OwnerUser = accounts.DisplayUser(owner) + return &Detail{Project: view, Releases: releases}, nil } // Change never grants normal browsing rights. It rechecks the administrator's diff --git a/app/siteconfig/service.go b/app/siteconfig/service.go index 3169648..63bd2ba 100644 --- a/app/siteconfig/service.go +++ b/app/siteconfig/service.go @@ -156,6 +156,12 @@ func (s *Service) checkedValues(row model.SiteConfigCurrent) (map[string]interfa if err != nil { return nil, appErrors.ErrDependency } + // Published v1 account policies omit this additive key; preserve their original JSON and checksum. + if row.Namespace == "account_policy" && values != nil { + if _, exists := values["max_active_sessions"]; !exists { + values["max_active_sessions"] = 5 + } + } normalized, err := config.ValidateValues(s.bootstrap, row.Namespace, values) if err != nil { return nil, appErrors.ErrDependency diff --git a/app/siteconfig/session_policy_test.go b/app/siteconfig/session_policy_test.go new file mode 100644 index 0000000..f0c48a4 --- /dev/null +++ b/app/siteconfig/session_policy_test.go @@ -0,0 +1,109 @@ +package siteconfig + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/json" + "testing" + + "github.com/mcoder2014/home_server/config" + "github.com/mcoder2014/home_server/domain/model" +) + +func TestStoredSessionPolicyDefaultsOnlyTheKnownMissingField(t *testing.T) { + conf := config.Config{} + service := New(nil, conf) + legacy := config.DefaultRuntimeValues(conf)["account_policy"] + delete(legacy, "max_active_sessions") + raw, _ := json.Marshal(legacy) + hash := sha256.Sum256(raw) + row := model.SiteConfigCurrent{Namespace: "account_policy", Revision: 1, SchemaVersion: 1, ValuesJSON: string(raw), ValuesSHA256: hash[:]} + values, err := service.checkedValues(row) + if err != nil || values["max_active_sessions"] != int64(5) { + t.Fatalf("valid legacy policy must read with website session limit 5: %v %v", values, err) + } + if row.ValuesJSON != string(raw) || !bytes.Equal(row.ValuesSHA256, hash[:]) { + t.Fatal("read compatibility modified stored JSON or checksum") + } + for _, change := range []struct { + name string + edit func(map[string]interface{}) + }{ + {"other missing key", func(v map[string]interface{}) { delete(v, "min_password_length") }}, + {"unknown key", func(v map[string]interface{}) { v["unknown_policy"] = 5 }}, + {"explicit null", func(v map[string]interface{}) { v["max_active_sessions"] = nil }}, + {"explicit zero", func(v map[string]interface{}) { v["max_active_sessions"] = 0 }}, + {"explicit excessive limit", func(v map[string]interface{}) { v["max_active_sessions"] = 101 }}, + } { + t.Run(change.name, func(t *testing.T) { + candidate := config.DefaultRuntimeValues(conf)["account_policy"] + delete(candidate, "max_active_sessions") + change.edit(candidate) + data, _ := json.Marshal(candidate) + digest := sha256.Sum256(data) + invalid := row + invalid.ValuesJSON, invalid.ValuesSHA256 = string(data), digest[:] + if _, err := service.checkedValues(invalid); err == nil { + t.Fatal("legacy read compatibility accepted an invalid document") + } + }) + } + nullSHA := sha256.Sum256([]byte("null")) + for _, invalid := range []model.SiteConfigCurrent{ + {Namespace: "account_policy", Revision: 1, SchemaVersion: 1, ValuesJSON: "null", ValuesSHA256: nullSHA[:]}, + {Namespace: "account_policy", Revision: 1, SchemaVersion: 1, ValuesJSON: string(raw), ValuesSHA256: make([]byte, 32)}, + } { + if _, err := service.checkedValues(invalid); err == nil { + t.Fatal("legacy read compatibility bypassed document shape or checksum verification") + } + } +} + +func TestStoredSessionPolicyIsReadOnlyAndLegacyRollbackAddsDefault(t *testing.T) { + service, database := runtimeTestService(t) + ctx := context.Background() + legacy := config.DefaultRuntimeValues(service.bootstrap)["account_policy"] + delete(legacy, "max_active_sessions") + raw, _ := json.Marshal(legacy) + hash := sha256.Sum256(raw) + for _, table := range []string{"site_config_current", "site_config_history"} { + if err := database.Exec("UPDATE "+table+" SET values_json=?,values_sha256=? WHERE namespace='account_policy' AND revision=1", string(raw), hash[:]).Error; err != nil { + t.Fatal(err) + } + } + if err := service.Initialize(ctx); err != nil { + t.Fatal(err) + } + if config.Runtime().AccountPolicy.MaxActiveSessions != 5 { + t.Fatal("legacy database startup did not receive default website session limit") + } + view, err := service.Get(ctx, "account_policy") + if err != nil || view.Values["max_active_sessions"] != int64(5) { + t.Fatalf("legacy settings view omitted the default: %v %v", view, err) + } + var stored model.SiteConfigCurrent + if err := database.Table("site_config_current").Where("namespace='account_policy'").Take(&stored).Error; err != nil { + t.Fatal(err) + } + if stored.Revision != 1 || stored.ValuesJSON != string(raw) || !bytes.Equal(stored.ValuesSHA256, hash[:]) { + t.Fatal("reading legacy settings wrote defaults into the database") + } + candidate := config.DefaultRuntimeValues(service.bootstrap)["account_policy"] + candidate["max_active_sessions"] = 3 + published, err := service.Publish(ctx, "account_policy", 1, 1, PublishRequest{RequestID: "website-session-limit", Reason: "synthetic limit update", Values: candidate}, allowConfigTest) + if err != nil || published.Revision != 2 || config.Runtime().AccountPolicy.MaxActiveSessions != 3 { + t.Fatalf("new website session policy was not applied: %v %v", published, err) + } + rolledBack, err := service.Rollback(ctx, "account_policy", 1, 2, RollbackRequest{TargetRevision: 1, RequestID: "website-session-limit-rollback", Reason: "synthetic legacy rollback"}, allowConfigTest) + if err != nil || rolledBack.Revision != 3 || rolledBack.Values["max_active_sessions"] != int64(5) || config.Runtime().AccountPolicy.MaxActiveSessions != 5 { + t.Fatalf("legacy rollback did not publish a complete document with limit 5: %v %v", rolledBack, err) + } + var historical model.SiteConfigHistory + if err := database.Table("site_config_history").Where("namespace='account_policy' AND revision=1").Take(&historical).Error; err != nil { + t.Fatal(err) + } + if historical.ValuesJSON != string(raw) || !bytes.Equal(historical.ValuesSHA256, hash[:]) { + t.Fatal("legacy rollback rewrote historical JSON or checksum") + } +} diff --git a/app/webprojects/projects.go b/app/webprojects/projects.go index 0cbcb57..4fec987 100644 --- a/app/webprojects/projects.go +++ b/app/webprojects/projects.go @@ -536,6 +536,18 @@ func (application *Application) IsMember(projectID, userID int64) (bool, error) } func (application *Application) EligibleUsers() ([]service.EligibleUser, error) { + if accounts.DatabaseMode() { + identities, err := accounts.ListActiveUsers(context.Background()) + if err != nil { + return nil, service.ErrDependency + } + users := make([]service.EligibleUser, 0, len(identities)) + for _, user := range identities { + identity := accounts.DisplayUser(user) + users = append(users, service.EligibleUser{ID: strconv.FormatInt(user.ID, 10), UserName: identity.UserName, DisplayName: identity.DisplayName, AvatarURL: identity.AvatarURL}) + } + return users, nil + } identities, err := passport.ListUsersWithError(context.Background()) if err != nil { return nil, service.ErrDependency diff --git a/cmd/accounts-maintain/main.go b/cmd/accounts-maintain/main.go new file mode 100644 index 0000000..1b3934e --- /dev/null +++ b/cmd/accounts-maintain/main.go @@ -0,0 +1,154 @@ +// accounts-maintain performs reviewed account-center DDL and bounded retention cleanup. +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "os/signal" + "syscall" + "time" + + "github.com/mcoder2014/home_server/internal/accountsmigrate" + "gopkg.in/yaml.v3" +) + +// run defaults to a redacted schema-only plan. Offline DDL requires all three +// reviewed confirmations, while daily retention cleanup is an explicit separate +// mode. It accepts ordinary protected server YAML without legacy mock identities; +// no configuration contents or server-supplied connection errors are printed. +func run(ctx context.Context, args []string, stdout, stderr io.Writer) error { + flags := flag.NewFlagSet("accounts-maintain", flag.ContinueOnError) + flags.SetOutput(io.Discard) + conf := flags.String("config", "", "protected server YAML file (0600)") + database := flags.String("database", "", "optional isolated database override") + apply := flags.Bool("apply-migration", false, "apply reviewed account center DDL during maintenance") + clear := flags.Bool("clear-session-metadata", false, "erase metadata thirty days after natural session expiry") + confirmed := flags.String("confirm-database", "", "exact target database required for migration apply") + planSHA := flags.String("plan-sha256", "", "reviewed additive plan digest required for migration apply") + maintenance := flags.Bool("maintenance-confirmed", false, "confirm backend writers stopped and fresh backup verified") + output := flags.String("out", "", "write redacted JSON to a new mode-0600 file") + timeout := flags.Duration("timeout", 5*time.Minute, "total timeout; cleanup is additionally bounded to five minutes") + if err := flags.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + flags.SetOutput(stderr) + flags.PrintDefaults() + return nil + } + return errors.New("invalid command-line arguments (details suppressed)") + } + if flags.NArg() != 0 || *conf == "" { + return errors.New("--config is required; no positional arguments are accepted") + } + if *apply && *clear { + return errors.New("--apply-migration and --clear-session-metadata are separate modes") + } + if *timeout <= 0 || *timeout > time.Hour { + return errors.New("timeout must be greater than zero and at most one hour") + } + if *apply && (!*maintenance || *confirmed == "" || *planSHA == "") { + return errors.New("apply-migration requires --maintenance-confirmed, --confirm-database and --plan-sha256") + } + source, err := readConfig(*conf) + if err != nil { + return err + } + db, name, err := accountsmigrate.OpenDatabase(source, *database) + if err != nil { + return err + } + defer db.Close() + ctx, cancel := context.WithTimeout(ctx, *timeout) + defer cancel() + if *output != "" { + file, err := os.OpenFile(*output, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + return errors.New("cannot create new output file; existing files are never overwritten") + } + defer file.Close() + stdout = file + } + if *clear { + result, err := accountsmigrate.ClearSessionMetadata(ctx, db, name, time.Now()) + if err != nil { + return err + } + return writeReport(stdout, map[string]interface{}{"action": "session_metadata_cleared", "result": result}) + } + opts, err := accountsmigrate.AccountCenterOptions(name) + if err != nil { + return err + } + plan, err := accountsmigrate.BuildAdditivePlan(ctx, db, opts) + if err != nil { + return err + } + action := "migration_plan" + if *apply { + if err := accountsmigrate.ApplyAdditive(ctx, db, opts, *planSHA, *confirmed, *maintenance); err != nil { + return err + } + plan, err = accountsmigrate.BuildAdditivePlan(ctx, db, opts) + if err != nil { + return fmt.Errorf("migration applied but readback verification failed: %w", err) + } + if len(plan.PendingSteps) != 0 || len(plan.PendingMarkers) != 0 { + return errors.New("migration applied but schema or stage markers remain incomplete") + } + action = "migration_applied_and_verified" + } + return writeReport(stdout, map[string]interface{}{"action": action, "plan": plan}) +} + +func readConfig(path string) (*accountsmigrate.Source, error) { + info, err := os.Lstat(path) + if err != nil { + return nil, errors.New("cannot stat protected server config") + } + if !info.Mode().IsRegular() || info.Mode().Perm()&0177 != 0 { + return nil, errors.New("server config must be a regular file with mode 0600 or stricter") + } + file, err := os.Open(path) + if err != nil { + return nil, errors.New("cannot read protected server config") + } + defer file.Close() + opened, err := file.Stat() + if err != nil || !os.SameFile(info, opened) || !opened.Mode().IsRegular() || opened.Mode().Perm()&0177 != 0 { + return nil, errors.New("protected server config changed while opening") + } + raw, err := io.ReadAll(io.LimitReader(file, 4*1024*1024+1)) + if err != nil || len(raw) > 4*1024*1024 { + return nil, errors.New("cannot read server config within the four MiB limit") + } + source := &accountsmigrate.Source{} + if err := yaml.Unmarshal(raw, &source.Config); err != nil { + return nil, errors.New("invalid server YAML (details suppressed)") + } + if source.Config.Mysql.MasterDB == "" { + return nil, errors.New("server config requires mysql.master_db") + } + return source, nil +} + +func writeReport(output io.Writer, report interface{}) error { + encoder := json.NewEncoder(output) + encoder.SetIndent("", " ") + if err := encoder.Encode(report); err != nil { + return errors.New("cannot write redacted maintenance report") + } + return nil +} + +func main() { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + if err := run(ctx, os.Args[1:], os.Stdout, os.Stderr); err != nil { + json.NewEncoder(os.Stderr).Encode(map[string]string{"error": err.Error()}) + os.Exit(1) + } +} diff --git a/cmd/accounts-maintain/main_test.go b/cmd/accounts-maintain/main_test.go new file mode 100644 index 0000000..fe5c9c3 --- /dev/null +++ b/cmd/accounts-maintain/main_test.go @@ -0,0 +1,66 @@ +package main + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestApplyMigrationRequiresReviewedOfflineConfirmations(t *testing.T) { + var out, errOut bytes.Buffer + err := run(context.Background(), []string{"--config", "nonexistent", "--apply-migration"}, &out, &errOut) + if err == nil || !strings.Contains(err.Error(), "maintenance-confirmed") || out.Len() != 0 { + t.Fatal("apply must require maintenance, exact database and reviewed plan before reading secrets") + } +} + +func TestMaintenanceRejectsMixedModesAndUnboundedTimeout(t *testing.T) { + for _, args := range [][]string{{"--apply-migration", "--clear-session-metadata"}, {"--timeout", "0"}, {"--timeout", "2h"}, {"unexpected"}} { + var out, errOut bytes.Buffer + err := run(context.Background(), append([]string{"--config", "nonexistent"}, args...), &out, &errOut) + if err == nil || out.Len() != 0 { + t.Fatal("mixed modes, unbounded timeout or positional argument accepted") + } + } +} + +func TestMaintenanceConfigAllowsDatabaseModeWithoutLegacyIdentities(t *testing.T) { + path := filepath.Join(t.TempDir(), "server.yaml") + secret := "fixture-private-secret" + if err := os.WriteFile(path, []byte("identity_source: database\nconfig_source: database\nmysql:\n master_db: 'fixture:"+secret+"@tcp(127.0.0.1:3306)/fixture'\n"), 0600); err != nil { + t.Fatal(err) + } + source, err := readConfig(path) + if err != nil || !strings.Contains(source.Config.Mysql.MasterDB, secret) || len(source.Users) != 0 { + t.Fatalf("maintenance must accept active database config without mock_data: %v", err) + } + if err := os.Chmod(path, 0644); err != nil { + t.Fatal(err) + } + if _, err := readConfig(path); err == nil || strings.Contains(err.Error(), secret) { + t.Fatal("readable secret config accepted or credential leaked") + } + if err := os.Chmod(path, 0600); err != nil { + t.Fatal(err) + } + symlink := filepath.Join(filepath.Dir(path), "link.yaml") + if err := os.Symlink(path, symlink); err != nil { + t.Fatal(err) + } + if _, err := readConfig(symlink); err == nil { + t.Fatal("symlink secret config accepted") + } +} + +func TestMaintenanceConfigErrorsSuppressYAMLContents(t *testing.T) { + path := filepath.Join(t.TempDir(), "invalid.yaml") + if err := os.WriteFile(path, []byte("mysql: [fixture-private-secret"), 0600); err != nil { + t.Fatal(err) + } + if _, err := readConfig(path); err == nil || strings.Contains(err.Error(), "fixture-private-secret") { + t.Fatal("malformed YAML contents exposed in error") + } +} diff --git a/config/nginx/api_auth_locations.conf b/config/nginx/api_auth_locations.conf index a10e097..b58cec6 100644 --- a/config/nginx/api_auth_locations.conf +++ b/config/nginx/api_auth_locations.conf @@ -50,6 +50,19 @@ location ^~ /api/site/ { add_header Cache-Control "no-store" always; } +# Images remain bounded by the backend's 2 MiB input and 64 KiB multipart allowance. +location = /api/account/avatar { + access_log off; + client_max_body_size 3m; + proxy_pass http://127.0.0.1:18080; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_cache off; + proxy_intercept_errors off; + add_header Cache-Control "no-store" always; +} + location ^~ /api/account/ { access_log off; client_max_body_size 16k; diff --git a/config/registry.go b/config/registry.go index 3811fb3..0eeaf8f 100644 --- a/config/registry.go +++ b/config/registry.go @@ -84,6 +84,7 @@ func Registry(conf Config) []NamespaceSchema { {"web_projects", "max_concurrent_extracts", "全站同时上传数量", "个", 1, 100, 4}, {"web_projects", "delete_retention_days", "删除后保留时间", "天", 1, 365, 7}, {"account_policy", "session_ttl_seconds", "用户会话有效期", "秒", 300, 2592000, 604800}, + {"account_policy", "max_active_sessions", "每个账号同时有效的网站登录上限", "个会话", 1, 100, 5}, {"account_policy", "temporary_password_ttl_days", "临时密码有效期", "天", 1, 30, 7}, {"account_policy", "min_password_length", "密码最少字符数", "字符", 15, 64, 15}, {"account_policy", "bcrypt_cost", "密码哈希成本", "", 10, 14, 12}, diff --git a/config/runtime.go b/config/runtime.go index b9a1172..76ac7cc 100644 --- a/config/runtime.go +++ b/config/runtime.go @@ -9,6 +9,7 @@ import ( type AccountPolicyConfig struct { SessionTTLSeconds int `json:"session_ttl_seconds"` + MaxActiveSessions int `json:"max_active_sessions"` TemporaryPasswordTTLDays int `json:"temporary_password_ttl_days"` MinPasswordLength int `json:"min_password_length"` BcryptCost int `json:"bcrypt_cost"` diff --git a/config/runtime_test.go b/config/runtime_test.go index 27c207c..dfba81f 100644 --- a/config/runtime_test.go +++ b/config/runtime_test.go @@ -1,6 +1,7 @@ package config import ( + "encoding/json" "testing" ) @@ -129,3 +130,33 @@ func TestResourceQuotaSchemaDefaultsAndRelationships(t *testing.T) { t.Fatal("per-user quota accepted below per-project quota") } } + +func TestMaxActiveSessionsPolicyDefaultsAndBounds(t *testing.T) { + conf := Config{} + values := DefaultRuntimeValues(conf) + if values["account_policy"]["max_active_sessions"] != 5 { + t.Fatalf("default website session limit must be 5: %v", values["account_policy"]) + } + snapshot, err := BuildRuntimeSnapshot(conf, 0, nil, values) + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(snapshot.AccountPolicy) + var policy map[string]interface{} + if err := json.Unmarshal(raw, &policy); err != nil || policy["max_active_sessions"] != float64(5) { + t.Fatalf("runtime omitted website session policy: %s %v", raw, err) + } + for _, limit := range []interface{}{1, 100, 0, -1, 101, 1.5, "5", true, nil} { + candidate := DefaultRuntimeValues(conf)["account_policy"] + candidate["max_active_sessions"] = limit + _, err := ValidateValues(conf, "account_policy", candidate) + valid := limit == 1 || limit == 100 + if (err == nil) != valid { + t.Errorf("website session limit %v validity=%t: %v", limit, valid, err) + } + } + delete(values["account_policy"], "max_active_sessions") + if _, err := ValidateValues(conf, "account_policy", values["account_policy"]); err == nil { + t.Fatal("new publication accepted a missing website session limit") + } +} diff --git a/deploy/pi/accounts-maintain.service b/deploy/pi/accounts-maintain.service new file mode 100644 index 0000000..cbf50d8 --- /dev/null +++ b/deploy/pi/accounts-maintain.service @@ -0,0 +1,23 @@ +# Public example; install a built accounts-maintain binary and a private 0600 YAML first. +[Unit] +Description=CQ Home Server session metadata retention +After=network.target mariadb.service +Requires=mariadb.service + +[Service] +Type=oneshot +User=home-server +Group=home-server +WorkingDirectory=/opt/home_server/current +ExecStart=/opt/home_server/current/bin/accounts-maintain --config /etc/home_server/server.yaml --clear-session-metadata --timeout=5m +TimeoutStartSec=330 +UMask=0077 +NoNewPrivileges=true +PrivateTmp=true +ProtectHome=true +ProtectSystem=strict +RestrictSUIDSGID=true +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=accounts-maintain diff --git a/deploy/pi/accounts-maintain.timer b/deploy/pi/accounts-maintain.timer new file mode 100644 index 0000000..adc2fe8 --- /dev/null +++ b/deploy/pi/accounts-maintain.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Daily CQ Home Server session metadata retention + +[Timer] +OnCalendar=*-*-* 03:15:00 Asia/Singapore +RandomizedDelaySec=15m +Persistent=true +Unit=accounts-maintain.service + +[Install] +WantedBy=timers.target diff --git a/deploy/pi/gateway-api.conf b/deploy/pi/gateway-api.conf index ffcc3d9..5b6f6dd 100644 --- a/deploy/pi/gateway-api.conf +++ b/deploy/pi/gateway-api.conf @@ -19,6 +19,21 @@ server { # Keep only the new account namespaces on a bounded, uncached API route. # Preserve the existing gateway destination and raw Host (including port). + location = /api/account/avatar { + client_max_body_size 3m; + proxy_pass http://192.0.2.10:18080; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header Connection ""; + proxy_request_buffering off; + proxy_read_timeout 300s; + proxy_cache off; + proxy_intercept_errors off; + add_header Cache-Control "no-store" always; + } + location ~ ^/api/(site|account|admin)/ { client_max_body_size 64k; proxy_pass http://192.0.2.10:18080; diff --git a/deploy/pi/gateway-frontend.conf b/deploy/pi/gateway-frontend.conf index da3db80..db97a61 100644 --- a/deploy/pi/gateway-frontend.conf +++ b/deploy/pi/gateway-frontend.conf @@ -19,6 +19,21 @@ server { # Keep only the new account namespaces on a bounded, uncached API route. # Preserve the existing gateway destination and raw Host (including port). + location = /api/account/avatar { + client_max_body_size 3m; + proxy_pass http://192.0.2.10:18081; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header Connection ""; + proxy_request_buffering off; + proxy_read_timeout 300s; + proxy_cache off; + proxy_intercept_errors off; + add_header Cache-Control "no-store" always; + } + location ~ ^/api/(site|account|admin)/ { client_max_body_size 64k; proxy_pass http://192.0.2.10:18081; diff --git a/docs/account-center-operations.md b/docs/account-center-operations.md new file mode 100644 index 0000000..728a55f --- /dev/null +++ b/docs/account-center-operations.md @@ -0,0 +1,131 @@ +# 个人中心升级与会话元信息维护 + +适用范围:已具备数据库账号结构的 Home Server,验证数据库版本为 MariaDB 10.11。所有路径、库名和服务用户名均为公开模板;真实连接信息只保存在服务器的受限 YAML。增量升级保留原账号、密码、授权、应用凭证、登录期限及审计。 + +## 1. 工具与安装顺序 + +| 场景 | 操作顺序 | +| --- | --- | +| 首次启用数据库账号 | 先按 [账号迁移说明](accounts-migration.md) 完成旧结构及账号/动态配置导入,再运行个人中心增量升级 | +| 已完成数据库账号导入 | 直接使用 `accounts-maintain` 计划并应用个人中心增量升级,无需旧 `mock_data` | +| 增量 DDL 中断 | 保持维护,重新生成计划,核对已生效结构与逐步标记,再应用剩余阶段 | +| 日常隐私维护 | 显式运行 `--clear-session-metadata`;可由每日 systemd timer 调用 | + +`accounts-migrate` 的两个已发布 DDL 文件及导入校验清单保持原样。`accounts-maintain` 只读取 `20260917_account_center.sql`,不读取旧身份数据,不重新导入账号,不改变历史导入标记。 + +在已授权构建主机的独立源码目录生成维护工具,随后将产物安装到匹配的 release `bin/`。不得在运行中的 release 目录编译或覆盖正在执行的二进制内容。 + +```bash +ssh build-host 'source ~/.zshrc && cd /tmp/home-server-account-center-build && go build -o /tmp/accounts-maintain ./cmd/accounts-maintain' +``` + +工具读取 `--config` 所指向的普通 YAML,权限为 0600 或更严;拒绝符号链接、可执行或其他用户可读的配置。文件最多 4 MiB,必须含 `mysql.master_db`,不要求 `passport.mock_data` 或旧授权清单。DSN、配置正文、密码、Token、IP/UA及头像内容不会写入计划、结果或错误。 + +## 2. 增量结构与预检 + +| 对象 | 新增内容 | 兼容行为 | +| --- | --- | --- | +| `user_account` | `avatar_version BIGINT NOT NULL DEFAULT 0` | 存量资料保持;0 表示默认头像 | +| `user_avatar` | 用户 ID 主键、版本、MIME、`MEDIUMBLOB`、字节数、更新时间 | 每用户一张当前标准头像;常规账号查询不读取图像 | +| `login_token` | `login_ip`、`user_agent`、`client_name`、`os_name`、`device_type`、`login_source` | 六个字段均 `NOT NULL DEFAULT ''`,旧会话允许未知描述 | +| 会话列表索引 | `idx_login_token_session_page (user_id,auth_version,is_expired,create_time,id)` | 时间/ID 游标分页;保留旧 `idx_login_token_user` | +| 保留期索引 | `idx_login_token_metadata_expiry (expire_time,id)` | 按自然到期时间和 ID 扫描清理批次 | +| 有效会话范围索引 | `idx_login_token_effective_range (user_id,auth_version,is_expired,expire_time,create_time,id)` | 固定账号、当前认证版本和未撤销标记后,直接定位自然期限内的集合;列表可对该集合按创建时间/ID 排序 | + +旧 `idx_login_token_user (user_id,is_expired,expire_time)` 可以跳过自然过期记录,但仍会读取尚未自然到期的旧认证版本。新增范围索引同时限定当前认证版本,避免这些历史记录放大有效会话查询。列表、单用户计数及登录限额查询显式带入同一快照已读取的账号 ID/版本,数据库仍核对账号状态、撤销标记、自然期限及会话用途。管理员分页聚合一次传入最多 100 组已读账号 ID/版本组合,并保留实际账号版本的 JOIN 校验。 + +原列表索引继续保留。列表与游标在同一事务计数不超过 100 时使用 `USE INDEX (idx_login_token_effective_range)`,先读取当前版本未自然过期的小集合,再按创建时间/ID 排序;超过 100 的合法存量保留自然索引选择,使原排序索引仍可提前返回,避免固定范围索引后排序全部有效历史。该列表策略有条件索引提示,计数、管理员聚合和无排序的登录限额查询由优化器自然选择索引。 + +存量未到期会话可能超过新的登录限额;计数保留全部有效会话,不以限额截断,也不自动撤销存量记录。 + +升级前核对数据库实际版本、表结构、会话规模、DDL 锁表成本、全部入口代理及可回滚的数据库账号兼容版本。先完成数据库备份和独立恢复演练,再停止后端及其他写入者、暂停每日维护任务,并确认在途写请求已排空。工具不会自动停服务或执行备份。 + +默认命令只读取结构和阶段标记,生成新计划文件: + +```bash +/opt/home_server/current/bin/accounts-maintain \ + --config /etc/home_server/server.yaml \ + --out /var/backups/home_server/account-center-plan.json +``` + +`--out` 按 0600 新建文件且拒绝覆盖;省略时输出脱敏 JSON。计划包含目标库、嵌入 DDL 摘要、实际结构摘要、尚未生效的 `pending_steps`、尚未完成的 `pending_markers`、每阶段摘要与状态、`plan_sha256`。计划不读取业务数据;在线登录或资料变化不参与哈希,因此应用时仍必须实际停写。 + +演练时可用 `--database home_server_rehearsal_account_center` 覆盖库名。连接账号只应被授予演练库权限,演练计划不能用于正式库。正式库名始终取受限配置中的 DSN 或操作者明确指定的覆盖值。 + +## 3. 应用、重复运行与阶段恢复 + +核对目标库、11 个独立增量阶段及全部摘要后,使用审核过的哈希: + +```bash +/opt/home_server/current/bin/accounts-maintain \ + --config /etc/home_server/server.yaml \ + --apply-migration \ + --maintenance-confirmed \ + --confirm-database home_server \ + --plan-sha256 '' \ + --out /var/backups/home_server/account-center-applied.json +``` + +应用必须同时提供维护声明、精确库名和计划摘要。工具在专用连接取得与原账号迁移共用的库级互斥锁,再核对真实连接库名并重算计划。结构、阶段标记、目标库或新 DDL 变化导致哈希不一致时,在执行 DDL 前停止。默认总超时为 5 分钟,`--timeout` 可调整,但必须大于 0 且最多 1 小时。 + +MariaDB DDL 会隐式提交。工具复用严格 DDL 解析与实际列/索引/约束检查,为每个阶段记录 `started`,执行并读回后标为 `completed`。新增结构不能被假定为事务回滚。 + +有效会话范围索引是追加的 `20260917_account_center.sql:011`,前 10 阶段的 ID、SQL 和摘要保持不变。已完成前 10 阶段的演练库重新生成计划后只需应用第 11 阶段,保留原阶段完成时间和历史导入摘要。DDL 文件摘要已变化,追加索引前审核的旧计划不能继续使用。 + +| 检查结果 | 处理 | +| --- | --- | +| 结构尚未生效,标记缺失或 `started` | 应用该增量阶段,再读回和完成标记 | +| 结构已精确匹配,标记缺失或 `started` | 跳过 DDL,补全完成标记 | +| 结构精确匹配且标记 `completed` | 安全重跑,保留资料、头像及认证状态 | +| 完成标记对应的结构缺失 | 拒绝,保持维护并排查人为删除或恢复错误 | +| 同名结构定义不同、摘要冲突或未知标记阶段 | 拒绝,修复原因后重新生成并审核计划 | + +中断后不得复用旧计划或删除已完成标记;保持维护并重新生成计划。成功返回 `action=migration_applied_and_verified`,`pending_steps` 与 `pending_markers` 都为空。验收新旧登录、资料、头像、会话撤销及管理员审计后,再启动每日维护任务和正常业务。 + +## 4. 每日元信息清理 + +```bash +/opt/home_server/current/bin/accounts-maintain \ + --config /etc/home_server/server.yaml \ + --clear-session-metadata \ + --timeout=5m +``` + +清理使用同一次服务端时间,条件严格为 `expire_time < now - 30天`。按 `expire_time,id` 游标查询,每批最多 500 行,在独立事务清空六个新增描述字段;自然到期时间缺失的记录不被推测处理。刚撤销但尚未自然到期的会话仍按自然期限保留元信息。 + +工具不删除 `login_token` 行,不改变令牌原文/摘要、失效标记、认证版本、用途、登录或到期时间,不修改管理审计。重复执行没有额外数据变化;中断时已提交批次保持清空,下次继续处理未清空记录。内置总上限为 5 分钟,较短 `--timeout` 仍生效。清理与迁移互斥;清理失败不改变会话认证规则。 + +成功返回 `action=session_metadata_cleared`,结果只含目标库、实际清空行数和批次数。数据库错误隐藏服务端原始详情;失败以非零状态退出。 + +## 5. systemd 模板与运行检查 + +模板为 [accounts-maintain.service](../deploy/pi/accounts-maintain.service) 和 [accounts-maintain.timer](../deploy/pi/accounts-maintain.timer)。服务账号示例为 `home-server`,工具路径为 `/opt/home_server/current/bin/accounts-maintain`,配置路径为 `/etc/home_server/server.yaml`。部署者需先核对实际路径、账号以及受限 YAML 的读取权限。 + +安装到匹配的 `/etc/systemd/system/` 后,在部署主机校验并启用: + +```bash +sudo systemd-analyze verify /etc/systemd/system/accounts-maintain.service /etc/systemd/system/accounts-maintain.timer +sudo systemctl daemon-reload +sudo systemctl enable --now accounts-maintain.timer +sudo systemctl start accounts-maintain.service +systemctl list-timers accounts-maintain.timer +systemctl show accounts-maintain.service -p Result -p ExecMainStatus +journalctl -u accounts-maintain.service --since '2 days ago' +``` + +timer 每天在 `Asia/Singapore` 03:15 后随机延迟最多 15 分钟执行,支持停机后补跑。清理服务有 330 秒 systemd 启动上限,仅数据库写入,不需要写应用目录或配置文件。模板本身不安装或启用生产单元。 + +将该服务接入现有告警系统:失败保留脱敏错误;连续失败超过 48 小时,或已经启用 timer 但超过 48 小时没有成功执行,触发告警。告警内容使用任务状态、时间、失败次数和数据库名,不包含连接凭据或会话描述。通知渠道由部署环境配置。 + +## 6. 合成验证与回滚 + +Go 测试与构建在获准远端独立源码目录运行。迁移集成测试要求 `ACCOUNTS_MIGRATION_TEST_DSN` 指向前缀为 `home_server_accounts_migration_test_` 的专属合成库,且会清空该库全部表;该环境变量应来自 0600 临时文件。 + +```bash +# 在获准远端、仅加载专属合成库凭据后执行。 +go test -race ./internal/accountsmigrate ./cmd/accounts-maintain ./cmd/accounts-migrate -count=1 +``` + +行为用例覆盖首次结构安装、已导入库升级、重复应用、DDL 已生效但标记未完成的恢复、计划漂移与结构冲突、原迁移计划在新结构上重验、过期 30 天边界、500 行批次和清理幂等。清理查询及会话查询须在同版本合成库上通过 `EXPLAIN` 核对索引和排序,再依据实际规模评估维护窗口。 + +回滚保留新增列、表和索引,先撤回新前端,再退到支持数据库账号的兼容后端;已撤销的会话不会复活。清空元信息和移除头像属于真实删除,普通代码回滚不恢复其内容。不要通过恢复旧数据库备份回滚程序,旧备份可能复活已经撤销的凭据。 diff --git a/docs/accounts-migration.md b/docs/accounts-migration.md index 9876e63..9873dbb 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、临时密码期限、密码最低长度、bcrypt 成本 | 用于新签发会话与新设置密码 | +| `account_policy` | 会话 TTL、同时有效登录上限(默认 5、可配 1–100)、临时密码期限、密码最低长度、bcrypt 成本 | 用于新签发会话与新设置密码;调低上限保留已有会话,满额拒绝新签发 | 每月 3 个邀请码、有效 7 天、`Asia/Singapore` 月份计算是固定规则,管理页面只读展示。 @@ -49,6 +49,8 @@ WebDAV Basic 是持续支持的客户端认证协议。`/webdav/` 和 `/webdav_d 配置回滚不会恢复旧邀请码、被撤销的账号能力、旧密码、旧会话、吊销的应用凭证或已清理文件。 +存量账号策略缺少 `max_active_sessions` 时,读取先校验原始 JSON 摘要,再在副本补 5;不会写回当前或历史记录。新发布要求完整字段。V1 账号导入继续使用原四字段策略计算摘要;扩展运行时默认值不会使已完成导入失效。已发布新策略后,代码回滚必须使用识别新字段的兼容后端;更旧版本需先按其 schema 准备可验证的策略文档。 + ## 3. 编译与合成测试 在获准构建主机的独立源码目录执行。非交互 SSH 必须先加载远端 shell 环境;不得在生产 release 目录编译。 @@ -176,3 +178,13 @@ upload_hard_limit_bytes: 52428800 | 必须整库恢复 | 单独核对恢复点和之后的数据损失,再执行恢复流程 | 正式切换前需准备可回滚的兼容二进制。迁移工具不执行整库恢复、不撤销用户授权,也不恢复已经失效的凭证。 + +## 9. 个人中心的独立增量升级 + +个人中心使用新增的 `20260917_account_center.sql`,由 `accounts-maintain` 生成脱敏计划并在维护窗口应用。首次安装先完成本文的账号/动态配置迁移,再运行个人中心增量升级;已完成账号导入的站点只运行增量升级。操作、阶段恢复与每日元信息清理见 [个人中心运维说明](account-center-operations.md)。 + +个人中心共有 11 个独立阶段,最后追加 `idx_login_token_effective_range (user_id,auth_version,is_expired,expire_time,create_time,id)`,用于同时限定当前认证版本与自然期限。此前已完成 10 阶段的演练库需重新生成计划并应用第 11 阶段;原阶段标记与历史账号导入摘要保持不变。 + +`accounts-migrate` 仍只读取 `20260913_runtime_config.sql` 和 `20260913_accounts.sql`。这两个已发布文件及历史 `import_sha256` 的 DDL 清单保持原样;不得将新文件加入旧数据导入选项,否则已迁移库会发生导入摘要冲突。 + +原 `user_account` 建表检查仅允许精确定义的 `avatar_version BIGINT NOT NULL DEFAULT 0` 扩展;未知额外列、错误类型/默认值、未知索引或约束仍被拒绝。原导入工具可以在升级后的结构上重新核验,不能因此重新导入用户或再次撤销登录。 diff --git a/docs/specs/account-center/design.md b/docs/specs/account-center/design.md new file mode 100644 index 0000000..c268e31 --- /dev/null +++ b/docs/specs/account-center/design.md @@ -0,0 +1,400 @@ +# Home Server 个人中心与账号治理 + +产品设计与技术方案 · 2026-09-18 · v1.1,个人中心及登录数量限制 + +基线:远端 `origin/master`,提交 [`8404303ab2c5cdde9a5cb6449db49d44c7f36730`](https://github.com/mcoder2014/home_server/commit/8404303ab2c5cdde9a5cb6449db49d44c7f36730),提交时间 2026-09-16 13:37:51 +08:00。实现分支为 `feat/cq/account_center`;验证使用 Pi 的独立服务和合成数据库。线上启用需按增量升级流程执行;原型使用虚构数据。 + +## 01 核心判断与范围 + +**【核心判断】✅ 值得做。** 已有账号体系具备昵称、改密、账号封禁及会话撤销基础;补全头像、可辨认的会话列表和管理员处置闭环,可以直接解决识别用户、回收泄露登录态及治理不当资料的问题。 + +**【关键洞察】** 数据结构以 `user_account` 为用户、`login_token` 为一次登录凭据。设备是会话的辅助描述,不是新身份。最大的兼容风险是把“退出指定会话”误做成全账号撤销,以及封禁/解封时改变现有权限回收规则。 + +**【Linus式方案】** 复用当前账号字段和认证表;按会话 ID 撤销选中记录;头像采用有上限的单张存储;保留现有改密、封禁和全量撤销语义。新增独立接口,不重写认证框架。 + +| 方案 | 成本与取舍 | 结论 | +|---|---|---| +| 扩展现有账号/会话模型 | 沿用 Cookie、数据库校验、版本控制和管理员审计;增量实现 | **采用** | +| 另建完整设备中心并指纹去重 | 浏览器无法可靠识别物理设备;增加合并、误判和隐私成本 | 不采用 | +| 改造为 JWT/刷新令牌体系 | 需要迁移客户端、撤销与多端刷新协议,与本次目标无直接收益 | 不采用 | + +首版范围包括个人资料、头像、密码、登录会话、全站身份展示、管理员会话数量及详情、封禁、资料重置,以及管理员可配置的同时登录上限,默认 5 个有效会话。保留现有联系方式、权限展示、邀请朋友和应用凭证入口。最近活动时间、登录历史、通知、二次验证和账号找回属于后续独立能力。数量异常提示人工核查,不自动封禁。 + +## 02 已确认现状与差距 + +| 能力 | master 已有实现 | 本方案新增或调整 | +|---|---|---| +| 个人中心 | `/account`、`/account/security`;昵称、邮箱、电话编辑;资料冲突保留草稿 | 增加头像和 `/account/sessions`;昵称文案统一 | +| 昵称展示 | `display_name` 已存在;页头与首页已优先显示昵称 | 补齐成员选择、网页所有者、邀请记录、评论及审计中的用户展示 | +| 头像 | 页头取名称首字,无上传能力 | 上传、裁剪预览、移除、异常回退、管理员重置 | +| 登录会话 | 摘要令牌、用户 ID、认证版本、用途、登录时间、期限、失效标记 | 记录登录 IP/UA/来源;只读列表、数量及按会话撤销 | +| 密码 | 验证当前密码;新密码策略;改密后提升 `auth_version`,停用应用凭据 | 保持语义,补清晰影响提示与客户端适配说明 | +| 管理员 | 用户列表/详情、封禁/解封、重置密码、全部退出、独立授权、审计 | 增加有效会话数、会话详情、昵称/头像重置 | +| 权限生效 | 每次认证读取会话与账号;GET 请求内有快照复用 | 继续以数据库为准,不加入跨请求会话缓存 | + +代码依据:[`Account.vue`](https://github.com/mcoder2014/home_server/blob/8404303ab2c5cdde9a5cb6449db49d44c7f36730/front_vue/src/views/Account.vue)、[`MyHeader.vue:29`](https://github.com/mcoder2014/home_server/blob/8404303ab2c5cdde9a5cb6449db49d44c7f36730/front_vue/src/components/MyHeader.vue#L29)、[`account.go:20`](https://github.com/mcoder2014/home_server/blob/8404303ab2c5cdde9a5cb6449db49d44c7f36730/domain/model/account.go#L20)、[`CheckSession:233`](https://github.com/mcoder2014/home_server/blob/8404303ab2c5cdde9a5cb6449db49d44c7f36730/domain/service/accounts/service.go#L233)。 + +运行边界:新能力要求 `identity_source=database`。现有配置用户模式保留兼容,新增编辑/会话管理入口不对它开放。代码基线已核对;生产数据库结构、用户规模、代理可信网段与正式功能配置尚未核查,实施前需只读预检。发布这份文档无需改变这些配置。 + +## 03 页面结构与使用路径 + +入口保持页头头像菜单中的“个人中心”。桌面以资料卡和主内容区呈现,手机用横向标签及竖排会话卡,主要按钮高度至少 44px。保留既有路由直达与浏览器返回行为。 + +| 页面 | 核心内容 | 主要操作 | +|---|---|---| +| 个人资料 `/account` | 头像、昵称、用户名、用户 ID、联系方式、创建/导入时间、独立功能权限 | 编辑昵称、保存资料、更换/移除头像 | +| 账户安全 `/account/security` | 改密表单、影响范围、当前会话摘要、应用凭证入口 | 修改密码;进入登录管理;退出全部登录 | +| 登录管理 `/account/sessions` | 当前登录、其他有效登录、数量、更新时间、展开详情 | 刷新、单条/批量退出、退出其他全部登录 | +| 管理中心 `/admin/users` | 用户身份、状态、有效会话数、数量提示 | 查看会话、封禁/解封、重置昵称或头像 | + +初始密码会话仍只允许个人信息读取、改密和退出;页面强制进入安全页,不能借新头像/会话接口扩大权限。应用 AK/SK 不获得个人中心或管理员能力。 + +状态必须可辨认:加载时展示骨架;查询失败保留旧数据并标注“刷新失败,数据截至 XX”;从未获得数据时显示“暂时无法获取”,不能显示成 0;空列表显示“暂无有效登录”;操作成功以服务端读回为准;网络结果不明先刷新,不盲目重试。 + +## 04 个人资料、头像与全站展示 + +### 昵称与联系方式 + +沿用 `display_name`,不另建 `nickname`。昵称去首尾空白后 0–64 个 Unicode 字符,空值表示使用登录用户名;非唯一,不能作为登录名、资源所有者标识或权限依据。前端当前不允许空昵称、后端允许空值,本次统一为允许清空,并显示用户名回退预览。登录用户名保持只读;联系方式仍不改变登录别名,也不提供密码找回。 + +提交仅包含用户实际修改的字段,沿用 `If-Match: revision`。管理员处置或另一标签页导致版本冲突时保留草稿、加载新资料,让用户选择重新编辑;不得自动覆盖新状态。头像变更单独保存,不顺带提交昵称草稿,成功后同步新 revision。 + +### 头像规则 + +| 项目 | 首版规则 | +|---|---| +| 输入 | JPEG/PNG,文件最多 2 MiB,宽高各不超过 4096、总像素不超过 1600 万;拒绝动画、SVG、外链头像 | +| 交互 | 选图后展示正方形裁剪与圆形预览,支持移动和缩放;取消不产生远端变更;明确点击“保存头像”才上传 | +| 服务端 | 独立检查大小、真实格式和像素上限;裁剪/缩放成 256×256 JPEG,透明区铺白,去除原图元信息;最终不超过 128 KiB | +| 保存 | 每用户只保留一张当前标准图;不保留原图与历史版本;上传失败保持原头像 | +| 移除/重置 | 删除当前头像内容,版本指针清零;昵称首个可显示字素作为占位,无可用字素时显示通用人形 | + +客户端须处理照片方向并预览实际结果;服务端仍把所有上传视为不可信,不能凭浏览器预处理跳过检查。头像解码并发最多 2 个、编码有超时/像素/输出大小边界,饱和返回可重试错误,避免图片解码占满家庭设备内存。图片类型白名单与重新编码依据 [OWASP 文件上传建议](https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html)。 + +### 全站身份规则 + +统一显示 `display_name 非空 ? display_name : user_name`,头像取 `avatar_url`,缺失或加载失败使用占位;用户 ID 始终作为稳定身份。管理列表、成员选择、同名用户场景同时显示 `@用户名 / ID`,角色徽标由服务端角色决定,不能靠昵称伪装管理员。 + +| 展示位置 | 改动范围 | +|---|---| +| 页头、首页、个人中心、管理用户列表/详情 | 当前昵称+头像;保存资料后立即更新当前 Vuex 状态 | +| 网页成员选择、管理网页所有者、邀请记录 | 返回最小用户展示对象,批量补齐资料;保留既有 `user_name` 等字段兼容旧客户端 | +| 托管容器及评论/回复 | 增加当前作者展示对象;人类作者展示当前昵称头像;应用作者保留应用名和“应用”标记,用户身份单独展示 | +| 管理审计 | 当前昵称便于识别,用户名/用户 ID/历史快照仍保留;不把历史快照改写为当前值 | + +新增可复用的 `UserIdentity` 前端展示组件,负责真实复用场景中的回退与同名辨认,不抽通用资料管理框架。跨标签页仅广播“资料已更新”事件并重新拉取 `/api/auth/me`,不广播凭据;标签页重新可见时也刷新。其他读者下次读取即获取新资料,不承诺已经渲染的离线页面立即更新。 + +评论现存 `author_name_snapshot` / `actor_name_snapshot` 保留为历史证据。读接口按本页用户 ID 批量附加当前资料,不逐条查询;管理员重置后普通评论视图不再回退到旧违规快照。账号已删除/资料不可读时显示“已注销用户”与默认头像,历史名称仅在有权查看的记录中呈现。依据 [`webcomments/service.go`](https://github.com/mcoder2014/home_server/blob/8404303ab2c5cdde9a5cb6449db49d44c7f36730/domain/service/webcomments/service.go#L331)。 + +实施契约:普通评论响应保留同名 `author_name_snapshot` / `actor_name_snapshot` 字段,其人类作者值投影为当前显示名,数据库原始历史不改写;应用作者的应用名称保持。读取、写入及幂等响应均遵守此规则。注销身份保留不可修改的稳定用户名以兼容旧字段,昵称显示“已注销用户”,头像为空;配置用户模式按配置中的真实用户名补齐身份。 + +## 05 登录管理:辨认、选择与退出 + +页面标题为“登录管理”,计数文案为“有效登录会话”。一条记录对应一份登录凭据:同设备不同浏览器/无痕窗口、重复登录可以产生多条记录;复制同一凭据到另一设备仍是同一条记录。不能可靠统计物理设备,也不能把“未过期”写成“正在在线”。 + +### 网站同时登录上限 + +管理员在“站点设置 → 账号策略”配置 `account_policy.max_active_sessions`,默认 5,允许 1–100 个有效网站会话。普通网站登录与旧 RSA 登录共享上限;受限改密会话也占用名额。应用 Token、WebDAV Basic 与同一 Token 的 Cookie 兑换不产生名额。 + +达到上限时拒绝新登录,保留已有会话;网站登录返回 429,提示从已登录设备退出其他登录或联系管理员。旧 RSA 入口保留原 HTTP 200 响应封装,通过错误码表达同一限制,不返回新 Token。退出或自然过期后可再次登录。调高上限对下一次签发生效;调低不主动退出已有会话,在数量降至新上限以下前拒绝继续签发。登录管理显示有效数量与网站上限。 + +签发先锁同一用户行,再以当前锁定读读取最多 `max_active_sessions` 条有效会话,足额即拒绝,不生成凭据、不修改最近登录时间。不得使用旧事务快照判断名额;并发网站/旧入口登录不能超过上限。 + +| 字段 | 来源与展示规则 | +|---|---| +| 当前登录 | 由本次请求真正通过认证的会话 ID 比较,前端不传当前 ID 自证身份 | +| 客户端/系统/类型 | 对登录时 UA 做尽力解析,例如 Chrome、macOS、桌面;版本缺失就省略,无法识别显示“未知” | +| 登录 IP | 复用 `TrustedClientIP`;支持 IPv4/IPv6;标明“登录时 IP”,不声称当前位置或当前 IP | +| 登录时间 | 优先 `authenticated_at`;存量仅有合法 `create_time` 时标明“会话创建时间”;缺失显示未知 | +| 到期时间/剩余时间 | 由服务端 `server_time` 和有效到期时间计算;显示绝对时间及“剩余 2 天 3 小时”,不逐秒轮询 | +| 登录来源/用途 | 网站登录、旧版客户端、历史来源未知;普通会话或仅改密的受限会话 | +| UA 原文 | 展开详情后展示,纯文本且最多 1024 字节;只供本人和管理员查阅,不作为授权依据 | + +UA 可能被伪造或浏览器缩减,不保证精确 OS 版本、物理型号或设备名称。首版不采集指纹、MAC 地址、硬件序列号和精准地理位置。依据 [MDN UA 缩减说明](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/User-agent_reduction)。 + +当前会话卡单独固定展示;其他会话按 `create_time DESC, id DESC` 分页,每页 20、上限 100,游标包含这两个字段。不能把现有会话 ID 当成时间排序依据。日期统一标明 UTC+8,与现有界面一致;倒计时使用服务端时间偏移修正本机时钟,归零后刷新确认。 + +| 动作 | 产品行为 | 服务端影响 | +|---|---|---| +| 退出某一条/选中多条 | 二次确认,展示数量与设备描述;批量最多 100 条,当前会话不可勾选 | 只将指定本人会话 `is_expired=1`;不改 `auth_version`、密码或应用状态 | +| 退出其他全部登录 | 明示包括列表下一页;保留当前登录 | 在用户锁内撤销当时全部其他有效会话;提交后新建立的会话不属于此次操作 | +| 退出当前登录 | 当前卡独立按钮,成功后回登录页 | 复用现有 `/api/auth/logout` 并清 Cookie | +| 退出全部登录 | 放在安全页的高影响操作区,包含当前设备 | 复用原 `/api/auth/logout-all`,保留全会话失效及应用凭据停用行为 | + +成功退出后,下次认证请求即拒绝该会话;已完成或已经通过认证的在途请求不能追溯撤回,长连接和已下载内容不会凭空消失。页面在收到 401 时清理会话信息并引导登录;保持匿名可访问页面的现有行为。后台不靠删除客户端 Cookie 实现撤销。产品依据 [OWASP 会话管理建议](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html)。 + +## 06 密码与管理员治理 + +### 密码兼容规则 + +修改密码继续要求当前密码、新密码、重复输入。最低长度读 `/api/auth/me.password_policy.min_length`,不得硬编码成更弱策略;当前代码下限 15 个 Unicode 字符,上限 72 个 UTF-8 字节,允许空格和粘贴,拒绝空字符、两次不一致以及新旧相同。忘记密码仍联系管理员,不把未验证邮箱当作找回凭据。 + +确认文案明确写:“修改后包括当前设备在内的全部网站登录失效,应用凭据停用,WebDAV 客户端需要更新密码。”管理员重置密码生成初始密码的既有流程保持不变,不能将“重置头像/昵称”混入密码重置。 + +### 管理员操作闭环 + +列表新增“有效登录”列,显示总数;受限账号例如显示“3 个,仅可改密”。现有签发和改密流程不会让同一认证版本下的普通会话与改密会话同时有效,界面不得编造混合示例。首版 **达到 10 条**显示“数量偏多,建议核查”,10 是设计默认提示阈值,不是已验证风险阈值。提示不阻止登录,也不触发自动封禁。首版继续使用原用户名/状态过滤和用户 ID 分页,不增加“按会话数全站排序/过滤”,避免先分页后过滤产生遗漏。 + +点击数量进入用户详情的“登录会话”标签,使用相同有效性判断和分页规则,显示用户名/ID防止误处置。管理员可以封禁、执行已有的退出全部登录,或重置资料。首版不新增管理员逐条踢会话接口,批量治理复用现有全量撤销。 + +| 管理动作 | 必填与保护 | 结果 | +|---|---|---| +| 封禁用户 | 管理员当前密码、原因、目标 revision;禁止自封禁;保留至少一名可用管理员 | 沿用已有封禁事务和审计;本方案不自动判定封禁 | +| 解封用户 | 管理员密码、原因、revision | 只恢复账号状态;不恢复旧会话、应用、邀请码和原业务授权 | +| 重置昵称 | 同上;确认前展示重置后的用户名回退效果 | `display_name=''`;用户名、邮箱、电话、密码不变 | +| 重置头像 | 同上;展示重置前后预览 | 清空头像指针并删除当前图片;不撤销登录 | +| 同时重置昵称和头像 | 在同一确认框勾选两个字段,至少选一个 | 一个事务提交、一次审计;任何失败都不部分成功 | + +重置后用户仍可重新设置头像或昵称;反复违规由管理员封禁处理,首版不添加资料禁改期限。管理员理由与前后摘要写入现有审计;图片仅记录版本、字节数等元数据,不把原图、密码或令牌放进审计。 + +现有封禁实际影响必须在确认框完整说明:网站会话失效、应用凭据停用、藏书权限与 WebDAV 权限回收、相关应用 scope 移除、未使用邀请撤销;所属托管页面的读取还受 owner 状态控制。封禁不等于删除数据。解封不自动恢复这些被回收的能力。依据 [`adminTransition:309`](https://github.com/mcoder2014/home_server/blob/8404303ab2c5cdde9a5cb6449db49d44c7f36730/domain/service/accounts/mutations.go#L309)。 + +## 07 认证影响矩阵与有效性口径 + +| 操作 | 当前会话 | 其他网站/旧客户端会话 | 应用凭据/应用访问令牌 | WebDAV Basic | +|---|---|---|---|---| +| 本人编辑或管理员重置资料 | 保持 | 保持 | 保持 | 保持 | +| 退出选中/其他会话 | 保持 | 仅选中范围失效 | 保持 | 保持 | +| 退出当前会话 | 失效 | 保持 | 保持 | 保持 | +| 现有退出全部 | 失效 | 全部失效 | 凭据停用,现有 Token 受应用状态校验拒绝 | 原密码仍可用 | +| 本人改密 | 失效 | 全部失效 | 同上 | 需改用新密码 | +| 管理员封禁 | 目标全部失效 | 目标全部失效 | 停用并按原规则回收 scope | 拒绝访问 | +| 解封 | 旧会话不复活 | 须重新登录 | 须核查后重新启用/授权 | 须另行恢复权限 | + +应用 Token、WebDAV Basic 没有与网站登录表一一对应的设备记录,不混入设备数量。详情提供现有“应用凭证”和 WebDAV 权限入口说明范围。Basic 为逐请求认证,现有文档已明确它不依赖网站会话。依据 [`accounts-migration.md`](https://github.com/mcoder2014/home_server/blob/8404303ab2c5cdde9a5cb6449db49d44c7f36730/docs/accounts-migration.md)。 + +**有效登录 = 当前服务端仍接受的用户会话**,不能只写 `expire_time > now`。列表、数量、撤销选择集和鉴权使用相同条件: + +```text +共同条件:用户存在且 status=active;token_digest 非空;is_expired=0; + session.auth_version=user.auth_version;expire_time > 同一个 server_time。 +普通登录:共同条件 AND purpose=user AND must_change_password=false。 +改密登录:共同条件 AND purpose=password_change AND password_expires_at > server_time。 +总数 = 普通登录数 + 改密登录数。 +有效到期时间:普通登录为 expire_time;改密登录取 expire_time 与 password_expires_at 较早者。 +``` + +封禁/删除用户、旧认证版本、已退出、未知 purpose、已过期初始密码均不计入。存量缺 UA/IP不影响有效性。列表、当前卡和数量在同一只读事务快照中查询,复用一次服务端时间;下一次刷新可能因真实并发登录变化,不宣称跨请求强一致。 + +## 08 技术结构与数据模型 + +```mermaid +flowchart LR + A["个人中心与管理中心"] --> B["现有 RequireAccount / BrowserWrite"] + B --> C["accounts 服务:资料、会话、管理动作"] + C --> D["user_account:身份、revision、auth_version"] + C --> E["login_token:有效会话与设备描述"] + C --> F["user_avatar:单张标准头像"] + C --> G["admin_audit_log:管理处置"] + D --> H["最小用户展示对象"] + H --> I["页头、成员、评论、管理列表"] +``` + +### 头像存储选择 + +首版采用 **数据库独立头像表+账号上的版本指针**。家庭/少量好友规模,每人最多 128 KiB,1,000 个用户的图像净数据上限约 125 MiB;头像替换、管理员重置、资料 revision 和审计可在一个数据库事务完成,不引入磁盘孤儿文件、清理队列和新的文件路径配置。图像字节不得进入常规账号查询或列表 DTO。 + +本地磁盘方案可以降低大规模数据库体积,但需额外处理数据库/文件一致性、旧 URL 授权及孤儿清理;当前已有网页文件目录按项目/版本组织,不适合直接借来放用户头像。若未来实测头像体积或读取负载超过数据库预算,再迁到独立媒体存储,保持 `avatar_url` 契约即可。 + +| 对象 | 新增字段 | 约束 | +|---|---|---| +| `user_account` | `avatar_version BIGINT NOT NULL DEFAULT 0` | 0 表示默认头像;有图时取本次更新后的用户 revision,避免删除后重复版本 | +| 新表 `user_avatar` | `user_id BIGINT PK`、`version BIGINT`、`content_type VARCHAR(32)`、`content_blob MEDIUMBLOB`、`byte_size INT UNSIGNED`、`update_time DATETIME(6)` | 每用户一行,内容只允许标准 JPEG;业务强制 ≤131072 字节;版本和账号指针同事务更新 | +| `login_token` | `login_ip VARCHAR(45)`、`user_agent VARCHAR(1024)`、`client_name VARCHAR(128)`、`os_name VARCHAR(128)`、`device_type VARCHAR(16)`、`login_source VARCHAR(24)` | 均允许为空/默认空;来源取固定枚举;UA限制按 UTF-8 安全截断 | +| 会话索引 | `(user_id,auth_version,is_expired,create_time,id)` 与 `(user_id,auth_version,is_expired,expire_time,create_time,id)` | 分页排序及有效期范围查询;固定当前认证版本,跳过自然过期及旧认证版本历史 | +| 清理索引 | `(expire_time,id)` | 自然到期后 30 天按批清空元信息;保留既有索引 | + +不另存“剩余时间”“有效数量”或可写 `avatar_url`,避免派生状态漂移。头像 URL 根据用户 ID和头像版本生成。沿用已有会话 `id`、`authenticated_at`、`purpose`、`expire_time` 和 `is_expired`,不引入并行 session 表。新视图 DTO必须显式挑字段,禁止序列化令牌原文/摘要或密码哈希。 + +`last_seen_at` 和 `last_ip` 留在第二阶段:确需展示时再实现每会话最多 5 分钟一次的条件更新,并标注采样时间。首版没有实时心跳,不用最近登录时间冒充最近活动时间。 + +### 头像读取与缓存 + +新增 `GET /api/account/avatars/:user_id/:version`。它是站内用户展示资源,仅有效普通用户会话可读;头像是所有已登录用户可见的基础资料,不携带邮箱、电话或会话信息。匿名、受限改密会话和应用 Bearer 不可用;改密页显示占位头像。目标封禁/删除、无头像、版本不匹配统一返回 404,前端回退。 + +服务端每次先校验当前头像指针,再取匹配图像,返回 `image/jpeg`、`X-Content-Type-Options: nosniff`、`Cache-Control: private, no-store`。旧版本 URL 在重置提交后的新请求不能继续读到图片;已下载字节无法召回。账号响应增加只读 `avatar_url`,不向客户端暴露 BLOB,不使用 base64嵌入每份列表响应。 + +## 09 接口契约 + +下表为接口契约。沿用当前 `{code, message, data}` 响应封装、Cookie/兼容用户 Token 认证、HTTPS、精确 Origin、CSRF及错误码体系。所有 ID输出为十进制字符串;账号相关 JSON响应统一 `Cache-Control: no-store`。`GET /api/auth/me` 追加 `session_policy.max_active_sessions`;会话列表追加 `max_active_sessions`,均来自当前运行配置。 + +| 方法与路径 | 入参/返回 | 权限与并发 | +|---|---|---| +| `GET /api/auth/me`(沿用) | 原字段+`avatar_url`、`avatar_version` | 保留受限改密会话读取能力 | +| `PATCH /api/account/profile`(沿用) | 修改字段;返回最新资料和 revision | 本人普通会话+`If-Match` | +| `POST /api/account/avatar` | multipart `file`,仅一个标准裁剪结果;返回更新后的本人资料 | 普通用户+BrowserWrite+`If-Match` | +| `DELETE /api/account/avatar` | 无正文;返回更新后的资料 | 同上;无头像且版本匹配时成功且不重复递增 | +| `GET /api/account/avatars/:user_id/:version` | JPEG图像 | 按第08节的身份及版本检查 | +| `GET /api/account/sessions` | `cursor`、`limit`;`current_session`、其他 `items`、`total_count`、`restricted_count`、`server_time`、`has_more`、`next_cursor` | 本人普通会话;总数包含当前会话,items不重复当前 | +| `POST /api/account/sessions/revoke` | `session_ids: string[]`;返回 `revoked_count`、`already_inactive_count`、`server_time` | 本人;去重后 1–100 条;包含当前 ID为400;未知或他人 ID统一404且全部不执行 | +| `POST /api/account/sessions/revoke-others` | 无正文;返回本次撤销数量及 `server_time` | 本人;由后端取得当前会话,不能传 `keep_session_id` | +| `POST /api/auth/logout`、`logout-all`、`change-password`(沿用) | 原契约 | 不改变既有客户端语义 | +| `GET /api/admin/users`(沿用) | 每行追加 `active_session_count`、`restricted_session_count`;页面级 `server_time`、`session_warning_threshold=10` | 管理员;在当前页用户集合上批量统计 | +| `GET /api/admin/users/:id/sessions` | 游标列表、有效总数、受限数、`server_time`;items包含该用户全部会话 | 管理员;不泄露 Token/摘要;不存在账号404 | +| `POST /api/admin/users/:id/reset-profile` | `reset_display_name`、`reset_avatar`、`reason`、`current_password`;返回新用户资料 | 两项至少一项true;`If-Match`;管理员密码预算与事务内复核 | +| 管理员 `ban`/`unban`/`logout-all`(沿用) | 原请求、revision、理由和密码 | 继续走 `AdminChange` 与审计 | + +会话 DTO示例只展示业务数据;这不是可以用于认证的凭据: + +```json +{ + "id": "20301", + "is_current": false, + "purpose": "user", + "login_source": "web", + "client_name": "Chrome 140", + "os_name": "macOS", + "device_type": "desktop", + "login_ip": "198.51.100.24", + "user_agent": "Mozilla/5.0 (...) Chrome/140.0.0.0 Safari/537.36", + "authenticated_at": "2026-09-17T09:20:00+08:00", + "create_time": "2026-09-17T09:20:00+08:00", + "expire_time": "2026-09-24T09:20:00+08:00", + "effective_expire_time": "2026-09-24T09:20:00+08:00" +} +``` + +`avatar_url`、`current_session` 和资料版本由服务端决定。`session_ids`只能选择本人既有记录,不接受 `user_id`指定操作者。新接口不把认证版本、token_digest、密码哈希或联系人加入公开用户展示对象。 + +| 失败 | 前端行为 | +|---|---| +| 400参数不合法/文件格式错误 | 定位字段或文件错误;不丢失未提交资料 | +| 401会话失效 | 清理当前身份并转登录;不自动重放修改 | +| 403权限/CSRF/模式不支持 | 明确刷新或无权提示,不误写为密码错误 | +| 404目标不存在或不可见 | 刷新列表;不区分他人会话是否存在 | +| 409资料版本冲突 | 保留草稿,对照最新值后人工重新提交 | +| 413文件/请求过大,429频率/并发受限 | 明确限额/稍后重试;请求器须兼容代理返回的非JSON错误 | +| 5xx/连接中断 | 结果可能已提交,先读回状态;撤销可对同一已选 ID集合幂等重试,管理员动作及头像不自动重试 | + +## 10 核心流程、并发与查询 + +### 登录时记录元信息 + +请求入口收集 `TrustedClientIP(request)` 和有界 `User-Agent`,解析后形成一个 `SessionMetadata` 参数,显式传给账号登录和 `issueSessionTx`,与会话同事务插入。UA 解析使用 [`github.com/mileusna/useragent`](https://github.com/mileusna/useragent) v1.3.5 的 `Parse`,提供浏览器、系统和类型;构建验证使用 Go 1.21.12。解析失败写“未知”且允许合法登录。 + +必须覆盖两个签发路径:`api/accounts/handlers.go.login → accounts.Login → issueSessionTx`,以及旧 `/passport/login → passport.GenToken → accounts.IssueVerifiedSession → issueSessionTx`。当前 `GenToken` 使用 `context.Background()`,会丢失请求上下文;将内部签名明确加入 ctx和 metadata并更新其两个生产调用点,外部 HTTP协议保持不变。配置用户模式按原流程运行,元信息允许为空。 + +`/api/auth/browser-login` 与两个网页托管别名只是把同一用户 Token转为 Cookie,**不创建第二条会话、不重置登录时间/期限、不把兑换时的设备覆盖为原登录设备**。复制 Token的场景保留其同一会话本质。依据 [`passport/token.go:21`](https://github.com/mcoder2014/home_server/blob/8404303ab2c5cdde9a5cb6449db49d44c7f36730/domain/service/passport/token.go#L21) 和 [`api/auth/route.go`](https://github.com/mcoder2014/home_server/blob/8404303ab2c5cdde9a5cb6449db49d44c7f36730/api/auth/route.go)。 + +### 撤销与重新登录的竞争 + +```mermaid +sequenceDiagram + participant U as 用户页面 + participant A as 账号接口 + participant D as 数据库 + participant R as 被退出会话 + U->>A: 选择会话并确认退出 + A->>D: 锁用户行,复核当前会话及归属 + A->>D: 原子标记选中会话 is_expired=1 + D-->>A: 提交成功 + A-->>U: 返回撤销数量 + U->>A: 刷新有效会话列表 + R->>A: 下一次携带旧凭据请求 + A->>D: CheckSession读取失效状态 + A-->>R: 认证失败 +``` + +`RequireAccount`将已验证会话 ID放入服务端上下文,不能由客户端传值覆盖。新增撤销操作先锁账号行,再按 ID升序锁会话;事务内复核操作者绑定的当前会话仍有效,核对全部目标归属后统一更新。已退出/已过期的本人记录计入 `already_inactive_count`,不存在或他人 ID使整个批次返回404。只更新目标标记,不递增账号认证版本。 + +“退出其他全部”与签发会话都锁同一用户行,线性化时点定义为取得用户锁并校验后:已存在的其他有效会话全部退出,提交之后的新登录保留。故操作成功仍可能在刷新时看到新会话;不能因此重复清空新登录。既有单条退出不改为物理删除,复用其可重入失效标记。当前会话被选中则返回参数错误,前端引导独立“退出当前登录”。 + +### 资料更新与管理员重置 + +头像先在事务外完成限流、受限解码与标准化,再短事务锁账号、复核身份与 revision;图片行、`avatar_version`、账号 `revision`一起提交。管理员重置复用 `AdminChange`的全局管理锁、双方账号固定顺序锁、密码快照复核与 revision检查,新增 `reset-profile`动作分支。头像行在账号锁之后处理,避免反向锁序。 + +重置只改变选中的资料字段及 revision,不提升 `auth_version`,不改变密码与授权。为新动作生成专用前后摘要,现有 `accountSummary`目前不记录昵称/头像,不能照搬后声称已审计。审计写失败则回滚处置;新摘要只包括重置字段的文字值/图片版本与大小。 + +### 数量和分页查询 + +用户列表先按现有规则获取一页最多 100 个用户,再把同一快照中的用户 ID/认证版本组成 OR 条件,执行**一次**会话聚合;仍与账号连接核对版本、状态及第07节用途规则,再回填零值。单用户详情的账号与数量也共用只读事务快照。仅在查询成功且没有记录时填 0,数据库失败返回错误。 + +列表只读取展示字段,按时间与 ID复合游标推进,不使用深 OFFSET。索引选择必须在独立合成 MariaDB 10.11数据上验证,并核对生产版本:分别对单用户列表、当前会话、100个用户的聚合跑 `EXPLAIN`;模拟10、100、10,000条历史会话,观察扫描行数与排序。当前源码的数据库迁移目标为 MariaDB 10.11,但不能据此假设生产结构已一致。 + +单用户查询固定快照中的认证版本,按 `expire_time > server_time` 查询范围,不在时间字段上做函数计算。有效总数不超过 100 时,列表通过 `USE INDEX (idx_login_token_effective_range)` 选择有效期范围索引,仅对有效候选排序;100 是当前允许配置的最大同时登录数。更多有效会话的存量账号保留自然索引选择,利用分页索引的 LIMIT 提前结束,数量查询始终返回真实总数,不截断成网站上限。 + +有效会话相关读取始终直查数据库,允许同一请求内共享快照,不接入Redis跨请求缓存。已有Redis用于内容缓存与访问统计,不负责会话撤销传播。依据 [`read_snapshot.go`](https://github.com/mcoder2014/home_server/blob/8404303ab2c5cdde9a5cb6449db49d44c7f36730/domain/service/accounts/read_snapshot.go) 和 [`queries.go:75`](https://github.com/mcoder2014/home_server/blob/8404303ab2c5cdde9a5cb6449db49d44c7f36730/domain/service/accounts/queries.go#L75)。 + +## 11 权限、代理与数据生命周期 + +**身份与防越权。** 新账号接口继续使用 `RequireAccount`,不复用可接受应用 Bearer的通用接口守卫。普通用户只能列出和撤销自己会话;管理员通过专门读接口查看他人;管理员重置必须验证其当前密码及角色,不把隐藏按钮当授权。个人会话 API不接受 WebDAV Basic。敏感写操作沿用 HTTPS+Origin+CSRF,不能取消上传场景的CSRF。 + +**代理与请求边界。** 现有 `/api/account/`在 [`api_auth_locations.conf:53`](https://github.com/mcoder2014/home_server/blob/8404303ab2c5cdde9a5cb6449db49d44c7f36730/config/nginx/api_auth_locations.conf#L53) 只允许16 KiB。仅为精确路径 `/api/account/avatar`增加独立 location,代理限额3 MiB,后端整个multipart请求限额 `2 MiB + 64 KiB`、文件仍限2 MiB;其他账号接口继续16 KiB。保持proxy_cache关闭及可信IP/HTTPS头配置。所有内外入口均需检查,不能只改后端上限。 + +**IP可信度。** 复用 [`TrustedClientIP:29`](https://github.com/mcoder2014/home_server/blob/8404303ab2c5cdde9a5cb6449db49d44c7f36730/api/middleware/account.go#L29),只信任配置网段的代理转发链,不直接读取客户端传入的 X-Forwarded-For。代理配置错误时宁可展示代理地址/未知,不推测用户公网地址。 + +**资料和隐私。** 昵称、UA、管理员理由都以纯文本输出;头像拒绝任意URL,避免SSRF和外链跟踪。IP/UA仅本人和管理员读取,不进入首页、评论、公开身份对象或访问统计。普通业务日志仅记录动作、操作者ID、目标会话ID、数量、结果和请求追踪ID,不记录原始令牌、密码、完整Cookie及原始UA。 + +首版保存会话元信息至该会话自然到期后30天,随后清空新增IP/UA/解析描述字段;不为本次功能删除旧认证表记录或管理审计。增加受限的幂等 `accounts-maintain`维护命令及systemd每日timer,按 `(expire_time,id)` 分批最多500行擦除,每批提交;补清理索引前先EXPLAIN。原会话撤销行为不依赖timer。维护任务连续失败超过48小时告警。已删除用户的当前头像在删除用户事务中一并清除;封禁保留原图但读取路由拒绝,重置则实际删除。 + +**同源托管边界。** 仓库现有说明明确接受上传HTML与主站同源的风险。现有CSRF不能防御同源恶意脚本;这份方案不把它描述成已经隔离。头像只允许重新编码的图片,避免新增可执行上传类型。站点内容隔离、Passkey或独立二次确认令牌属于另一项安全演进,不通过本次头像功能顺带改造。 + +## 12 迁移、发布与回滚设计 + +迁移采用新文件,例如 `domain/dal/migrations/20260917_account_center.sql`,只增加字段/索引及头像表,**不得修改已发布的20260913迁移内容**。当前账号导入工具有严格结构验证,新字段需要检查其结构校验是否接受扩展:为新安装补齐新迁移阶段,不让旧导入工具误判,也不重新导入账号。 + +| 阶段 | 执行动作 | 放行条件 | +|---|---|---| +| 预检 | 核对生产数据库账号模式、MariaDB版本、表结构、用户及会话规模、所有入口代理;备份并做独立恢复演练 | 明确当前结构与可回滚版本;测试环境只使用合成数据 | +| 增量DDL | 加可空/默认空元信息、默认0头像指针及独立表;验证结构和索引 | 旧代码可忽略新增结构;按MariaDB DDL隐式提交处理阶段恢复 | +| 后端 | 部署兼容新增契约的版本,接通两个登录路径和旧Token兑换路径;保留现有路由 | 新旧会话均可正常认证;数量口径与鉴权一致 | +| 代理与前端 | 配置精确头像入口,部署新增标签/展示组件/管理员能力 | 受限账号不出现可用的越权入口;图片上传经过所有真实入口 | +| 验收 | 用专用账号与会话逐项测试,读回审计和状态 | 第14节核心用例通过后再开放功能 | + +存量资料不强制重置:保留 `display_name`;`avatar_version=0`回退默认图;旧会话未知元信息显示“历史登录,设备信息未记录”,不根据当前访问UA补写成原登录设备,也不为了补齐数据强制所有人下线。期限不变,不引入滑动续期。 + +回滚优先撤回新前端,再退到兼容数据库账号模式的后端,保留新增字段/表。按会话退出使用旧代码已识别的 `is_expired`,回退不会使它复活;改密和封禁后的 `auth_version`也不能回退。恢复旧数据库备份可能复活凭据,不能作为常规代码回滚。旧版本前端可能继续展示评论历史名称快照,因此若已处置违规昵称,回滚需要保持新身份展示投影或关闭受影响展示,不能声称治理效果天然兼容旧界面。 + +旧 `account_policy` JSON 缺少新增上限时,先验证原始 SHA256,再在读取副本补默认 5;不写回 JSON、revision 或历史。新发布要求完整且合法的字段。V1 导入文档和摘要继续只包含原账号策略四个字段,避免新增默认值后误判旧导入源变化。回滚到不识别新增字段的后端前,须准备与旧注册表匹配的有效策略文档,不能直接把新 JSON 交给旧版本启动。 + +数据库 DDL 有锁表风险。演练与构建仅在隔离环境执行,正式数据库规模及维护窗口须上线预检。 + +## 13 实施拆分与改动落点 + +| 批次 | 修改位置 | 可独立验收的结果 | +|---|---|---| +| A:会话识别与读取 | `domain/model/account.go`、`domain/dal/account.go`、新增迁移;`accounts/service.go`、`passport/token.go`、两个登录handler;新增 `accounts/sessions.go` | 新登录有元信息;历史会话可读;计数/受限会话口径一致 | +| B:会话退出与管理页 | `api/middleware/account.go`、`api/accounts/routes.go`及小型会话handler;`accounts/queries.go`;`Account.vue`、`AdminUsers.vue`、router和accounts API | 单条/批量退出、保留当前退出其他、管理员数量和详情可用 | +| C:头像与全站展示 | 新 `accounts/avatars.go`及图片handler;显式DTO投影、用户展示组件;页头、首页、网页成员/所有者、邀请、评论容器、审计视图 | 更换/移除头像;昵称和头像在列出的真实使用点一致 | +| D:资料治理与交付 | `accounts/mutations.go`的 `reset-profile`分支与审计摘要;`AdminConfirm`、`AdminUsers`;精确Nginx入口、维护命令/timer及运维文档 | 管理员重置原子化;限额、清理与回滚完成演练 | + +这些是同一首版的开发批次,不把用户要求拆成未交付的可选项。遵循现有Gin/GORM、Vue3/Vuex/Element Plus模式,不迁移状态管理、不改项目目录结构、不改公开错误码、不改Cookie名称。只有存在实际多个调用点的身份显示规则才做小范围复用。 + +后续可独立评估:最近活动采样、登录历史与异常通知、Passkey/MFA、经验证的找回渠道、可配置会话提醒阈值。没有真实数据支持前,不实现风险评分或设备指纹合并。 + +## 14 验收标准与测试重点 + +以下是验收用例,实际验证结果单独记录;测试以用户可见行为、授权结果和副作用为准。 + +| 组 | 必测场景 | 通过标准 | +|---|---|---| +| 资料 | 中文/Emoji/64字符/空昵称、修改联系方式、旧revision提交、头像保存与昵称草稿并存 | 字符规则一致;用户名回退;不改登录别名;冲突不丢草稿、不覆盖新资料 | +| 头像 | 正常JPEG/PNG、透明图片、超大小/像素、伪造MIME、SVG/动画、解码失败、并发上传与管理员重置 | 服务端独立拒绝非法图;失败保留原图;一次仅有一张当前图;旧URL无法再次读取 | +| 全站展示 | 页头/成员/邀请/网页所有者/评论/审计、同名、空昵称、缺图、注销、应用作者 | 当前身份规则统一;旧字段仍可消费;历史快照保留但不泄露已重置的违规显示 | +| 登录列表 | 两浏览器、同浏览器多标签、重复登录、IPv6、代理伪造、UA缩减/未知、旧会话 | 准确定位当前会话;不按IP或UA错误去重;未知信息不伪造 | +| 有效性 | 刚过期、`is_expired=1`、旧auth_version、封禁/删除、受限会话、临时密码过期、未知purpose | 数量与可认证结果一致;受限会话单列;错误不展示成0 | +| 登录上限 | 默认第 6 次、12/32 并发、旧 RSA、旧事务快照、过期释放、调高/调低、旧配置与回滚 | 仅有效会话占名额;无超发;满额保留原会话;旧配置与导入摘要不改写 | +| 选择退出 | 1条、多条、全部其他、当前ID、重复ID、他人ID混入、已失效ID、跨页 | 只退出目标;越权整批拒绝;当前会话和应用凭据保持;其他全部覆盖未加载页 | +| 并发 | 登录与退出其他竞争、两次撤销、会话被撤销后发起资料写入、封禁与登录竞争 | 按已定义锁序和提交时点生效;不得在封禁后签发可用会话;新接口事务内复核当前会话 | +| 改密兼容 | 普通/初始密码改密、15字符与72字节边界、旧密码错误、相同密码、应用停用、Basic | 原有HTTP语义和失败预算不变;网站旧会话失效;Basic按新密码验证 | +| 管理员 | 普通用户调用、应用Bearer、自封禁、最后管理员、密码错误、revision冲突、审计失败 | 服务端阻断;事务失败无部分重置;封禁/解封副作用与矩阵一致 | +| 查询性能 | 每页100用户、10,000历史会话、头像列表不读BLOB、元信息清理 | 无N+1、无深OFFSET;EXPLAIN和压测记录扫描行数/延迟;查询出错不放行 | +| 浏览器与入口 | 桌面/390px手机/320px窄屏、键盘与焦点、内外代理、413非JSON、断网及401 | 主要操作可完成;无页面级横向溢出;确认框说明准确;不泄露凭据 | +| 回滚 | 新增字段存在时运行旧兼容版、已撤销会话、已重置头像、元信息空值 | 无需DROP结构;凭据不复活;界面降级符合第12节限制 | + +验证执行环境:Go 构建/测试在获准 Pi 或隔离 CI 运行,不在本地直接 `go test`;仓库 CI 与测试夹具使用合成账号和独立数据库。前端使用 `npm run test:frontend` 和 `npm run build`,实际浏览器连接 Pi 独立后端及 HTTPS 代理。 + +## 15 关键决策与实施前核查 + +| 已选定的设计决策 | 原因 | +|---|---| +| 以会话计数,不以物理设备计数 | 可从现有服务端数据准确判断;不误导风险判断 | +| 默认 5 个有效登录,管理员可调整;满额拒绝新登录 | 限制凭据数量,保留已有登录;并发签发在用户锁内校验 | +| 首版头像入独立DB表,最多128 KiB/用户 | 小规模场景下原子重置与运维成本更低;正常账号查询不读图像 | +| 数量达到10仅提示;人工封禁 | 缺乏真实基线,不能以数量代替风险证据 | +| 指定/其他会话退出不影响应用;原全量退出保留行为 | 满足细粒度控制,同时避免破坏现有契约 | +| 当前昵称头像用于展示,历史快照用于有权审计 | 重置能影响当前展示,历史证据不会被篡改 | + +实施前待核查项限定为环境证据:生产是否已迁入数据库账号模式;现有索引与数据库版本;所有代理入口的上传上限与可信网段;用户/会话规模及家庭设备资源;UA与图片处理依赖的Go版本兼容性。没有这些证据不能承诺具体性能指标或无维护窗口DDL。 + +基线源码证据固定到提交;新增接口、字段和策略以实现分支为准,不能当作现网配置。交互原型仅演示规则,不发送账号、密码、头像或会话管理请求。 diff --git a/docs/specs/account-center/implementation.md b/docs/specs/account-center/implementation.md new file mode 100644 index 0000000..25a5af8 --- /dev/null +++ b/docs/specs/account-center/implementation.md @@ -0,0 +1,23 @@ +# 个人中心实施与验证计划 + +**目标**:落实个人资料、头像、有效登录管理及管理员资料重置;保持现有认证和授权兼容;在 Pi 的隔离环境验证并提交 PR。 + +**架构**:扩展现有 accounts 服务和 login_token,不引入第二套认证。头像使用独立 user_avatar 表;账号 revision 与头像版本在同一事务更新。有效登录列表与统计共用有效性条件和服务端时间。 + +**技术栈**:Go / Gin / GORM / MariaDB,Vue 3 / Element Plus,现有 Cookie、Origin、CSRF 和审计。 + +基线:远端 master `8404303ab2c5cdde9a5cb6449db49d44c7f36730`。详细产品方案见 design.md。 + +| 步骤 | 文件与内容 | 验证 | +|---|---|---| +| 1 | 新增 20260917_account_center.sql;扩展 domain/model/account.go、domain/dal/account.go;兼容 accountsmigrate 的严格 DDL 校验 | Pi 基线测试、新旧迁移、重复应用 | +| 2 | domain/service/accounts/sessions.go、service.go、queries.go;api/accounts/sessions.go;登录入口和账号中间件携带元数据与当前会话 ID;动态配置默认同时登录上限 5 | 有效性边界、跨用户撤销、分页、批量统计、并发签发/撤销、旧配置及导入摘要兼容 | +| 3 | domain/service/accounts/avatars.go;api/accounts/avatars.go;mutations.go 的 reset-profile 和删除清理 | 真图像解码、字节和像素限制、版本冲突、权限、旧 URL 失效、审计原子性 | +| 4 | 最小用户展示对象;项目成员、邀请、审计、评论补充当前昵称和头像 | 批量查询、管理员重置后展示更新、不返回头像字节或密钥 | +| 5 | Account.vue、AdminUsers.vue、共享身份和会话组件;accounts API;网页容器展示 | 资料草稿冲突、头像裁剪、退出选择、管理员处置、窄屏浏览器验证 | +| 6 | nginx 精确头像路由;accounts-maintain 元数据清理;操作文档 | Pi nginx 配置检查、过期清理幂等、EXPLAIN | +| 7 | Pi 独立目录和 synthetic MariaDB:远端 Go 构建、全量测试、前端构建及实际 HTTP/浏览器验证 | 不使用生产数据库、不替换在线服务;证据记录后 commit、push、PR | + +边界:账号配置模式保持旧登录协议;数据库专属新接口明确拒绝。初始改密会话不能访问个人中心和头像。WebDAV Basic 的逐请求认证保持原行为。普通撤销不影响应用凭证。 + +验证命令:Pi 中 `./build.sh`、CI 定义的应用与存储包测试(显式配置 HTTP、会话、迁移、分析及缓存合成库 DSN)、`npm run test:frontend`、`npm run build`;浏览器测试使用独立服务及合成账号。每一步先补行为测试,观察失败,再落实实现。所有检查通过后提交 PR,由仓库所有者合并。 diff --git a/docs/specs/account-center/verification.md b/docs/specs/account-center/verification.md new file mode 100644 index 0000000..a59a831 --- /dev/null +++ b/docs/specs/account-center/verification.md @@ -0,0 +1,44 @@ +# 个人中心验证记录 + +日期:2026-09-18。基线:`origin/master` 的 `8404303ab2c5cdde9a5cb6449db49d44c7f36730`;实现分支:`feat/cq/account_center`。 + +## 环境与范围 + +Go 构建、MariaDB 集成与真实后端运行在授权访问的 Pi:Linux amd64、Go 1.21.12、MariaDB 10.11.14、Node 24.16。浏览器通过 SSH 隧道访问 Pi 的回环 HTTPS nginx 和独立 Home Server。全部账号、数据库、Redis 与文件目录为合成夹具;正式服务及数据库保持原状态。 + +## 功能与构建 + +| 验证 | 结果 | +| --- | --- | +| `go build ./...`、`./build.sh`、维护工具独立构建 | 成功;主服务和维护工具产物非空 | +| CI 定义的应用与存储 Go 回归 | 31 个包通过,475 项测试通过(含子用例);数据库账号 HTTP、会话、迁移、动态配置、分析与缓存均显式使用独立库;27 个额外条件用例未启用 | +| DAL 缓存、Python API 客户端及隔离 HTTPS 托管回归 | 通过 | +| 前端 Node 测试与生产构建 | 82/82 通过,构建成功 | +| 真实 Vue/Element Plus 浏览器夹具 | 6/6 通过,覆盖冲突草稿、64 个 Emoji、裁剪、身份隔离、旧四字段策略回滚 | +| Pi 实际页面:资料、头像、选择退出、资料重置、改密 | 7 项检查通过;图片返回受保护的 256×256 JPEG;重置后旧 URL 404;退出目标后的 Cookie 401,当前保持 200;320px 无页面横向溢出 | +| Pi 实际页面:同时登录及管理员配置 | 6 项检查通过;5 个登录保持有效,第 6 个拒绝;退出释放名额;UI 调高至 6 可登录,调低至 2 保留 6 条并拒绝新签发;旧策略回滚恢复默认 5,历史不改写 | +| 并发签发与旧事务快照 | 12/32 个混合网页登录/旧入口并发各仅签发 5 条;旧 RR 快照不能绕过限制;受限改密会话占名额,应用/Basic/Cookie 兑换不占名额 | +| 详情并发数量回归 | 账号读取与统计改为同一只读 RR 事务后,新并发用例及 23 项会话/容量回归通过;精确暂停在 V1 读取后提交 V2,新旧详情分别返回 V1/1、V2/1,旧 Token 拒绝、新 Token 有效 | +| 正数 ID | 8,192 个并发 ID 均正数且无重复;时钟回退仍递增;int64 正数耗尽时拒绝返回无效值;旧负会话 ID 仍可读取及撤销 | +| 迁移、清理及部署模板 | 三个迁移/维护包 race 通过;真实 CLI 从 10 阶段续升至 11、拒绝旧 plan、重复应用无待执行阶段;前 10 阶段时间与摘要、旧导入标记保留;500 行批次清理幂等;新增 nginx 示例语法、systemd 模板及公开配置检查通过 | +| 文档插图 | 2 张 Mermaid 均成功渲染 | + +原 `master` 的真实 V1 导入摘要用于回归:新默认值曾改变摘要并使测试失败,修复后原始四字段导入、原标记及重复执行通过。新增头像、资料重置和会话撤销在事务内复核当前凭据;测试确认中途被选择退出的会话不能继续执行新增资料写入。 + +## 会话 SQL + +使用 `EXPLAIN`、`ANALYZE FORMAT=JSON` 与七次数据库查询耗时中位数。数据均保留 `is_expired=0`,过期与有效记录具有合法的不同 TTL,旧认证版本记录尚未自然到期。42 项查询结果对照与原集合完全一致。耗时仅为共享 Pi 的数据库查询耗时,不含 HTTP/隧道延迟。 + +| 合成数据 | 最终查询 | 实际扫描会话行数 | SQL 中位数 | +| --- | --- | ---: | ---: | +| 单用户 10,000 历史:9,995 过期+5 有效 | 列表,排除当前会话 | 4 | 0.76 ms | +| 单用户 10,000 历史:9,900 过期+100 有效 | 列表,排除当前会话 | 99 | 1.41 ms | +| 单用户:9,000 过期+995 旧版本未到期+5 有效 | 列表/数量/签发名额检查 | 4/5/5 | 0.75/0.73/0.71 ms | +| 100 用户:9,000 过期+500 旧版本未到期+500 有效 | 一次批量 OR 聚合 | 500 | 7.45 ms | +| 单用户遗留 10,000 条全部有效 | 首列表/游标下一页 | 22/21 | 0.57/1.10 ms | + +有效总数不超过 100 时,列表使用 `USE INDEX (idx_login_token_effective_range)`,按固定用户/版本与到期时间筛选后排序。仅新增索引并让优化器自然选择不能稳定排除过期扫描:同为 100 有效/9,900 过期时,排序索引曾扫描 9,922 行。大于 100 的合法存量保留自然分页索引选择,避免排序全部有效记录;总数仍准确返回 10,000,签发名额检查仍至多读取网站上限条数。管理员聚合使用同快照用户/版本常量,但保留自然索引选择。 + +## 上线边界 + +验证完成不等于生产启用。上线先按 [增量升级说明](../../account-center-operations.md) 应用 11 阶段结构,再部署匹配后端、代理与前端,并启用受限维护任务。更旧后端不识别已发布的新策略字段,回滚需准备兼容策略。正式规模和 DDL 维护窗口由上线预检确定。 diff --git a/domain/dal/account.go b/domain/dal/account.go index 0c0b33b..09a6623 100644 --- a/domain/dal/account.go +++ b/domain/dal/account.go @@ -11,16 +11,17 @@ import ( ) const ( - AccountTable = "user_account" - LoginAliasTable = "user_login_alias" - InvitationTable = "user_invitation" - AdminAuditTable = "admin_audit_log" + AccountTable = "user_account" + AccountAvatarTable = "user_avatar" + LoginAliasTable = "user_login_alias" + InvitationTable = "user_invitation" + AdminAuditTable = "admin_audit_log" ) -var AccountColumns = []string{"id", "username", "username_key", "display_name", "contact_email", "contact_mobile", "password_hash", "status", "role", "library_enabled", "webdav_permission", "auth_version", "revision", "must_change_password", "password_expires_at", "invite_eligible_at", "source", "invited_by_user_id", "created_by_user_id", "imported_at", "last_login_at", "deleted_at", "create_time", "update_time"} +var AccountColumns = []string{"id", "username", "username_key", "display_name", "avatar_version", "contact_email", "contact_mobile", "password_hash", "status", "role", "library_enabled", "webdav_permission", "auth_version", "revision", "must_change_password", "password_expires_at", "invite_eligible_at", "source", "invited_by_user_id", "created_by_user_id", "imported_at", "last_login_at", "deleted_at", "create_time", "update_time"} var InvitationColumns = []string{"id", "inviter_user_id", "quota_month", "slot", "token_digest", "token_hint", "registration_epoch", "status", "expires_at", "used_by_user_id", "used_at", "revoked_at", "revoke_reason", "note", "request_id", "create_time"} -var AccountSessionColumns = []string{"id", "user_id", "token_digest", "auth_version", "purpose", "is_expired", "authenticated_at", "expire_time", "create_time", "update_time"} +var AccountSessionColumns = []string{"id", "user_id", "token_digest", "auth_version", "purpose", "is_expired", "authenticated_at", "login_ip", "user_agent", "client_name", "os_name", "device_type", "login_source", "expire_time", "create_time", "update_time"} var AdminAuditColumns = []string{"id", "actor_user_id", "action", "target_type", "target_id", "before_summary", "after_summary", "reason", "result", "request_id", "create_time"} // Queries involving identity/session material suppress SQL logging, including @@ -35,6 +36,7 @@ func QueryAccount(database *gorm.DB, id int64, lock bool) (*model.UserAccount, e if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } + user.AvatarURL = model.AccountAvatarURL(&user) return &user, err } @@ -109,6 +111,9 @@ func ListAccounts(database *gorm.DB, filter AccountFilter) ([]*model.UserAccount } var accounts []*model.UserAccount err := query.Order("id DESC").Limit(filter.Limit).Find(&accounts).Error + for _, user := range accounts { + user.AvatarURL = model.AccountAvatarURL(user) + } return accounts, err } diff --git a/domain/dal/migrations/20260917_account_center.sql b/domain/dal/migrations/20260917_account_center.sql new file mode 100644 index 0000000..e8ff243 --- /dev/null +++ b/domain/dal/migrations/20260917_account_center.sql @@ -0,0 +1,23 @@ +-- Additive account center upgrade. Never change the published 20260913 import. +ALTER TABLE user_account ADD COLUMN avatar_version BIGINT NOT NULL DEFAULT 0; + +CREATE TABLE user_avatar ( + user_id BIGINT NOT NULL PRIMARY KEY, + version BIGINT NOT NULL, + content_type VARCHAR(32) NOT NULL, + content_blob MEDIUMBLOB NOT NULL, + byte_size INT UNSIGNED NOT NULL, + update_time DATETIME(6) NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +ALTER TABLE login_token ADD COLUMN login_ip VARCHAR(45) NOT NULL DEFAULT ''; +ALTER TABLE login_token ADD COLUMN user_agent VARCHAR(1024) NOT NULL DEFAULT ''; +ALTER TABLE login_token ADD COLUMN client_name VARCHAR(128) NOT NULL DEFAULT ''; +ALTER TABLE login_token ADD COLUMN os_name VARCHAR(128) NOT NULL DEFAULT ''; +ALTER TABLE login_token ADD COLUMN device_type VARCHAR(16) NOT NULL DEFAULT ''; +ALTER TABLE login_token ADD COLUMN login_source VARCHAR(24) NOT NULL DEFAULT ''; +-- Preserve idx_login_token_user for existing validity-range queries. +ALTER TABLE login_token ADD KEY idx_login_token_session_page (user_id,auth_version,is_expired,create_time,id); +ALTER TABLE login_token ADD KEY idx_login_token_metadata_expiry (expire_time,id); +-- Bound effective-session reads by account version and natural expiry. +ALTER TABLE login_token ADD KEY idx_login_token_effective_range (user_id,auth_version,is_expired,expire_time,create_time,id); diff --git a/domain/model/account.go b/domain/model/account.go index a228e85..bbc2ae8 100644 --- a/domain/model/account.go +++ b/domain/model/account.go @@ -1,6 +1,9 @@ package model -import "time" +import ( + "strconv" + "time" +) const ( AccountActive = "active" @@ -22,6 +25,8 @@ type UserAccount struct { Username string `gorm:"column:username" json:"user_name"` UsernameKey string `gorm:"column:username_key" json:"-"` DisplayName string `gorm:"column:display_name" json:"display_name"` + AvatarVersion int64 `gorm:"column:avatar_version" json:"avatar_version"` + AvatarURL string `gorm:"-" json:"avatar_url"` ContactEmail string `gorm:"column:contact_email" json:"contact_email"` ContactMobile string `gorm:"column:contact_mobile" json:"contact_mobile"` PasswordHash string `gorm:"column:password_hash" json:"-"` @@ -44,6 +49,30 @@ type UserAccount struct { UpdateTime time.Time `gorm:"column:update_time" json:"update_time"` } +// AccountAvatarURL derives an authorized, versioned address without reading image bytes. +func AccountAvatarURL(user *UserAccount) string { + if user == nil || user.Status != AccountActive || user.MustChangePassword || user.AvatarVersion <= 0 { + return "" + } + return "/api/account/avatars/" + strconv.FormatInt(user.ID, 10) + "/" + strconv.FormatInt(user.AvatarVersion, 10) +} + +type UserDisplay struct { + UserID int64 `json:"user_id,string"` + UserName string `json:"user_name"` + DisplayName string `json:"display_name"` + AvatarURL string `json:"avatar_url"` +} + +type UserAvatar struct { + UserID int64 `gorm:"column:user_id;primaryKey" json:"-"` + Version int64 `gorm:"column:version" json:"-"` + ContentType string `gorm:"column:content_type" json:"-"` + ContentBlob []byte `gorm:"column:content_blob" json:"-"` + ByteSize uint32 `gorm:"column:byte_size" json:"-"` + UpdateTime time.Time `gorm:"column:update_time" json:"-"` +} + type LoginAlias struct { LoginKey string `gorm:"column:login_key;primaryKey"` UserID int64 `gorm:"column:user_id"` @@ -58,6 +87,12 @@ type AccountSession struct { Purpose string `gorm:"column:purpose" json:"-"` IsExpired int `gorm:"column:is_expired" json:"-"` AuthenticatedAt time.Time `gorm:"column:authenticated_at" json:"-"` + LoginIP string `gorm:"column:login_ip" json:"-"` + UserAgent string `gorm:"column:user_agent" json:"-"` + ClientName string `gorm:"column:client_name" json:"-"` + OSName string `gorm:"column:os_name" json:"-"` + DeviceType string `gorm:"column:device_type" json:"-"` + LoginSource string `gorm:"column:login_source" json:"-"` ExpireTime time.Time `gorm:"column:expire_time" json:"-"` CreateTime time.Time `gorm:"column:create_time" json:"-"` UpdateTime time.Time `gorm:"column:update_time" json:"-"` @@ -81,6 +116,8 @@ type UserInvitation struct { RequestID string `gorm:"column:request_id" json:"-"` CreateTime time.Time `gorm:"column:create_time" json:"create_time"` UsedByUserName string `gorm:"-" json:"used_by_user_name,omitempty"` + + UsedByUser *UserDisplay `gorm:"-" json:"used_by_user,omitempty"` } type AdminAuditLog struct { diff --git a/domain/service/accounts/avatars.go b/domain/service/accounts/avatars.go new file mode 100644 index 0000000..6c07a14 --- /dev/null +++ b/domain/service/accounts/avatars.go @@ -0,0 +1,173 @@ +package accounts + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "image" + "image/color" + "image/jpeg" + "image/png" + "time" + + "github.com/mcoder2014/home_server/domain/dal" + "github.com/mcoder2014/home_server/domain/model" + apperrors "github.com/mcoder2014/home_server/errors" + "golang.org/x/image/draw" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +const AvatarInputLimit = 2 << 20 + +var avatarDecoders = make(chan struct{}, 2) + +// normalizeAvatar bounds decoder work, rejects unsupported/animated images, then +// crops and reencodes pixels to discard metadata and untrusted input bytes. +func normalizeAvatar(raw []byte) ([]byte, error) { + if len(raw) == 0 || len(raw) > AvatarInputLimit { + return nil, apperrors.ErrInvalid + } + select { + case avatarDecoders <- struct{}{}: + defer func() { <-avatarDecoders }() + default: + return nil, apperrors.ErrRateLimited + } + var cfg image.Config + var err error + isPNG := bytes.HasPrefix(raw, []byte("\x89PNG\r\n\x1a\n")) + if isPNG { + // Check structural APNG chunks rather than arbitrary occurrences inside compressed pixels. + for pos := 8; pos+12 <= len(raw); { + n := int64(binary.BigEndian.Uint32(raw[pos : pos+4])) + if n > int64(len(raw)-pos-12) { + return nil, apperrors.ErrInvalid + } + if string(raw[pos+4:pos+8]) == "acTL" { + return nil, apperrors.ErrInvalid + } + pos += int(n) + 12 + } + cfg, err = png.DecodeConfig(bytes.NewReader(raw)) + } else if bytes.HasPrefix(raw, []byte{0xff, 0xd8}) { + cfg, err = jpeg.DecodeConfig(bytes.NewReader(raw)) + } else { + return nil, apperrors.ErrInvalid + } + if err != nil || cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width > 4096 || cfg.Height > 4096 || int64(cfg.Width)*int64(cfg.Height) > 16000000 { + return nil, apperrors.ErrInvalid + } + var source image.Image + if isPNG { + source, err = png.Decode(bytes.NewReader(raw)) + } else { + source, err = jpeg.Decode(bytes.NewReader(raw)) + } + if err != nil { + return nil, apperrors.ErrInvalid + } + bounds := source.Bounds() + size := bounds.Dx() + if bounds.Dy() < size { + size = bounds.Dy() + } + x, y := bounds.Min.X+(bounds.Dx()-size)/2, bounds.Min.Y+(bounds.Dy()-size)/2 + output := image.NewRGBA(image.Rect(0, 0, 256, 256)) + draw.Draw(output, output.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src) + draw.CatmullRom.Scale(output, output.Bounds(), source, image.Rect(x, y, x+size, y+size), draw.Over, nil) + var encoded bytes.Buffer + if err = jpeg.Encode(&encoded, output, &jpeg.Options{Quality: 85}); err != nil || encoded.Len() > 128<<10 { + return nil, apperrors.ErrInvalid + } + return encoded.Bytes(), nil +} + +// UpdateAvatar locks the account and acting session before committing the image, +// revision and version pointer together. +// A missing image deletion is idempotent after the supplied revision is checked. +func UpdateAvatar(ctx context.Context, token string, revision int64, raw []byte, remove bool) (*model.UserAccount, error) { + var normalized []byte + var err error + if !remove { + normalized, err = normalizeAvatar(raw) + if err != nil { + return nil, err + } + } + database, err := database(ctx) + if err != nil { + return nil, err + } + var id int64 + err = database.Transaction(func(tx *gorm.DB) error { + user, _, _, e := actingSessionTx(tx, token, true, false) + if e != nil { + return e + } + id = user.ID + if user.Revision != revision { + return apperrors.ErrConflict + } + if remove && user.AvatarVersion == 0 { + return nil + } + pointer := revision + 1 + if remove { + pointer = 0 + if e = tx.Table(dal.AccountAvatarTable).Where("user_id = ?", id).Delete(&model.UserAvatar{}).Error; e != nil { + return e + } + } else { + avatar := model.UserAvatar{UserID: id, Version: pointer, ContentType: "image/jpeg", ContentBlob: normalized, ByteSize: uint32(len(normalized)), UpdateTime: time.Now()} + if e = tx.Table(dal.AccountAvatarTable).Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "user_id"}}, DoUpdates: clause.AssignmentColumns([]string{"version", "content_type", "content_blob", "byte_size", "update_time"})}).Create(&avatar).Error; e != nil { + return e + } + } + updated, e := dal.UpdateAccount(tx, id, revision, map[string]interface{}{"avatar_version": pointer, "revision": revision + 1, "update_time": time.Now()}) + if e != nil { + return e + } + if !updated { + return apperrors.ErrConflict + } + return nil + }) + if err != nil { + return nil, normalizeError(err) + } + return GetByID(ctx, id) +} + +// ReadAvatar checks the latest target state and version in the same snapshot as the bytes. +func ReadAvatar(ctx context.Context, id, version int64) ([]byte, error) { + database, err := database(ctx) + if err != nil { + return nil, err + } + var result []byte + err = database.Transaction(func(tx *gorm.DB) error { + user, e := dal.QueryAccount(tx, id, false) + if e != nil { + return e + } + if user == nil || user.Status != model.AccountActive || user.MustChangePassword || version <= 0 || user.AvatarVersion != version { + return apperrors.ErrNotFound + } + var avatar model.UserAvatar + e = tx.Table(dal.AccountAvatarTable).Select("content_blob", "byte_size", "content_type").Where("user_id = ? AND version = ?", id, version).Take(&avatar).Error + if errors.Is(e, gorm.ErrRecordNotFound) { + return apperrors.ErrNotFound + } + if e != nil { + return e + } + if avatar.ContentType != "image/jpeg" || len(avatar.ContentBlob) > 128<<10 || len(avatar.ContentBlob) != int(avatar.ByteSize) { + return apperrors.ErrDependency + } + result = avatar.ContentBlob + return nil + }) + return result, normalizeError(err) +} diff --git a/domain/service/accounts/avatars_test.go b/domain/service/accounts/avatars_test.go new file mode 100644 index 0000000..fc6563a --- /dev/null +++ b/domain/service/accounts/avatars_test.go @@ -0,0 +1,72 @@ +package accounts + +import ( + "bytes" + "encoding/binary" + "image" + "image/color" + "image/jpeg" + "image/png" + "testing" +) + +func TestAvatarNormalizationRejectsInvalidAndOversizedInput(t *testing.T) { + for _, raw := range [][]byte{nil, []byte(""), bytes.Repeat([]byte("x"), (2<<20)+1)} { + if _, err := normalizeAvatar(raw); err == nil { + t.Fatal("non-image or oversized avatar accepted") + } + } + var raw bytes.Buffer + if err := png.Encode(&raw, image.NewGray(image.Rect(0, 0, 4097, 1))); err != nil { + t.Fatal(err) + } + if _, err := normalizeAvatar(raw.Bytes()); err == nil { + t.Fatal("excessive width accepted") + } + raw.Reset() + if err := png.Encode(&raw, image.NewGray(image.Rect(0, 0, 4001, 4000))); err != nil { + t.Fatal(err) + } + if _, err := normalizeAvatar(raw.Bytes()); err == nil { + t.Fatal("excessive pixel count accepted") + } +} + +func TestAvatarNormalizationProducesSmallSquareJPEGOnWhite(t *testing.T) { + input := image.NewNRGBA(image.Rect(0, 0, 800, 400)) + input.SetNRGBA(0, 0, color.NRGBA{R: 255, A: 255}) + var raw bytes.Buffer + if err := png.Encode(&raw, input); err != nil { + t.Fatal(err) + } + result, err := normalizeAvatar(raw.Bytes()) + if err != nil { + t.Fatal(err) + } + output, err := jpeg.Decode(bytes.NewReader(result)) + if err != nil { + t.Fatal(err) + } + if output.Bounds().Dx() != 256 || output.Bounds().Dy() != 256 || len(result) > 128<<10 { + t.Fatal("invalid normalized avatar dimensions or size") + } + r, g, b, _ := output.At(128, 128).RGBA() + if r < 65000 || g < 65000 || b < 65000 { + t.Fatal("transparent input must have a white background") + } +} + +func TestAvatarNormalizationRejectsAnimatedPNG(t *testing.T) { + var raw bytes.Buffer + if err := png.Encode(&raw, image.NewNRGBA(image.Rect(0, 0, 2, 2))); err != nil { + t.Fatal(err) + } + // An APNG animation-control chunk must be rejected before the ordinary PNG decoder ignores it. + chunk := make([]byte, 20) + binary.BigEndian.PutUint32(chunk, 8) + copy(chunk[4:8], "acTL") + input := append(append(append([]byte{}, raw.Bytes()[:33]...), chunk...), raw.Bytes()[33:]...) + if _, err := normalizeAvatar(input); err == nil { + t.Fatal("animated PNG accepted") + } +} diff --git a/domain/service/accounts/detail_session_count_test.go b/domain/service/accounts/detail_session_count_test.go new file mode 100644 index 0000000..6b32e4c --- /dev/null +++ b/domain/service/accounts/detail_session_count_test.go @@ -0,0 +1,105 @@ +package accounts + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/mcoder2014/home_server/domain/dal" + "github.com/mcoder2014/home_server/domain/model" + apperrors "github.com/mcoder2014/home_server/errors" + "gorm.io/gorm" +) + +type detailSnapshotTestKey struct{} + +// TestUserDetailSessionCountSharesAccountVersionSnapshot pauses a real account +// read, commits a new authentication version and session on another connection, +// then verifies that the paused detail and a fresh detail each retain one count. +func TestUserDetailSessionCountSharesAccountVersionSnapshot(t *testing.T) { + database := sessionTestDatabase(t) + now := time.Now().UTC().Truncate(time.Second) + // Detail also reads invitation state after its account/session summary. + if err := database.Table(dal.SiteRuntimeStateTable).AutoMigrate(&model.SiteRuntimeState{}); err != nil { + t.Fatal(err) + } + if err := database.Exec("DELETE FROM " + dal.SiteRuntimeStateTable).Error; err != nil { + t.Fatal(err) + } + if err := database.Table(dal.SiteRuntimeStateTable).Create(&model.SiteRuntimeState{ID: 1, Revision: 1, RegistrationEpoch: 1, ConfigGeneration: 1, UpdateTime: now}).Error; err != nil { + t.Fatal(err) + } + sessionTestInsert(t, database, 9600, 8101, model.SessionUser, now, now.Add(time.Hour)) + userRead, resume := make(chan struct{}), make(chan struct{}) + ctx, cancel := context.WithTimeout(context.WithValue(context.Background(), detailSnapshotTestKey{}, true), 15*time.Second) + defer cancel() + var once sync.Once + const callback = "test:pause_detail_account_read" + if err := database.Callback().Query().After("gorm:query").Register(callback, func(query *gorm.DB) { + user, ok := query.Statement.Dest.(*model.UserAccount) + if query.Error != nil || !ok || query.Statement.Table != dal.AccountTable || user.ID != 8101 || user.AuthVersion != 1 || query.Statement.Context.Value(detailSnapshotTestKey{}) != true { + return + } + once.Do(func() { + close(userRead) + select { + case <-resume: + case <-ctx.Done(): + } + }) + }); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { database.Callback().Query().Remove(callback) }) + type detailResult struct { + detail map[string]interface{} + err error + } + result := make(chan detailResult, 1) + go func() { + detail, err := UserDetail(ctx, 8101) + result <- detailResult{detail, err} + }() + select { + case <-userRead: + case <-ctx.Done(): + t.Fatal("detail did not reach the account read:", ctx.Err()) + } + writer := database.Session(&gorm.Session{NewDB: true}) + if err := writer.Table(dal.AccountTable).Where("id = ?", 8101).Updates(map[string]interface{}{"auth_version": 2, "revision": 2}).Error; err != nil { + t.Fatal(err) + } + newToken, err := IssueVerifiedSession(context.Background(), 8101, 2) + if err != nil { + t.Fatal("could not issue the next-version session while the detail was paused:", err) + } + close(resume) + var response detailResult + select { + case response = <-result: + case <-ctx.Done(): + t.Fatal("detail did not finish:", ctx.Err()) + } + if response.err != nil { + t.Fatal(response.err) + } + user := response.detail["user"].(*model.UserAccount) + if user.AuthVersion != 1 || response.detail["active_session_count"] != int64(1) || response.detail["restricted_session_count"] != int64(0) { + t.Fatalf("paused detail must retain version 1 and its one effective session: version=%d active=%v restricted=%v", user.AuthVersion, response.detail["active_session_count"], response.detail["restricted_session_count"]) + } + fresh, err := UserDetail(context.Background(), 8101) + if err != nil { + t.Fatal(err) + } + if fresh["user"].(*model.UserAccount).AuthVersion != 2 || fresh["active_session_count"] != int64(1) { + t.Fatal("fresh detail did not count the new version's one effective session") + } + if _, _, err := CheckSession(context.Background(), sessionTestToken(9600), false); !errors.Is(err, apperrors.ErrUnauthorized) { + t.Fatal("the previous authentication version remained usable:", err) + } + if current, _, err := CheckSession(context.Background(), newToken, false); err != nil || current.AuthVersion != 2 { + t.Fatal("the new authentication version was not usable:", err) + } +} diff --git a/domain/service/accounts/identities.go b/domain/service/accounts/identities.go new file mode 100644 index 0000000..5909e8d --- /dev/null +++ b/domain/service/accounts/identities.go @@ -0,0 +1,46 @@ +package accounts + +import ( + "context" + "github.com/mcoder2014/home_server/domain/dal" + "github.com/mcoder2014/home_server/domain/model" +) + +type UserDisplay = model.UserDisplay + +// DisplayUser exposes only the current public identity, never credentials or contact details. +// Deleted identities hide their previous custom name and image. +func DisplayUser(user *model.UserAccount) UserDisplay { + if user == nil { + return UserDisplay{DisplayName: "已注销用户"} + } + value := UserDisplay{UserID: user.ID, UserName: user.Username, DisplayName: user.DisplayName, AvatarURL: model.AccountAvatarURL(user)} + if user.Status == model.AccountDeleted { + value.DisplayName, value.AvatarURL = "已注销用户", "" + } + return value +} + +// DisplayUsers batches page-sized current identities without fetching avatar blobs or passwords. +func DisplayUsers(ctx context.Context, ids []int64) (map[int64]UserDisplay, error) { + result := map[int64]UserDisplay{} + if len(ids) == 0 { + return result, nil + } + database, err := database(ctx) + if err != nil { + return nil, err + } + var users []model.UserAccount + err = database.Table(dal.AccountTable).Select("id", "username", "display_name", "avatar_version", "status", "must_change_password").Where("id IN ?", ids).Find(&users).Error + if err != nil { + return nil, normalizeError(err) + } + for _, id := range ids { + result[id] = UserDisplay{UserID: id, DisplayName: "已注销用户"} + } + for _, user := range users { + result[user.ID] = DisplayUser(&user) + } + return result, nil +} diff --git a/domain/service/accounts/identities_test.go b/domain/service/accounts/identities_test.go new file mode 100644 index 0000000..72a8d81 --- /dev/null +++ b/domain/service/accounts/identities_test.go @@ -0,0 +1,23 @@ +package accounts + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/mcoder2014/home_server/domain/model" +) + +func TestDeletedDisplayPreservesStableUsernameWithoutCustomIdentity(t *testing.T) { + identity := DisplayUser(&model.UserAccount{ID: 1002, Username: "stable_login", DisplayName: "previous_custom_name", AvatarVersion: 3, Status: model.AccountDeleted, PasswordHash: "private"}) + if identity.UserName != "stable_login" || identity.DisplayName != "已注销用户" || identity.AvatarURL != "" { + t.Fatal("deleted display broke legacy username compatibility or exposed custom identity") + } + raw, err := json.Marshal(identity) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "previous_custom_name") || strings.Contains(string(raw), "private") { + t.Fatal("minimal display contains historical custom identity or credentials") + } +} diff --git a/domain/service/accounts/invitations.go b/domain/service/accounts/invitations.go index 18b1034..58bfcfb 100644 --- a/domain/service/accounts/invitations.go +++ b/domain/service/accounts/invitations.go @@ -71,15 +71,9 @@ func ListInvitations(ctx context.Context, id int64) (*InvitationPage, error) { usedIDs = append(usedIDs, *inv.UsedByUserID) } } - names := map[int64]string{} - if len(usedIDs) > 0 { - var usedUsers []model.UserAccount - if e := database.Table(dal.AccountTable).Select("id", "username").Where("id IN ?", usedIDs).Find(&usedUsers).Error; e != nil { - return nil, normalizeError(e) - } - for _, usedUser := range usedUsers { - names[usedUser.ID] = usedUser.Username - } + identities, e := DisplayUsers(ctx, usedIDs) + if e != nil { + return nil, e } for _, inv := range page.Items { if inv.Status == "unused" { @@ -91,7 +85,9 @@ func ListInvitations(ctx context.Context, id int64) (*InvitationPage, error) { } } if inv.UsedByUserID != nil { - inv.UsedByUserName = names[*inv.UsedByUserID] + identity := identities[*inv.UsedByUserID] + inv.UsedByUserName = identity.UserName + inv.UsedByUser = &identity } } return page, nil diff --git a/domain/service/accounts/mutations.go b/domain/service/accounts/mutations.go index 1055cdb..2ee831b 100644 --- a/domain/service/accounts/mutations.go +++ b/domain/service/accounts/mutations.go @@ -191,6 +191,7 @@ func AdminCreate(ctx context.Context, actorID, version int64, input CreateInput) } type AdminInput struct { + ActingToken string `json:"-"` Reason string `json:"reason"` CurrentPassword string `json:"current_password"` Password string `json:"password"` @@ -198,6 +199,8 @@ type AdminInput struct { Role string `json:"role"` Enabled bool `json:"enabled"` Permission string `json:"permission"` + ResetDisplayName bool `json:"reset_display_name"` + ResetAvatar bool `json:"reset_avatar"` } func VerifyAdminPassword(ctx context.Context, id int64, password string) (*model.UserAccount, error) { @@ -272,6 +275,16 @@ func AdminChange(ctx context.Context, actorID, version, targetID, revision int64 if actor == nil || actor.Status != model.AccountActive || actor.Role != model.RoleAdmin || actor.MustChangePassword || actor.AuthVersion != version || actor.PasswordHash != verified.PasswordHash { return apperrors.ErrUnauthorized } + if action == "reset-profile" { + // Both account locks are already held; take the acting session lock next. + acting, _, _, e := actingSessionTx(tx, input.ActingToken, true, true) + if e != nil { + return e + } + if acting.ID != actorID || acting.AuthVersion != version { + return apperrors.ErrUnauthorized + } + } if target == nil { return apperrors.ErrNotFound } @@ -279,6 +292,9 @@ func AdminChange(ctx context.Context, actorID, version, targetID, revision int64 return apperrors.ErrConflict } before := accountSummary(target) + if action == "reset-profile" { + before = profileSummary(target) + } fields, e := adminTransition(tx, actorID, target, action, input, hash) if e != nil { return e @@ -334,6 +350,10 @@ func adminTransition(tx *gorm.DB, actorID int64, target *model.UserAccount, acti if action == "delete" { fields["status"] = model.AccountDeleted fields["deleted_at"] = now + fields["avatar_version"] = int64(0) + if err := tx.Table(dal.AccountAvatarTable).Where("user_id = ?", target.ID).Delete(&model.UserAvatar{}).Error; err != nil { + return nil, err + } } if err := suspendApplicationsTx(tx, target.ID, action == "delete"); err != nil { return nil, err @@ -355,6 +375,19 @@ func adminTransition(tx *gorm.DB, actorID int64, target *model.UserAccount, acti return nil, apperrors.ErrConflict } fields["status"] = model.AccountActive + case "reset-profile": + if !input.ResetDisplayName && !input.ResetAvatar { + return nil, apperrors.ErrInvalid + } + if input.ResetDisplayName { + fields["display_name"] = "" + } + if input.ResetAvatar { + fields["avatar_version"] = int64(0) + if err := tx.Table(dal.AccountAvatarTable).Where("user_id = ?", target.ID).Delete(&model.UserAvatar{}).Error; err != nil { + return nil, err + } + } case "role": if input.Role != model.RoleAdmin && input.Role != model.RoleUser { return nil, apperrors.ErrInvalid @@ -456,10 +489,21 @@ func accountSummary(user *model.UserAccount) string { return string(data) } +func profileSummary(user *model.UserAccount) string { + if user == nil { + return "{}" + } + data, _ := json.Marshal(map[string]interface{}{"display_name": user.DisplayName, "avatar_version": user.AvatarVersion, "revision": user.Revision}) + return string(data) +} + func writeAudit(tx *gorm.DB, actorID int64, action, targetType string, targetID int64, before, reason string, after *model.UserAccount) error { if before == "" { before = "{}" } audit := model.AdminAuditLog{ActorUserID: actorID, Action: action, TargetType: targetType, TargetID: targetID, BeforeSummary: before, AfterSummary: accountSummary(after), Reason: reason, Result: "success", CreateTime: time.Now()} + if action == "reset-profile" { + audit.AfterSummary = profileSummary(after) + } return tx.Table(dal.AdminAuditTable).Create(&audit).Error } diff --git a/domain/service/accounts/queries.go b/domain/service/accounts/queries.go index 53324d4..b0fade7 100644 --- a/domain/service/accounts/queries.go +++ b/domain/service/accounts/queries.go @@ -2,6 +2,7 @@ package accounts import ( "context" + "database/sql" "encoding/json" "strconv" "time" @@ -23,17 +24,34 @@ func ListActiveUsers(ctx context.Context) ([]*model.UserAccount, error) { } // UserDetail 组合账号资料及最近的应用、网页项目和邀请记录,应用列表仅选取管理页面需要的非密钥字段。 +// 账号与会话数量共用只读快照;其他资源保留独立读取顺序。 func UserDetail(ctx context.Context, id int64) (map[string]interface{}, error) { database, err := database(ctx) if err != nil { return nil, err } - user, err := dal.QueryAccount(database, id, false) + var user *model.UserAccount + var counts []sessionCount + var now time.Time + err = database.Transaction(func(tx *gorm.DB) error { + var e error + user, e = dal.QueryAccount(tx, id, false) + if e != nil { + return e + } + if user == nil { + return apperrors.ErrNotFound + } + now = time.Now() + counts, e = sessionCountsTx(tx, []*model.UserAccount{user}, now) + return e + }, &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true}) if err != nil { return nil, normalizeError(err) } - if user == nil { - return nil, apperrors.ErrNotFound + var activeSessions, restrictedSessions int64 + if len(counts) > 0 { + activeSessions, restrictedSessions = counts[0].Total, counts[0].Restricted } var apps []struct { ID int64 `json:"id,string"` @@ -54,7 +72,7 @@ func UserDetail(ctx context.Context, id int64) (map[string]interface{}, error) { if err != nil { return nil, err } - return map[string]interface{}{"user": user, "applications": apps, "projects": projects, "invitations": invites.Items}, nil + return map[string]interface{}{"user": user, "applications": apps, "projects": projects, "invitations": invites.Items, "active_session_count": activeSessions, "restricted_session_count": restrictedSessions, "server_time": now, "session_warning_threshold": SessionWarningThreshold}, nil } type UserSummary struct { @@ -63,12 +81,16 @@ type UserSummary struct { ActiveApplicationCount int64 `json:"active_application_count"` ProjectCount int64 `json:"project_count"` PublishedProjectCount int64 `json:"published_project_count"` + ActiveSessionCount int64 `json:"active_session_count"` + RestrictedSessionCount int64 `json:"restricted_session_count"` } type UserPage struct { - Items []*UserSummary `json:"items"` - NextCursor string `json:"next_cursor"` - HasMore bool `json:"has_more"` + Items []*UserSummary `json:"items"` + NextCursor string `json:"next_cursor"` + HasMore bool `json:"has_more"` + ServerTime time.Time `json:"server_time"` + SessionWarningThreshold int `json:"session_warning_threshold"` } // ListUsers 按账号游标分页,并批量汇总本页用户的应用与项目数量,多读一条判断是否有下一页。 @@ -83,13 +105,25 @@ func ListUsers(ctx context.Context, filter dal.AccountFilter) (*UserPage, error) if err != nil { return nil, err } + var page *UserPage + err = database.Transaction(func(tx *gorm.DB) error { + var e error + page, e = listUsersPageTx(tx, filter, time.Now()) + return e + }, &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true}) + return page, normalizeError(err) +} + +// listUsersPageTx reads a user page and batches all resource/session counts in +// the same snapshot; database failures never become invented zero counts. +func listUsersPageTx(database *gorm.DB, filter dal.AccountFilter, now time.Time) (*UserPage, error) { limit := filter.Limit filter.Limit++ users, err := dal.ListAccounts(database, filter) if err != nil { return nil, normalizeError(err) } - page := &UserPage{Items: []*UserSummary{}, HasMore: len(users) > limit} + page := &UserPage{Items: []*UserSummary{}, HasMore: len(users) > limit, ServerTime: now, SessionWarningThreshold: SessionWarningThreshold} if page.HasMore { users = users[:limit] } @@ -104,12 +138,20 @@ func ListUsers(ctx context.Context, filter dal.AccountFilter) (*UserPage, error) if len(ids) == 0 { return page, nil } + sessions, err := sessionCountsTx(database, users, now) + if err != nil { + return nil, normalizeError(err) + } + for _, count := range sessions { + byID[count.UserID].ActiveSessionCount = count.Total + byID[count.UserID].RestrictedSessionCount = count.Restricted + } var counts []struct { OwnerUserID int64 Total int64 Active int64 } - err = database.Table(dal.ApplicationTable).Select("owner_user_id, COUNT(*) AS total, SUM(status = 1 AND expires_at > ?) AS active", time.Now()).Where("owner_user_id IN ? AND status <> ?", ids, model.ApplicationStatusRevoked).Group("owner_user_id").Scan(&counts).Error + err = database.Table(dal.ApplicationTable).Select("owner_user_id, COUNT(*) AS total, SUM(status = 1 AND expires_at > ?) AS active", now).Where("owner_user_id IN ? AND status <> ?", ids, model.ApplicationStatusRevoked).Group("owner_user_id").Scan(&counts).Error if err != nil { return nil, normalizeError(err) } @@ -135,6 +177,7 @@ func ListUsers(ctx context.Context, filter dal.AccountFilter) (*UserPage, error) type AuditView struct { model.AdminAuditLog ActorUserName string `json:"actor_user_name"` + ActorUser *UserDisplay `json:"actor_user"` Before map[string]interface{} `json:"before"` After map[string]interface{} `json:"after"` } @@ -173,19 +216,25 @@ func ListAudit(ctx context.Context, cursor int64, limit int, targetType string, for _, log := range logs { ids = append(ids, log.ActorUserID) } - var actors []model.UserAccount + var actorRows []model.UserAccount if len(ids) > 0 { - if err = database.Table(dal.AccountTable).Select("id", "username").Where("id IN ?", ids).Find(&actors).Error; err != nil { + if err = database.Table(dal.AccountTable).Select("id", "username", "display_name", "avatar_version", "status", "must_change_password").Where("id IN ?", ids).Find(&actorRows).Error; err != nil { return nil, normalizeError(err) } } names := map[int64]string{} - for _, actor := range actors { + actors := map[int64]UserDisplay{} + for _, actor := range actorRows { names[actor.ID] = actor.Username + actors[actor.ID] = DisplayUser(&actor) } items := []AuditView{} for _, log := range logs { - v := AuditView{AdminAuditLog: log, ActorUserName: names[log.ActorUserID]} + actor, found := actors[log.ActorUserID] + if !found { + actor = UserDisplay{UserID: log.ActorUserID, DisplayName: "已注销用户"} + } + v := AuditView{AdminAuditLog: log, ActorUserName: names[log.ActorUserID], ActorUser: &actor} _ = json.Unmarshal([]byte(log.BeforeSummary), &v.Before) _ = json.Unmarshal([]byte(log.AfterSummary), &v.After) items = append(items, v) diff --git a/domain/service/accounts/service.go b/domain/service/accounts/service.go index 5c3c9fd..067dc70 100644 --- a/domain/service/accounts/service.go +++ b/domain/service/accounts/service.go @@ -18,6 +18,7 @@ import ( "github.com/mcoder2014/home_server/utils" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" + "gorm.io/gorm/clause" "gorm.io/gorm/logger" ) @@ -130,7 +131,7 @@ func Authenticate(ctx context.Context, key, password string) (*model.UserAccount // Login validates the password before a short row-locked transaction. The lock // rechecks the exact password and auth version so a reset/ban cannot race token // issuance. A temporary password only receives a password-change session. -func Login(ctx context.Context, key, password string) (*model.UserAccount, string, *model.AccountSession, error) { +func Login(ctx context.Context, key, password string, metadata ...SessionMetadata) (*model.UserAccount, string, *model.AccountSession, error) { verified, err := Authenticate(ctx, key, password) if err != nil { return nil, "", nil, err @@ -151,6 +152,10 @@ func Login(ctx context.Context, key, password string) (*model.UserAccount, strin var user *model.UserAccount var token string var session *model.AccountSession + info := NewSessionMetadata("", "", "unknown") + if len(metadata) > 0 { + info = NewSessionMetadata(metadata[0].LoginIP, metadata[0].UserAgent, metadata[0].LoginSource) + } // 锁定账号后确认凭据快照仍有效,必要时升级 bcrypt 成本,再原子写入新会话。 err = database.Transaction(func(tx *gorm.DB) error { var e error @@ -171,20 +176,39 @@ func Login(ctx context.Context, key, password string) (*model.UserAccount, strin user.PasswordHash = upgraded user.Revision++ } - token, session, e = issueSessionTx(tx, user) + token, session, e = issueSessionTx(tx, user, info) return e }) return user, token, session, normalizeError(err) } -// issueSessionTx 保存随机会话令牌的摘要并更新登录时间;待改密账号仅获得十分钟的改密专用会话。 -func issueSessionTx(tx *gorm.DB, user *model.UserAccount) (string, *model.AccountSession, error) { +// 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. +// 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 + if policy.MaxActiveSessions < 1 || policy.MaxActiveSessions > 100 { + return "", nil, apperrors.ErrDependency + } + now := time.Now() + 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"). + Where("s.user_id = ? AND s.auth_version = ?", user.ID, user.AuthVersion). + Limit(policy.MaxActiveSessions). + 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, "有效登录会话已达到网站上限,请从已登录设备退出其他登录或联系管理员") + } token, err := randomSecret("us_cq_", 32) if err != nil { return "", nil, err } - now := time.Now() - ttl := time.Duration(config.Runtime().AccountPolicy.SessionTTLSeconds) * time.Second + ttl := time.Duration(policy.SessionTTLSeconds) * time.Second purpose := model.SessionUser if user.MustChangePassword { purpose = model.SessionPasswordChange @@ -194,7 +218,7 @@ func issueSessionTx(tx *gorm.DB, user *model.UserAccount) (string, *model.Accoun return "", nil, apperrors.ErrDependency } digest := sha256.Sum256([]byte(token)) - session := &model.AccountSession{ID: utils.GenInt64ID(), UserID: user.ID, TokenDigest: digest[:], AuthVersion: user.AuthVersion, Purpose: purpose, AuthenticatedAt: now, ExpireTime: now.Add(ttl), CreateTime: now, UpdateTime: now} + session := &model.AccountSession{ID: utils.GenInt64ID(), UserID: user.ID, TokenDigest: digest[:], AuthVersion: user.AuthVersion, Purpose: purpose, AuthenticatedAt: now, ExpireTime: now.Add(ttl), CreateTime: now, UpdateTime: now, LoginIP: metadata.LoginIP, UserAgent: metadata.UserAgent, ClientName: metadata.ClientName, OSName: metadata.OSName, DeviceType: metadata.DeviceType, LoginSource: metadata.LoginSource} if err = dal.InsertAccountSession(tx, session); err != nil { return "", nil, err } @@ -206,12 +230,16 @@ func issueSessionTx(tx *gorm.DB, user *model.UserAccount) (string, *model.Accoun } // IssueVerifiedSession 为已验证身份签发会话,锁内复核认证版本、账号状态和临时密码有效期。 -func IssueVerifiedSession(ctx context.Context, id, version int64) (string, error) { +func IssueVerifiedSession(ctx context.Context, id, version int64, metadata ...SessionMetadata) (string, error) { database, err := database(ctx) if err != nil { return "", err } var token string + info := NewSessionMetadata("", "", "unknown") + if len(metadata) > 0 { + info = NewSessionMetadata(metadata[0].LoginIP, metadata[0].UserAgent, metadata[0].LoginSource) + } err = database.Transaction(func(tx *gorm.DB) error { user, e := dal.QueryAccount(tx, id, true) if e != nil { @@ -223,7 +251,7 @@ func IssueVerifiedSession(ctx context.Context, id, version int64) (string, error if user.MustChangePassword && (user.PasswordExpiresAt == nil || !user.PasswordExpiresAt.After(time.Now())) { return ErrCredentials } - token, _, e = issueSessionTx(tx, user) + token, _, e = issueSessionTx(tx, user, info) return e }) return token, normalizeError(err) @@ -243,18 +271,19 @@ func CheckSession(ctx context.Context, token string, allowPasswordChange bool) ( if err != nil { return nil, nil, normalizeError(err) } - if session == nil || session.IsExpired != 0 || !session.ExpireTime.After(time.Now()) { + if session == nil || len(session.TokenDigest) == 0 || session.IsExpired != 0 || !session.ExpireTime.After(time.Now()) { return nil, nil, apperrors.ErrUnauthorized } user, err := GetByID(ctx, session.UserID) if err != nil { return nil, nil, normalizeError(err) } - if user == nil || user.Status != model.AccountActive || user.AuthVersion != session.AuthVersion { + now := time.Now() + if user == nil || user.Status != model.AccountActive || user.AuthVersion != session.AuthVersion || len(session.TokenDigest) == 0 || session.IsExpired != 0 || !session.ExpireTime.After(now) { return nil, nil, apperrors.ErrUnauthorized } if user.MustChangePassword || session.Purpose == model.SessionPasswordChange { - if !allowPasswordChange || session.Purpose != model.SessionPasswordChange || user.PasswordExpiresAt == nil || !user.PasswordExpiresAt.After(time.Now()) { + if !allowPasswordChange || !sessionUsableAt(user, session, now) { return nil, nil, apperrors.ErrForbidden } } else if session.Purpose != model.SessionUser { diff --git a/domain/service/accounts/session_limit_test.go b/domain/service/accounts/session_limit_test.go new file mode 100644 index 0000000..2fc3148 --- /dev/null +++ b/domain/service/accounts/session_limit_test.go @@ -0,0 +1,248 @@ +package accounts + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "strconv" + "strings" + "testing" + "time" + + "github.com/mcoder2014/home_server/config" + "github.com/mcoder2014/home_server/domain/dal" + "github.com/mcoder2014/home_server/domain/model" + apperrors "github.com/mcoder2014/home_server/errors" + "gorm.io/gorm" +) + +func setSessionLimit(t *testing.T, limit int) { + t.Helper() + snapshot := config.Runtime() + snapshot.AccountPolicy.MaxActiveSessions = limit + if err := config.StoreRuntimeSnapshot(snapshot); err != nil { + t.Fatal(err) + } +} + +// sessionLimitDatabase restores the real registry default after the other +// session tests' explicitly larger fixture limit. Production defaults stay intact. +func sessionLimitDatabase(t *testing.T) *gorm.DB { + t.Helper() + database := sessionTestDatabase(t) + defaults, err := config.BuildRuntimeSnapshot(config.Global(), 0, nil, config.DefaultRuntimeValues(config.Global())) + if err != nil || defaults.AccountPolicy.MaxActiveSessions != 5 { + t.Fatalf("expected the production default of five sessions: policy=%v error=%v", defaults.AccountPolicy, err) + } + setSessionLimit(t, defaults.AccountPolicy.MaxActiveSessions) + return database +} + +func TestSessionLimitDefaultRejectsSixthLoginAndKeepsExistingSessions(t *testing.T) { + database := sessionLimitDatabase(t) + ctx := context.Background() + tokens := make([]string, 0, 5) + for i := 0; i < 5; i++ { + _, token, _, err := Login(ctx, "session8101", reviewPassword) + if err != nil { + t.Fatal(err) + } + 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 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) + } + 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) + } + } + page, err := ListSessions(ctx, tokens[0], SessionFilter{}) + if err != nil || page.TotalCount != 5 { + t.Fatalf("session page omitted the applied limit: page=%v error=%v", page, err) + } + raw, err := json.Marshal(page) + var body map[string]interface{} + if err != nil || json.Unmarshal(raw, &body) != nil || body["max_active_sessions"] != float64(5) { + t.Fatalf("session page must expose the applied limit: %s", raw) + } +} + +func TestSessionLimitConcurrentMixedIssuanceNeverExceedsCap(t *testing.T) { + for _, parallelism := range []int{12, 32} { + t.Run(strconv.Itoa(parallelism), func(t *testing.T) { + database := sessionLimitDatabase(t) + start := make(chan struct{}) + results := make(chan error, parallelism) + for i := 0; i < parallelism; i++ { + go func(modern bool) { + <-start + var err error + if modern { + _, _, _, err = Login(context.Background(), "session8101", reviewPassword) + } else { + _, err = IssueVerifiedSession(context.Background(), 8101, 1) + } + results <- err + }(i%2 == 0) + } + close(start) + issued, limited := 0, 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) + } + }) + } +} + +func TestSessionLimitUsesCurrentReadAfterAnEarlierTransactionSnapshot(t *testing.T) { + database := sessionLimitDatabase(t) + setSessionLimit(t, 1) + ready, resume := make(chan struct{}), make(chan struct{}) + result := make(chan error, 1) + go func() { + result <- database.Transaction(func(tx *gorm.DB) error { + var rows int64 + if err := tx.Table(dal.TableUserToken).Where("user_id = ?", 8101).Count(&rows).Error; err != nil { + return err + } + close(ready) + <-resume + user, err := dal.QueryAccount(tx, 8101, true) + if err != nil { + return err + } + _, _, err = issueSessionTx(tx, user, SessionMetadata{}) + return err + }, &sql.TxOptions{Isolation: sql.LevelRepeatableRead}) + }() + defer close(resume) + select { + case <-ready: + case err := <-result: + t.Fatal("snapshot setup failed:", err) + case <-time.After(5 * time.Second): + t.Fatal("transaction did not establish its initial snapshot") + } + if _, err := IssueVerifiedSession(context.Background(), 8101, 1); 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) + } + case <-time.After(5 * time.Second): + t.Fatal("session check did not finish") + } +} + +func TestSessionLimitInactiveRowsDoNotConsumeSlotsAndRestrictedRowsDo(t *testing.T) { + database := sessionLimitDatabase(t) + setSessionLimit(t, 1) + now := time.Now().Truncate(time.Second) + sessionTestInsert(t, database, 9600, 8101, model.SessionUser, now.Add(-time.Hour), now.Add(-time.Second)) + sessionTestInsert(t, database, 9601, 8101, model.SessionUser, now, now.Add(time.Hour)) + sessionTestInsert(t, database, 9602, 8101, model.SessionUser, now, now.Add(time.Hour)) + sessionTestInsert(t, database, 9603, 8101, model.SessionUser, now, now.Add(time.Hour)) + sessionTestInsert(t, database, 9604, 8101, "unknown_purpose", now, now.Add(time.Hour)) + sessionTestInsert(t, database, 9605, 8101, model.SessionPasswordChange, now, now.Add(time.Hour)) + for id, fields := range map[int64]map[string]interface{}{9601: {"is_expired": 1}, 9602: {"auth_version": 0}, 9603: {"token_digest": nil}} { + if err := database.Table(dal.TableUserToken).Where("id = ?", id).Updates(fields).Error; err != nil { + t.Fatal(err) + } + } + past := now.Add(-time.Second) + if err := database.Table(dal.AccountTable).Where("id = ?", 8101).Update("password_expires_at", past).Error; err != nil { + t.Fatal(err) + } + if _, err := IssueVerifiedSession(context.Background(), 8101, 1); err != nil { + t.Fatal("expired/revoked/stale/unsupported rows consumed a slot:", err) + } + future := now.Add(time.Hour) + if err := database.Table(dal.AccountTable).Where("id = ?", 8102).Updates(map[string]interface{}{"must_change_password": true, "password_expires_at": future}).Error; err != nil { + t.Fatal(err) + } + restricted, err := IssueVerifiedSession(context.Background(), 8102, 1) + 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) + } + if _, _, err := CheckSession(context.Background(), restricted, true); err != nil { + t.Fatal("limited login was revoked by a rejected new login:", err) + } +} + +func TestSessionLimitLoweringKeepsOldLoginsAndRevocationReleasesSlots(t *testing.T) { + sessionLimitDatabase(t) + ctx := context.Background() + tokens := []string{} + for i := 0; i < 5; i++ { + token, err := IssueVerifiedSession(ctx, 8101, 1) + if err != nil { + t.Fatal(err) + } + tokens = append(tokens, token) + } + setSessionLimit(t, 2) + for _, token := range tokens { + if _, _, err := CheckSession(ctx, token, false); err != nil { + 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) + } + if result, err := RevokeOtherSessions(ctx, tokens[0]); err != nil || result.RevokedCount != 4 { + 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) + } + setSessionLimit(t, 3) + if _, err := IssueVerifiedSession(ctx, 8101, 1); err != nil { + t.Fatal("raised cap was not applied to the next login:", err) + } +} + +func TestSessionLimitInvalidRuntimeFailsClosed(t *testing.T) { + for _, limit := range []int{0, -1, 101} { + t.Run(strconv.Itoa(limit), func(t *testing.T) { + database := sessionLimitDatabase(t) + setSessionLimit(t, limit) + if token, err := IssueVerifiedSession(context.Background(), 8101, 1); token != "" || !errors.Is(err, apperrors.ErrDependency) { + t.Fatalf("invalid runtime silently allowed an unbounded login: token_issued=%t error=%v", token != "", err) + } + var rows int64 + if err := database.Table(dal.TableUserToken).Count(&rows).Error; err != nil || rows != 0 { + t.Fatalf("invalid runtime issued a session: rows=%d error=%v", rows, err) + } + }) + } +} diff --git a/domain/service/accounts/sessions.go b/domain/service/accounts/sessions.go new file mode 100644 index 0000000..3030f92 --- /dev/null +++ b/domain/service/accounts/sessions.go @@ -0,0 +1,416 @@ +package accounts + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/json" + "errors" + "net" + "sort" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/mcoder2014/home_server/config" + "github.com/mcoder2014/home_server/domain/dal" + "github.com/mcoder2014/home_server/domain/model" + apperrors "github.com/mcoder2014/home_server/errors" + "github.com/mileusna/useragent" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +const SessionWarningThreshold = 10 + +// SessionMetadata describes issuance, never the device presenting a token later. +// These values are display hints and are not used for authentication. +type SessionMetadata struct { + LoginIP, UserAgent, ClientName, OSName, DeviceType, LoginSource string +} + +// NewSessionMetadata parses a bounded UTF-8 UA using the pinned parser. Unknown +// clients may still log in; untrusted IP/source values get an explicit fallback. +func NewSessionMetadata(loginIP, rawUA, source string) SessionMetadata { + metadata := SessionMetadata{LoginIP: "unknown", UserAgent: sessionText(rawUA, 1024), ClientName: "未知", OSName: "未知", DeviceType: "unknown", LoginSource: "unknown"} + if ip := net.ParseIP(strings.TrimSpace(loginIP)); ip != nil { + metadata.LoginIP = ip.String() + } + if source == "web" || source == "legacy" { + metadata.LoginSource = source + } + parsed := useragent.Parse(metadata.UserAgent) + if parsed.Name != "" && parsed.Name != parsed.String { + metadata.ClientName = sessionText(strings.TrimSpace(parsed.Name+" "+parsed.Version), 128) + } + if parsed.OS != "" { + metadata.OSName = sessionText(strings.TrimSpace(parsed.OS+" "+parsed.OSVersion), 128) + } + switch { + case parsed.Bot: + metadata.DeviceType = "bot" + case parsed.Tablet: + metadata.DeviceType = "tablet" + case parsed.Mobile: + metadata.DeviceType = "mobile" + case parsed.Desktop: + metadata.DeviceType = "desktop" + } + return metadata +} + +func sessionText(value string, limit int) string { + value = strings.ToValidUTF8(value, "�") + if len(value) <= limit { + return value + } + for limit > 0 && !utf8.RuneStart(value[limit]) { + limit-- + } + return value[:limit] +} + +// sessionUsableAt matches activeSessionQuery, including the temporary password's +// deadline. A password-change session is usable only on explicitly limited routes. +func sessionUsableAt(user *model.UserAccount, session *model.AccountSession, now time.Time) bool { + if user == nil || session == nil || user.Status != model.AccountActive || session.UserID != user.ID || len(session.TokenDigest) == 0 || session.IsExpired != 0 || session.AuthVersion != user.AuthVersion || !session.ExpireTime.After(now) { + return false + } + if session.Purpose == model.SessionUser { + return !user.MustChangePassword + } + return session.Purpose == model.SessionPasswordChange && user.PasswordExpiresAt != nil && user.PasswordExpiresAt.After(now) +} + +// activeSessionQuery is shared by lists, page counts, user aggregates and the +// revoke-others selection. Neither expired rows nor unknown purposes count. +func activeSessionQuery(tx *gorm.DB, now time.Time) *gorm.DB { + return tx.Table(dal.TableUserToken+" AS s"). + Joins("JOIN "+dal.AccountTable+" AS u ON u.id = s.user_id"). + Where("u.status = ? AND s.token_digest IS NOT NULL AND s.token_digest <> ? AND s.is_expired = 0 AND s.auth_version = u.auth_version AND s.expire_time > ? AND ((s.purpose = ? AND u.must_change_password = ?) OR (s.purpose = ? AND u.password_expires_at > ?))", + model.AccountActive, []byte{}, now, model.SessionUser, false, model.SessionPasswordChange, now) +} + +type sessionCount struct { + UserID int64 + Total int64 + Restricted int64 +} + +// sessionCountsTx performs one aggregate for the users already read by the +// caller. Constant user/version pairs let the expiry index skip obsolete rows. +func sessionCountsTx(tx *gorm.DB, users []*model.UserAccount, now time.Time) ([]sessionCount, error) { + var counts []sessionCount + if len(users) == 0 { + return counts, nil + } + pairs := make([]string, 0, len(users)) + args := make([]interface{}, 0, 2*len(users)) + for _, user := range users { + pairs = append(pairs, "(s.user_id = ? AND s.auth_version = ?)") + args = append(args, user.ID, user.AuthVersion) + } + err := activeSessionQuery(tx, now).Where("("+strings.Join(pairs, " OR ")+")", args...). + Select("s.user_id, COUNT(*) AS total, SUM(s.purpose = ?) AS restricted", model.SessionPasswordChange). + Group("s.user_id").Scan(&counts).Error + return counts, err +} + +// actingSessionTx bypasses request-level user snapshots. Writes always acquire +// the user lock before the acting session lock, as session issuance does. +func actingSessionTx(tx *gorm.DB, token string, lock, admin bool) (*model.UserAccount, *model.AccountSession, time.Time, error) { + if len(token) < 16 || len(token) > 256 { + return nil, nil, time.Time{}, apperrors.ErrUnauthorized + } + digest := sha256.Sum256([]byte(token)) + session, err := dal.QueryAccountSession(tx, digest[:]) + if err != nil { + return nil, nil, time.Time{}, err + } + if session == nil { + return nil, nil, time.Time{}, apperrors.ErrUnauthorized + } + user, err := dal.QueryAccount(tx, session.UserID, lock) + if err != nil { + return nil, nil, time.Time{}, err + } + if lock { + query := tx.Table(dal.TableUserToken).Select(dal.AccountSessionColumns).Where("id = ? AND token_digest = ?", session.ID, digest[:]).Clauses(clause.Locking{Strength: "UPDATE"}) + if err := query.Take(session).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + err = apperrors.ErrUnauthorized + } + return nil, nil, time.Time{}, err + } + } + now := time.Now() + if !sessionUsableAt(user, session, now) { + return nil, nil, now, apperrors.ErrUnauthorized + } + if user.MustChangePassword || session.Purpose != model.SessionUser { + return nil, nil, now, apperrors.ErrForbidden + } + if admin && user.Role != model.RoleAdmin { + return nil, nil, now, apperrors.ErrForbidden + } + return user, session, now, nil +} + +type SessionFilter struct { + Cursor string + Limit int +} + +type sessionCursor struct { + CreateTime time.Time `json:"create_time"` + ID int64 `json:"id,string"` +} + +type SessionView struct { + ID string `json:"id"` + LoginIP string `json:"login_ip"` + UserAgent string `json:"user_agent"` + ClientName string `json:"client_name"` + OSName string `json:"os_name"` + DeviceType string `json:"device_type"` + LoginSource string `json:"login_source"` + Purpose string `json:"purpose"` + AuthenticatedAt *time.Time `json:"authenticated_at"` + CreateTime time.Time `json:"create_time"` + ExpireTime time.Time `json:"expire_time"` + RemainingSeconds int64 `json:"remaining_seconds"` +} + +type SessionPage struct { + CurrentSession *SessionView `json:"current_session"` + Items []SessionView `json:"items"` + TotalCount int64 `json:"total_count"` + RestrictedCount int64 `json:"restricted_count"` + MaxActiveSessions int `json:"max_active_sessions"` + ServerTime time.Time `json:"server_time"` + HasMore bool `json:"has_more"` + NextCursor string `json:"next_cursor"` +} + +var sessionDisplayColumns = []string{"s.id", "s.login_ip", "s.user_agent", "s.client_name", "s.os_name", "s.device_type", "s.login_source", "s.purpose", "s.authenticated_at", "s.create_time", "s.expire_time"} + +func sessionView(user *model.UserAccount, session *model.AccountSession, now time.Time) SessionView { + expire := session.ExpireTime + if session.Purpose == model.SessionPasswordChange && user.PasswordExpiresAt != nil && user.PasswordExpiresAt.Before(expire) { + expire = *user.PasswordExpiresAt + } + value := SessionView{ID: strconv.FormatInt(session.ID, 10), LoginIP: session.LoginIP, UserAgent: sessionText(session.UserAgent, 1024), ClientName: sessionText(session.ClientName, 128), OSName: sessionText(session.OSName, 128), DeviceType: session.DeviceType, LoginSource: session.LoginSource, Purpose: session.Purpose, CreateTime: session.CreateTime, ExpireTime: expire, RemainingSeconds: int64(expire.Sub(now) / time.Second)} + if !session.AuthenticatedAt.IsZero() { + stamp := session.AuthenticatedAt + value.AuthenticatedAt = &stamp + } + if value.LoginSource == "" { + value.LoginSource = "unknown" + } + if value.DeviceType == "" { + value.DeviceType = "unknown" + } + return value +} + +// ListSessions returns the authenticated session separately and counts it in +// total_count. All rows and counts use one read-only repeatable-read snapshot. +func ListSessions(ctx context.Context, token string, filter SessionFilter) (*SessionPage, error) { + if len(token) < 16 || len(token) > 256 { + return nil, apperrors.ErrUnauthorized + } + return listSessionPage(ctx, token, 0, filter, false) +} + +// AdminUserSessions reads every effective session for the target without giving +// administrators a per-session revoke capability. +func AdminUserSessions(ctx context.Context, token string, targetID int64, filter SessionFilter) (*SessionPage, error) { + if targetID <= 0 { + return nil, apperrors.ErrInvalid + } + return listSessionPage(ctx, token, targetID, filter, true) +} + +// listSessionPage revalidates the acting token after middleware, rereads the +// target and computes current/items/counts using a single time and DB snapshot. +func listSessionPage(ctx context.Context, token string, targetID int64, filter SessionFilter, admin bool) (*SessionPage, error) { + if filter.Limit == 0 { + filter.Limit = 20 + } + if filter.Limit < 1 || filter.Limit > 100 || len(filter.Cursor) > 512 { + return nil, apperrors.ErrInvalid + } + var cursor sessionCursor + if filter.Cursor != "" { + raw, err := base64.RawURLEncoding.DecodeString(filter.Cursor) + if err != nil || json.Unmarshal(raw, &cursor) != nil || cursor.ID == 0 || cursor.CreateTime.Year() < 1000 || cursor.CreateTime.Year() > 9999 { + return nil, apperrors.ErrInvalid + } + } + database, err := database(ctx) + if err != nil { + return nil, err + } + page := &SessionPage{Items: []SessionView{}, MaxActiveSessions: config.Runtime().AccountPolicy.MaxActiveSessions} + err = database.Transaction(func(tx *gorm.DB) error { + user, current, now, e := actingSessionTx(tx, token, false, admin) + if e != nil { + return e + } + page.ServerTime = now + if admin { + user, e = dal.QueryAccount(tx, targetID, false) + if e != nil { + return e + } + if user == nil { + return apperrors.ErrNotFound + } + } else { + value := sessionView(user, current, now) + page.CurrentSession = &value + } + var counts struct{ TotalCount, RestrictedCount int64 } + if e = activeSessionQuery(tx, now).Where("s.user_id = ? AND s.auth_version = ?", user.ID, user.AuthVersion).Select("COUNT(*) AS total_count, COALESCE(SUM(s.purpose = ?), 0) AS restricted_count", model.SessionPasswordChange).Scan(&counts).Error; e != nil { + return e + } + page.TotalCount, page.RestrictedCount = counts.TotalCount, counts.RestrictedCount + // Pin the version already read in this snapshot for the index prefix. + query := activeSessionQuery(tx, now).Select(sessionDisplayColumns).Where("s.user_id = ? AND s.auth_version = ?", user.ID, user.AuthVersion) + // Within the largest permitted capacity, range candidates skip expired + // history. Larger legacy sets retain the paging index's LIMIT early exit. + if counts.TotalCount <= 100 { + query = query.Table(dal.TableUserToken + " AS s USE INDEX (idx_login_token_effective_range)") + } + if !admin { + query = query.Where("s.id <> ?", current.ID) + } + if filter.Cursor != "" { + query = query.Where("s.create_time < ? OR (s.create_time = ? AND s.id < ?)", cursor.CreateTime, cursor.CreateTime, cursor.ID) + } + var sessions []model.AccountSession + if e = query.Order("s.create_time DESC, s.id DESC").Limit(filter.Limit + 1).Find(&sessions).Error; e != nil { + return e + } + page.HasMore = len(sessions) > filter.Limit + if page.HasMore { + sessions = sessions[:filter.Limit] + last := sessions[len(sessions)-1] + raw, e := json.Marshal(sessionCursor{CreateTime: last.CreateTime, ID: last.ID}) + if e != nil { + return e + } + page.NextCursor = base64.RawURLEncoding.EncodeToString(raw) + } + for _, session := range sessions { + page.Items = append(page.Items, sessionView(user, &session, now)) + } + return nil + }, &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true}) + return page, normalizeError(err) +} + +type SessionRevokeResult struct { + RevokedCount int64 `json:"revoked_count"` + AlreadyInactiveCount int64 `json:"already_inactive_count"` +} + +// RevokeSessions validates the complete owned ID set before updating any row. +// Retries count already inactive owned records; invisible IDs reject the batch. +func RevokeSessions(ctx context.Context, token string, rawIDs []string) (*SessionRevokeResult, error) { + if len(rawIDs) < 1 || len(rawIDs) > 100 { + return nil, apperrors.ErrInvalid + } + unique := map[int64]bool{} + for _, raw := range rawIDs { + // Existing login_token IDs use a signed generator; stored negative IDs + // are valid identities and must remain manageable without regeneration. + digits := strings.TrimPrefix(raw, "-") + if len(digits) == 0 || len(raw) > 20 || strings.Trim(digits, "0123456789") != "" { + return nil, apperrors.ErrInvalid + } + id, err := strconv.ParseInt(raw, 10, 64) + if err != nil || id == 0 { + return nil, apperrors.ErrInvalid + } + unique[id] = true + } + ids := make([]int64, 0, len(unique)) + for id := range unique { + ids = append(ids, id) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + database, err := database(ctx) + if err != nil { + return nil, err + } + result := &SessionRevokeResult{} + err = database.Transaction(func(tx *gorm.DB) error { + user, acting, now, e := actingSessionTx(tx, token, true, false) + if e != nil { + return e + } + if unique[acting.ID] { + return apperrors.WithMessage(apperrors.ErrInvalid, "当前会话请使用退出登录") + } + var sessions []model.AccountSession + if e = tx.Table(dal.TableUserToken).Select(dal.AccountSessionColumns).Where("user_id = ? AND id IN ?", user.ID, ids).Order("id ASC").Clauses(clause.Locking{Strength: "UPDATE"}).Find(&sessions).Error; e != nil { + return e + } + if len(sessions) != len(ids) { + return apperrors.ErrNotFound + } + activeIDs := make([]int64, 0, len(sessions)) + for _, session := range sessions { + if sessionUsableAt(user, &session, now) { + activeIDs = append(activeIDs, session.ID) + } else { + result.AlreadyInactiveCount++ + } + } + if len(activeIDs) > 0 { + updated := tx.Table(dal.TableUserToken).Where("user_id = ? AND id IN ?", user.ID, activeIDs).Updates(map[string]interface{}{"is_expired": 1, "update_time": now}) + if updated.Error != nil { + return updated.Error + } + result.RevokedCount = updated.RowsAffected + } + return nil + }) + return result, normalizeError(err) +} + +// RevokeOtherSessions shares the user's issuance lock, then locks effective +// other sessions in ID order. A login created after this transaction survives. +func RevokeOtherSessions(ctx context.Context, token string) (*SessionRevokeResult, error) { + database, err := database(ctx) + if err != nil { + return nil, err + } + result := &SessionRevokeResult{} + err = database.Transaction(func(tx *gorm.DB) error { + user, acting, now, e := actingSessionTx(tx, token, true, false) + if e != nil { + return e + } + var sessions []struct{ ID int64 } + if e = activeSessionQuery(tx, now).Where("s.user_id = ? AND s.auth_version = ? AND s.id <> ?", user.ID, user.AuthVersion, acting.ID).Select("s.id").Order("s.id ASC").Clauses(clause.Locking{Strength: "UPDATE"}).Find(&sessions).Error; e != nil { + return e + } + ids := make([]int64, 0, len(sessions)) + for _, session := range sessions { + ids = append(ids, session.ID) + } + if len(ids) > 0 { + updated := tx.Table(dal.TableUserToken).Where("user_id = ? AND id IN ?", user.ID, ids).Updates(map[string]interface{}{"is_expired": 1, "update_time": now}) + if updated.Error != nil { + return updated.Error + } + result.RevokedCount = updated.RowsAffected + } + return nil + }) + return result, normalizeError(err) +} diff --git a/domain/service/accounts/sessions_test.go b/domain/service/accounts/sessions_test.go new file mode 100644 index 0000000..a849708 --- /dev/null +++ b/domain/service/accounts/sessions_test.go @@ -0,0 +1,638 @@ +package accounts + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os" + "strconv" + "strings" + "sync" + "testing" + "time" + "unicode/utf8" + + "github.com/go-sql-driver/mysql" + "github.com/mcoder2014/home_server/config" + "github.com/mcoder2014/home_server/domain/dal" + "github.com/mcoder2014/home_server/domain/db" + "github.com/mcoder2014/home_server/domain/model" + apperrors "github.com/mcoder2014/home_server/errors" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// sessionTestDatabase only writes to the named disposable session fixture. It +// does not require or read production configuration or credentials. +func sessionTestDatabase(t *testing.T) *gorm.DB { + t.Helper() + dsn := os.Getenv("ACCOUNTS_SESSIONS_TEST_DSN") + if dsn == "" { + t.Skip("set ACCOUNTS_SESSIONS_TEST_DSN to the dedicated synthetic session database") + } + parsed, err := mysql.ParseDSN(dsn) + if err != nil || !strings.HasPrefix(parsed.DBName, "home_server_account_sessions_test_") { + t.Fatal("unsafe session test database") + } + parsed.ParseTime = true + parsed.Loc = time.Local + parsed.MultiStatements = false + if err := db.InitDatabase(parsed.FormatDSN()); err != nil { + t.Fatal("session test database initialization failed") + } + database := db.MasterDB() + database.Logger = logger.Discard + old := config.Global() + config.SetGlobalConfig(config.Config{IdentitySource: "database"}) + snapshot := config.Runtime() + snapshot.AccountPolicy.BcryptCost = 4 + // General session regressions opt into a larger synthetic-only limit. + snapshot.AccountPolicy.MaxActiveSessions = 100 + if err := config.StoreRuntimeSnapshot(snapshot); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + config.SetGlobalConfig(old) + connection, _ := database.DB() + connection.Close() + }) + for _, table := range []struct { + name string + value interface{} + }{ + {dal.AccountTable, &model.UserAccount{}}, {dal.LoginAliasTable, &model.LoginAlias{}}, + {dal.TableUserToken, &model.AccountSession{}}, {dal.ApplicationTable, &model.Application{}}, + {dal.WebProjectTable, &model.WebProject{}}, {dal.InvitationTable, &model.UserInvitation{}}, + {dal.AdminAuditTable, &model.AdminAuditLog{}}, + } { + if err := database.Table(table.name).AutoMigrate(table.value); err != nil { + t.Fatal(err) + } + if err := database.Exec("DELETE FROM " + table.name).Error; err != nil { + t.Fatal(err) + } + } + // AutoMigrate does not install the production migration's required range index. + if !database.Migrator().HasIndex(dal.TableUserToken, "idx_login_token_effective_range") { + if err := database.Exec("ALTER TABLE login_token ADD KEY idx_login_token_effective_range (user_id,auth_version,is_expired,expire_time,create_time,id)").Error; err != nil { + t.Fatal(err) + } + } + hash, err := bcrypt.GenerateFromPassword([]byte(reviewPassword), 4) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC().Truncate(time.Second) + for _, id := range []int64{8101, 8102, 8103} { + user := &model.UserAccount{ID: id, Username: "session" + strconv.FormatInt(id, 10), UsernameKey: "session" + strconv.FormatInt(id, 10), DisplayName: "Synthetic session", PasswordHash: string(hash), Status: model.AccountActive, Role: model.RoleUser, WebDAVPermission: model.WebDAVNone, AuthVersion: 1, Revision: 1, InviteEligibleAt: now, Source: "test", CreateTime: now, UpdateTime: now} + if id == 8103 { + user.Role = model.RoleAdmin + } + if err := database.Transaction(func(tx *gorm.DB) error { return dal.InsertAccount(tx, user) }); err != nil { + t.Fatal(err) + } + } + return database +} + +func sessionTestToken(id int64) string { + return fmt.Sprintf("us_cq_synthetic_session_%d", id) +} + +func sessionTestInsert(t *testing.T, database *gorm.DB, id, userID int64, purpose string, create, expire time.Time) *model.AccountSession { + t.Helper() + digest := sha256.Sum256([]byte(sessionTestToken(id))) + session := &model.AccountSession{ID: id, UserID: userID, TokenDigest: digest[:], AuthVersion: 1, Purpose: purpose, AuthenticatedAt: create, CreateTime: create, UpdateTime: create, ExpireTime: expire} + if err := dal.InsertAccountSession(database, session); err != nil { + t.Fatal(err) + } + return session +} + +func TestSessionUserCountsExposeOnlyAuthenticatableSessions(t *testing.T) { + database := sessionTestDatabase(t) + now := time.Now().UTC().Truncate(time.Second) + for _, id := range []int64{8201, 8202, 8203} { + sessionTestInsert(t, database, id, 8101, model.SessionUser, now, now.Add(time.Hour)) + } + sessionTestInsert(t, database, 8204, 8101, model.SessionUser, now.Add(-time.Hour), now.Add(-time.Second)) + sessionTestInsert(t, database, 8205, 8101, "future_purpose", now, now.Add(time.Hour)) + sessionTestInsert(t, database, 8206, 8101, model.SessionUser, now, now.Add(time.Hour)) + if err := database.Table(dal.TableUserToken).Where("id = ?", 8206).Update("auth_version", 2).Error; err != nil { + t.Fatal(err) + } + temporaryExpiry := now.Add(5 * time.Minute) + if err := database.Table(dal.AccountTable).Where("id = ?", 8102).Updates(map[string]interface{}{"must_change_password": true, "password_expires_at": temporaryExpiry}).Error; err != nil { + t.Fatal(err) + } + sessionTestInsert(t, database, 8211, 8102, model.SessionPasswordChange, now, now.Add(time.Hour)) + sessionTestInsert(t, database, 8212, 8102, model.SessionUser, now, now.Add(time.Hour)) + page, err := ListUsers(context.Background(), dal.AccountFilter{Limit: 100}) + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(page) + if err != nil { + t.Fatal(err) + } + var body struct { + Items []map[string]interface{} `json:"items"` + } + if err := json.Unmarshal(raw, &body); err != nil { + t.Fatal(err) + } + for _, item := range body.Items { + switch item["id"] { + case "8101": + if item["active_session_count"] != float64(3) || item["restricted_session_count"] != float64(0) { + t.Fatalf("ordinary account counts must exclude unusable sessions: active=%v restricted=%v", item["active_session_count"], item["restricted_session_count"]) + } + case "8102": + if item["active_session_count"] != float64(1) || item["restricted_session_count"] != float64(1) { + t.Fatalf("temporary account count must contain only its valid restricted session: active=%v restricted=%v", item["active_session_count"], item["restricted_session_count"]) + } + } + } +} + +func TestSessionMetadataParsesBoundedUAAndCanonicalIP(t *testing.T) { + ua := "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" + metadata := NewSessionMetadata("2001:0db8:0000:0000:0000:0000:0000:0001", ua, "web") + if metadata.LoginIP != "2001:db8::1" || !strings.Contains(metadata.ClientName, "Chrome") || !strings.Contains(metadata.OSName, "Windows") || metadata.DeviceType != "desktop" || metadata.LoginSource != "web" || metadata.UserAgent != ua { + t.Fatalf("login descriptions must come from bounded login metadata: %+v", metadata) + } + metadata = NewSessionMetadata("not-an-ip", strings.Repeat("猫", 1000), "forged_source") + if len(metadata.UserAgent) > 1024 || !utf8.ValidString(metadata.UserAgent) || len(metadata.ClientName) > 128 || len(metadata.OSName) > 128 || metadata.LoginIP != "unknown" || metadata.LoginSource != "unknown" { + t.Fatal("unrecognized metadata must remain valid and bounded") + } + metadata = NewSessionMetadata("", "", "legacy") + if metadata.DeviceType != "unknown" || metadata.LoginSource != "legacy" { + t.Fatal("missing UA must not invent a device") + } +} + +func TestSessionLoginAndVerifiedIssuanceRetainMetadata(t *testing.T) { + database := sessionTestDatabase(t) + metadata := NewSessionMetadata("198.51.100.8", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", "web") + user, token, session, err := Login(context.Background(), "session8101", reviewPassword, metadata) + if err != nil || user == nil || session == nil { + t.Fatalf("metadata must not prevent valid login: %v", err) + } + _, stored, err := CheckSession(context.Background(), token, false) + if err != nil || stored.LoginIP != metadata.LoginIP || stored.UserAgent != metadata.UserAgent || stored.DeviceType != "mobile" || stored.LoginSource != "web" { + t.Fatalf("web session must retain login metadata: %v", err) + } + metadata.LoginSource = "legacy" + legacyToken, err := IssueVerifiedSession(context.Background(), user.ID, user.AuthVersion, metadata) + if err != nil { + t.Fatal(err) + } + _, legacy, err := CheckSession(context.Background(), legacyToken, false) + if err != nil || legacy.LoginSource != "legacy" || legacy.LoginIP != metadata.LoginIP || legacy.UserAgent != metadata.UserAgent { + t.Fatal("verified legacy session lost login metadata") + } + var count int64 + if err := database.Table(dal.TableUserToken).Where("user_id = ?", user.ID).Count(&count).Error; err != nil || count != 2 { + t.Fatal("each issuance must create exactly one session") + } +} + +func TestSessionListUsesCreateTimeAndIDCursorAndProtectsCurrent(t *testing.T) { + database := sessionTestDatabase(t) + now := time.Now().UTC().Truncate(time.Second) + expire := now.Add(time.Hour) + sessionTestInsert(t, database, 8200, 8101, model.SessionUser, now, expire) + sessionTestInsert(t, database, 8299, 8101, model.SessionUser, now.Add(-2*time.Minute), expire) + sessionTestInsert(t, database, 8202, 8101, model.SessionUser, now.Add(-time.Minute), expire) + sessionTestInsert(t, database, 8203, 8101, model.SessionUser, now.Add(-time.Minute), expire) + sessionTestInsert(t, database, 8204, 8101, model.SessionUser, now.Add(-time.Minute), now) + sessionTestInsert(t, database, 8205, 8101, "unknown", now, expire) + sessionTestInsert(t, database, 8206, 8101, model.SessionUser, now, expire) + if err := database.Table(dal.TableUserToken).Where("id = ?", 8206).Update("is_expired", 1).Error; err != nil { + t.Fatal(err) + } + page, err := ListSessions(context.Background(), sessionTestToken(8200), SessionFilter{Limit: 2}) + if err != nil { + t.Fatal(err) + } + if page.CurrentSession == nil || page.CurrentSession.ID != "8200" || page.TotalCount != 4 || page.RestrictedCount != 0 || len(page.Items) != 2 || page.Items[0].ID != "8203" || page.Items[1].ID != "8202" || !page.HasMore || page.NextCursor == "" { + t.Fatalf("current session must be separate and sorted by create_time,id: %+v", page) + } + last, err := ListSessions(context.Background(), sessionTestToken(8200), SessionFilter{Limit: 2, Cursor: page.NextCursor}) + if err != nil || len(last.Items) != 1 || last.Items[0].ID != "8299" || last.HasMore || last.NextCursor != "" || last.TotalCount != page.TotalCount { + t.Fatalf("cursor must use both create time and ID: %+v %v", last, err) + } + if page.CurrentSession.RemainingSeconds != int64(page.CurrentSession.ExpireTime.Sub(page.ServerTime)/time.Second) { + t.Fatal("remaining lifetime must use the response server time") + } + raw, _ := json.Marshal(page) + for _, private := range []string{"token_digest", "auth_version", "password_hash"} { + if strings.Contains(string(raw), private) { + t.Fatalf("session list leaked %s", private) + } + } +} + +func TestSessionRestrictedExpiryAndCountsMatchAuthentication(t *testing.T) { + database := sessionTestDatabase(t) + now := time.Now().UTC().Truncate(time.Second) + sessionTestInsert(t, database, 8300, 8103, model.SessionUser, now, now.Add(time.Hour)) + passwordExpiry := now.Add(3 * time.Minute) + if err := database.Table(dal.AccountTable).Where("id = ?", 8102).Updates(map[string]interface{}{"must_change_password": true, "password_expires_at": passwordExpiry}).Error; err != nil { + t.Fatal(err) + } + sessionTestInsert(t, database, 8311, 8102, model.SessionPasswordChange, now, now.Add(time.Hour)) + sessionTestInsert(t, database, 8312, 8102, model.SessionUser, now, now.Add(time.Hour)) + page, err := AdminUserSessions(context.Background(), sessionTestToken(8300), 8102, SessionFilter{Limit: 20}) + if err != nil || page.TotalCount != 1 || page.RestrictedCount != 1 || page.CurrentSession != nil || len(page.Items) != 1 || page.Items[0].ID != "8311" || !page.Items[0].ExpireTime.Equal(passwordExpiry) { + t.Fatalf("administrator must see only the authenticatable restricted session: %+v %v", page, err) + } + if _, _, err := CheckSession(context.Background(), sessionTestToken(8311), true); err != nil { + t.Fatal("listed restricted session must authenticate for password change") + } + if _, err := ListSessions(context.Background(), sessionTestToken(8311), SessionFilter{}); !errors.Is(err, apperrors.ErrForbidden) { + t.Fatal("restricted session must not gain access to login management") + } + if err := database.Table(dal.AccountTable).Where("id = ?", 8102).Update("password_expires_at", now).Error; err != nil { + t.Fatal(err) + } + page, err = AdminUserSessions(context.Background(), sessionTestToken(8300), 8102, SessionFilter{}) + if err != nil || page.TotalCount != 0 || page.RestrictedCount != 0 || len(page.Items) != 0 { + t.Fatal("expired initial password must remove all restricted sessions from counts") + } + if _, _, err := CheckSession(context.Background(), sessionTestToken(8311), true); err == nil { + t.Fatal("expired restricted session unexpectedly authenticated") + } +} + +func TestSessionBatchRevokeIsAtomicAndIdempotent(t *testing.T) { + database := sessionTestDatabase(t) + now := time.Now().UTC().Truncate(time.Second) + sessionTestInsert(t, database, 8400, 8101, model.SessionUser, now, now.Add(time.Hour)) + sessionTestInsert(t, database, 8401, 8101, model.SessionUser, now, now.Add(time.Hour)) + sessionTestInsert(t, database, 8402, 8101, model.SessionUser, now, now.Add(-time.Minute)) + sessionTestInsert(t, database, 8499, 8102, model.SessionUser, now, now.Add(time.Hour)) + for _, ids := range [][]string{{"8401", "8499"}, {"8401", "999999999"}} { + if _, err := RevokeSessions(context.Background(), sessionTestToken(8400), ids); !errors.Is(err, apperrors.ErrNotFound) { + t.Fatalf("invisible target must reject whole batch: %v", err) + } + if _, _, err := CheckSession(context.Background(), sessionTestToken(8401), false); err != nil { + t.Fatal("rejected mixed batch partially revoked an owned session") + } + } + if _, err := RevokeSessions(context.Background(), sessionTestToken(8400), []string{"8401", "8400"}); !errors.Is(err, apperrors.ErrInvalid) { + t.Fatal("current session must have its own logout action") + } + result, err := RevokeSessions(context.Background(), sessionTestToken(8400), []string{"8401", "8401", "8402"}) + if err != nil || result.RevokedCount != 1 || result.AlreadyInactiveCount != 1 { + t.Fatalf("batch must deduplicate IDs and count expired owned rows: %+v %v", result, err) + } + result, err = RevokeSessions(context.Background(), sessionTestToken(8400), []string{"8401", "8402"}) + if err != nil || result.RevokedCount != 0 || result.AlreadyInactiveCount != 2 { + t.Fatalf("same owned targets must be safely retryable: %+v %v", result, err) + } + if _, _, err := CheckSession(context.Background(), sessionTestToken(8400), false); err != nil { + t.Fatal("batch logout revoked the current session") + } + if _, _, err := CheckSession(context.Background(), sessionTestToken(8499), false); err != nil { + t.Fatal("batch logout modified another account") + } +} + +func TestSessionRevokeOthersRetainsCurrentVersionAndApplications(t *testing.T) { + database := sessionTestDatabase(t) + now := time.Now().UTC().Truncate(time.Second) + sessionTestInsert(t, database, 8500, 8101, model.SessionUser, now, now.Add(time.Hour)) + for id := int64(8501); id <= 8525; id++ { + sessionTestInsert(t, database, id, 8101, model.SessionUser, now, now.Add(time.Hour)) + } + sessionTestInsert(t, database, 8599, 8101, model.SessionUser, now, now.Add(-time.Minute)) + app := &model.Application{ID: 8600, OwnerUserID: 8101, Name: "Synthetic application", AccessKey: "synthetic_key", SecretDigest: []byte("synthetic_digest"), Scopes: []string{"library:read"}, Status: model.ApplicationStatusEnabled, Revision: 1, SecretVersion: 1, ExpiresAt: now.Add(time.Hour), CreateTime: now, UpdateTime: now} + if err := database.Table(dal.ApplicationTable).Create(app).Error; err != nil { + t.Fatal(err) + } + result, err := RevokeOtherSessions(context.Background(), sessionTestToken(8500)) + if err != nil || result.RevokedCount != 25 { + t.Fatalf("revoke others must cover sessions beyond a loaded page: %+v %v", result, err) + } + user, _, err := CheckSession(context.Background(), sessionTestToken(8500), false) + if err != nil || user.AuthVersion != 1 { + t.Fatal("selective logout must preserve current session and auth version") + } + if err := database.Table(dal.ApplicationTable).Select("status", "revision").Where("id = ?", app.ID).Take(app).Error; err != nil || app.Status != model.ApplicationStatusEnabled || app.Revision != 1 { + t.Fatal("selective logout must not disable application credentials") + } + page, err := ListSessions(context.Background(), sessionTestToken(8500), SessionFilter{}) + if err != nil || page.TotalCount != 1 || len(page.Items) != 0 { + t.Fatal("only the current login should remain") + } +} + +func TestSessionServicesRevalidateActingSessionAfterMiddleware(t *testing.T) { + database := sessionTestDatabase(t) + now := time.Now().UTC().Truncate(time.Second) + sessionTestInsert(t, database, 8700, 8101, model.SessionUser, now, now.Add(time.Hour)) + sessionTestInsert(t, database, 8701, 8101, model.SessionUser, now, now.Add(time.Hour)) + ctx := WithReadSnapshot(context.Background()) + if _, _, err := CheckSession(ctx, sessionTestToken(8700), false); err != nil { + t.Fatal(err) + } + if err := Logout(context.Background(), sessionTestToken(8700)); err != nil { + t.Fatal(err) + } + if _, err := ListSessions(ctx, sessionTestToken(8700), SessionFilter{}); !errors.Is(err, apperrors.ErrUnauthorized) { + t.Fatal("list trusted stale middleware authentication") + } + if _, err := RevokeSessions(ctx, sessionTestToken(8700), []string{"8701"}); !errors.Is(err, apperrors.ErrUnauthorized) { + t.Fatal("batch write trusted an already revoked acting session") + } + if _, err := RevokeOtherSessions(ctx, sessionTestToken(8700)); !errors.Is(err, apperrors.ErrUnauthorized) { + t.Fatal("logout others trusted an already revoked acting session") + } + if _, _, err := CheckSession(context.Background(), sessionTestToken(8701), false); err != nil { + t.Fatal("rejected stale request changed the target session") + } +} + +func TestSessionAdministratorReadRequiresLiveAdministratorSession(t *testing.T) { + database := sessionTestDatabase(t) + now := time.Now().UTC().Truncate(time.Second) + sessionTestInsert(t, database, 8800, 8101, model.SessionUser, now, now.Add(time.Hour)) + sessionTestInsert(t, database, 8803, 8103, model.SessionUser, now, now.Add(time.Hour)) + if _, err := AdminUserSessions(context.Background(), sessionTestToken(8800), 8102, SessionFilter{}); !errors.Is(err, apperrors.ErrForbidden) { + t.Fatal("ordinary user could enumerate another account's sessions") + } + ctx := WithReadSnapshot(context.Background()) + if _, _, err := CheckSession(ctx, sessionTestToken(8803), false); err != nil { + t.Fatal(err) + } + if err := database.Table(dal.AccountTable).Where("id = ?", 8103).Update("role", model.RoleUser).Error; err != nil { + t.Fatal(err) + } + if _, err := AdminUserSessions(ctx, sessionTestToken(8803), 8102, SessionFilter{}); !errors.Is(err, apperrors.ErrForbidden) { + t.Fatal("administrator read trusted the stale request user cache") + } +} + +func TestSessionInputLimitsRejectMalformedIDsAndCursor(t *testing.T) { + database := sessionTestDatabase(t) + now := time.Now().UTC().Truncate(time.Second) + sessionTestInsert(t, database, 8900, 8101, model.SessionUser, now, now.Add(time.Hour)) + tooMany := make([]string, 101) + for i := range tooMany { + tooMany[i] = "8901" + } + for _, ids := range [][]string{nil, {}, {"0"}, {"-0"}, {"-"}, {"--1"}, {"-+1"}, {"+1"}, {"1e3"}, {"9223372036854775808"}, {"-9223372036854775809"}, tooMany} { + if _, err := RevokeSessions(context.Background(), sessionTestToken(8900), ids); !errors.Is(err, apperrors.ErrInvalid) { + t.Fatalf("invalid selection was accepted: %v", ids) + } + } + zeroTimeCursor := base64.RawURLEncoding.EncodeToString([]byte(`{"id":"1","create_time":"0001-01-01T00:00:00Z"}`)) + missingTimeCursor := base64.RawURLEncoding.EncodeToString([]byte(`{"id":"1"}`)) + for _, filter := range []SessionFilter{{Limit: -1}, {Limit: 101}, {Cursor: "not-a-cursor"}, {Cursor: strings.Repeat("a", 1000)}, {Cursor: zeroTimeCursor}, {Cursor: missingTimeCursor}} { + if _, err := ListSessions(context.Background(), sessionTestToken(8900), filter); !errors.Is(err, apperrors.ErrInvalid) { + t.Fatal("invalid pagination was accepted") + } + } +} + +func TestSessionConcurrentRevokeIsIdempotentAndPreservesCurrent(t *testing.T) { + database := sessionTestDatabase(t) + now := time.Now().UTC().Truncate(time.Second) + sessionTestInsert(t, database, 9000, 8101, model.SessionUser, now, now.Add(time.Hour)) + sessionTestInsert(t, database, 9001, 8101, model.SessionUser, now, now.Add(time.Hour)) + var group sync.WaitGroup + results := make(chan *SessionRevokeResult, 2) + errors := make(chan error, 2) + for i := 0; i < 2; i++ { + group.Add(1) + go func() { + defer group.Done() + result, err := RevokeSessions(context.Background(), sessionTestToken(9000), []string{"9001"}) + results <- result + errors <- err + }() + } + group.Wait() + close(results) + close(errors) + for err := range errors { + if err != nil { + t.Fatalf("concurrent same-target revoke must serialize without errors: %v", err) + } + } + var revoked, inactive int64 + for result := range results { + revoked += result.RevokedCount + inactive += result.AlreadyInactiveCount + } + if revoked != 1 || inactive != 1 { + t.Fatal("concurrent revoke did not preserve idempotent counts") + } + if _, _, err := CheckSession(context.Background(), sessionTestToken(9000), false); err != nil { + t.Fatal("concurrent revokes affected current session") + } +} + +func TestSessionEffectiveCountsMatchAuthenticationOnAllBoundaries(t *testing.T) { + database := sessionTestDatabase(t) + now := time.Now().Truncate(time.Second) + sessionTestInsert(t, database, 9100, 8103, model.SessionUser, now, now.Add(time.Hour)) + for _, test := range []struct { + name string + status, purpose string + mustChange, emptyDigest bool + oldVersion, revoked bool + valid, restricted bool + sessionExpiry, passwordTTL time.Duration + }{ + {name: "ordinary", status: model.AccountActive, purpose: model.SessionUser, sessionExpiry: time.Hour, valid: true}, + {name: "revoked", status: model.AccountActive, purpose: model.SessionUser, sessionExpiry: time.Hour, revoked: true}, + {name: "expired", status: model.AccountActive, purpose: model.SessionUser, sessionExpiry: -time.Second}, + {name: "old_version", status: model.AccountActive, purpose: model.SessionUser, sessionExpiry: time.Hour, oldVersion: true}, + {name: "empty_digest", status: model.AccountActive, purpose: model.SessionUser, sessionExpiry: time.Hour, emptyDigest: true}, + {name: "banned", status: model.AccountBanned, purpose: model.SessionUser, sessionExpiry: time.Hour}, + {name: "deleted", status: model.AccountDeleted, purpose: model.SessionUser, sessionExpiry: time.Hour}, + {name: "unknown_purpose", status: model.AccountActive, purpose: "unexpected", sessionExpiry: time.Hour}, + {name: "ordinary_during_initial_password", status: model.AccountActive, purpose: model.SessionUser, sessionExpiry: time.Hour, mustChange: true, passwordTTL: time.Hour}, + {name: "restricted", status: model.AccountActive, purpose: model.SessionPasswordChange, sessionExpiry: time.Hour, mustChange: true, passwordTTL: time.Minute, valid: true, restricted: true}, + {name: "restricted_after_initial_expiry", status: model.AccountActive, purpose: model.SessionPasswordChange, sessionExpiry: time.Hour, mustChange: true, passwordTTL: -time.Second}, + {name: "restricted_without_deadline", status: model.AccountActive, purpose: model.SessionPasswordChange, sessionExpiry: time.Hour, mustChange: true}, + {name: "restricted_without_mustchange", status: model.AccountActive, purpose: model.SessionPasswordChange, sessionExpiry: time.Hour, passwordTTL: time.Hour, valid: true, restricted: true}, + } { + t.Run(test.name, func(t *testing.T) { + if err := database.Table(dal.TableUserToken).Where("user_id = ?", 8102).Delete(&model.AccountSession{}).Error; err != nil { + t.Fatal(err) + } + fields := map[string]interface{}{"status": test.status, "must_change_password": test.mustChange, "password_expires_at": nil} + if test.passwordTTL != 0 { + fields["password_expires_at"] = now.Add(test.passwordTTL) + } + if err := database.Table(dal.AccountTable).Where("id = ?", 8102).Updates(fields).Error; err != nil { + t.Fatal(err) + } + session := sessionTestInsert(t, database, 9102, 8102, test.purpose, now, now.Add(test.sessionExpiry)) + if test.revoked { + session.IsExpired = 1 + } + if test.oldVersion { + session.AuthVersion = 2 + } + if test.emptyDigest { + session.TokenDigest = []byte{} + } + if err := database.Table(dal.TableUserToken).Where("id = ?", session.ID).Select("is_expired", "auth_version", "token_digest").Updates(session).Error; err != nil { + t.Fatal(err) + } + page, err := AdminUserSessions(context.Background(), sessionTestToken(9100), 8102, SessionFilter{}) + if err != nil { + t.Fatal(err) + } + if (page.TotalCount == 1) != test.valid || (page.RestrictedCount == 1) != test.restricted || (len(page.Items) == 1) != test.valid { + t.Fatalf("counts disagree with effective conditions: %+v", page) + } + _, _, err = CheckSession(context.Background(), sessionTestToken(9102), true) + if (err == nil) != test.valid { + t.Fatalf("authentication disagrees with listed validity: %v", err) + } + }) + } +} + +func TestSessionConcurrentLoginAndRevokeOthersFollowUserLock(t *testing.T) { + database := sessionTestDatabase(t) + now := time.Now().Truncate(time.Second) + sessionTestInsert(t, database, 9200, 8101, model.SessionUser, now, now.Add(time.Hour)) + sessionTestInsert(t, database, 9201, 8101, model.SessionUser, now, now.Add(time.Hour)) + for i := 0; i < 12; i++ { + if err := database.Table(dal.TableUserToken).Where("id = ?", 9201).Update("is_expired", 0).Error; err != nil { + t.Fatal(err) + } + start := make(chan struct{}) + issued := make(chan string, 1) + issueErrors := make(chan error, 1) + revoked := make(chan *SessionRevokeResult, 1) + revokeErrors := make(chan error, 1) + go func() { + <-start + token, err := IssueVerifiedSession(context.Background(), 8101, 1) + issued <- token + issueErrors <- err + }() + go func() { + <-start + result, err := RevokeOtherSessions(context.Background(), sessionTestToken(9200)) + revoked <- result + revokeErrors <- err + }() + close(start) + token, result := <-issued, <-revoked + if err := <-issueErrors; err != nil { + t.Fatalf("concurrent issue failed: %v", err) + } + if err := <-revokeErrors; err != nil { + t.Fatalf("concurrent revoke failed: %v", err) + } + _, _, checkError := CheckSession(context.Background(), token, false) + if checkError == nil && result.RevokedCount != 1 || errors.Is(checkError, apperrors.ErrUnauthorized) && result.RevokedCount != 2 { + t.Fatalf("new login did not match the user-lock order: revoked=%d active=%v", result.RevokedCount, checkError == nil) + } + if checkError != nil && !errors.Is(checkError, apperrors.ErrUnauthorized) { + t.Fatal(checkError) + } + if _, _, err := CheckSession(context.Background(), sessionTestToken(9201), false); !errors.Is(err, apperrors.ErrUnauthorized) { + t.Fatal("previously existing other login survived revoke others") + } + if err := Logout(context.Background(), token); err != nil { + t.Fatal(err) + } + } +} + +func TestSessionAuditUsesCurrentIdentityAndPreservesLegacyActorName(t *testing.T) { + database := sessionTestDatabase(t) + now := time.Now().Truncate(time.Second) + if err := database.Table(dal.AccountTable).Where("id = ?", 8103).Updates(map[string]interface{}{"display_name": "当前昵称", "avatar_version": 7}).Error; err != nil { + t.Fatal(err) + } + log := &model.AdminAuditLog{ID: 9300, ActorUserID: 8103, Action: "synthetic-action", TargetType: "user", TargetID: 8101, BeforeSummary: `{"display_name":"历史昵称"}`, AfterSummary: `{}`, Reason: "Synthetic fixture", Result: "success", CreateTime: now} + if err := database.Table(dal.AdminAuditTable).Create(log).Error; err != nil { + t.Fatal(err) + } + page, err := ListAudit(context.Background(), 0, 20, "", 0) + if err != nil { + t.Fatal(err) + } + items := page["items"].([]AuditView) + if len(items) != 1 || items[0].ActorUser == nil || items[0].ActorUser.DisplayName != "当前昵称" || items[0].ActorUser.AvatarURL != "/api/account/avatars/8103/7" || items[0].ActorUserName != "session8103" || items[0].Before["display_name"] != "历史昵称" { + t.Fatal("audit must show current identity while retaining legacy name and historical evidence") + } + if err := database.Table(dal.AccountTable).Where("id = ?", 8103).Update("status", model.AccountDeleted).Error; err != nil { + t.Fatal(err) + } + page, err = ListAudit(context.Background(), 0, 20, "", 0) + if err != nil { + t.Fatal(err) + } + items = page["items"].([]AuditView) + if items[0].ActorUserName != "session8103" || items[0].ActorUser.DisplayName != "已注销用户" || items[0].ActorUser.AvatarURL != "" { + t.Fatal("deletion must preserve the legacy actor username for authorized audit readers") + } +} + +func TestSessionAggregateDatabaseFailureDoesNotBecomeZero(t *testing.T) { + database := sessionTestDatabase(t) + if err := database.Migrator().DropTable(dal.TableUserToken); err != nil { + t.Fatal(err) + } + if _, err := ListUsers(context.Background(), dal.AccountFilter{Limit: 20}); !errors.Is(err, apperrors.ErrDependency) { + t.Fatal("failed session aggregate became successful user counts") + } + if _, err := UserDetail(context.Background(), 8101); !errors.Is(err, apperrors.ErrDependency) { + t.Fatal("failed session aggregate became invented zero detail counts") + } +} + +// TestSessionPersistedSignedIDsPaginateWithoutChangingAuthentication reproduces +// the existing generator's signed-overflow IDs without depending on wall time. +func TestSessionPersistedSignedIDsPaginateWithoutChangingAuthentication(t *testing.T) { + database := sessionTestDatabase(t) + now := time.Now().Truncate(time.Second) + sessionTestInsert(t, database, -9400, 8101, model.SessionUser, now, now.Add(time.Hour)) + sessionTestInsert(t, database, -9401, 8101, model.SessionUser, now.Add(-time.Minute), now.Add(time.Hour)) + sessionTestInsert(t, database, -9402, 8101, model.SessionUser, now.Add(-2*time.Minute), now.Add(time.Hour)) + page, err := ListSessions(context.Background(), sessionTestToken(-9400), SessionFilter{Limit: 1}) + if err != nil || page.CurrentSession == nil || page.CurrentSession.ID != "-9400" || len(page.Items) != 1 || page.Items[0].ID != "-9401" || !page.HasMore { + t.Fatalf("stored signed IDs must remain authenticatable and visible: %+v %v", page, err) + } + last, err := ListSessions(context.Background(), sessionTestToken(-9400), SessionFilter{Limit: 1, Cursor: page.NextCursor}) + if err != nil || len(last.Items) != 1 || last.Items[0].ID != "-9402" || last.HasMore { + t.Fatalf("server-issued signed cursor must remain valid: %+v %v", last, err) + } +} + +func TestSessionPersistedSignedIDsRevokeAtomicallyAndProtectCurrent(t *testing.T) { + database := sessionTestDatabase(t) + now := time.Now().Truncate(time.Second) + sessionTestInsert(t, database, -9500, 8101, model.SessionUser, now, now.Add(time.Hour)) + sessionTestInsert(t, database, -9501, 8101, model.SessionUser, now, now.Add(time.Hour)) + sessionTestInsert(t, database, -9599, 8102, model.SessionUser, now, now.Add(time.Hour)) + sessionTestInsert(t, database, -9223372036854775808, 8101, model.SessionUser, now, now.Add(-time.Minute)) + result, err := RevokeSessions(context.Background(), sessionTestToken(-9500), []string{"-9501", "-9223372036854775808"}) + if err != nil || result.RevokedCount != 1 || result.AlreadyInactiveCount != 1 { + t.Fatalf("stored signed IDs must support selected logout: %+v %v", result, err) + } + if _, err := RevokeSessions(context.Background(), sessionTestToken(-9500), []string{"-9599"}); !errors.Is(err, apperrors.ErrNotFound) { + t.Fatalf("signed foreign ID must remain invisible: %v", err) + } + if _, err := RevokeSessions(context.Background(), sessionTestToken(-9500), []string{"-9500"}); !errors.Is(err, apperrors.ErrInvalid) { + t.Fatal("signed current ID bypassed the current-session protection") + } + if _, _, err := CheckSession(context.Background(), sessionTestToken(-9500), false); err != nil { + t.Fatal("selected logout revoked the signed current session") + } +} diff --git a/domain/service/passport/token.go b/domain/service/passport/token.go index 3af5c93..a4e83da 100644 --- a/domain/service/passport/token.go +++ b/domain/service/passport/token.go @@ -30,6 +30,15 @@ func GenToken(identity *model.UserIdentity) (string, error) { return token.Token, nil } +// GenTokenWithContext retains request cancellation and login metadata on the +// database-backed legacy path while preserving config-mode token semantics. +func GenTokenWithContext(ctx context.Context, identity *model.UserIdentity, metadata ...accounts.SessionMetadata) (string, error) { + if accounts.DatabaseMode() { + return accounts.IssueVerifiedSession(ctx, identity.ID, identity.AuthVersion, metadata...) + } + return GenToken(identity) +} + // CheckToken 按身份来源验证会话并带回到期时间;旧版令牌到期后同时标记数据库中的失效状态。 func CheckToken(ctx context.Context, token string) (*model.UserIdentity, error) { if accounts.DatabaseMode() { diff --git a/domain/service/webcomments/service.go b/domain/service/webcomments/service.go index 34cfe64..47b0072 100644 --- a/domain/service/webcomments/service.go +++ b/domain/service/webcomments/service.go @@ -14,6 +14,7 @@ import ( "github.com/mcoder2014/home_server/domain/dal" "github.com/mcoder2014/home_server/domain/db" "github.com/mcoder2014/home_server/domain/service/accounts" + "github.com/mcoder2014/home_server/domain/service/passport" apperrors "github.com/mcoder2014/home_server/errors" "github.com/mcoder2014/home_server/utils" "gorm.io/gorm" @@ -35,6 +36,40 @@ type EventPage struct { NextSequence string `json:"next_seq"` } +// commentUsers reads current identities in both supported modes; snapshots remain database evidence. +func commentUsers(ctx context.Context, ids []int64) (map[int64]accounts.UserDisplay, error) { + identities := map[int64]accounts.UserDisplay{} + if accounts.DatabaseMode() { + var err error + identities, err = accounts.DisplayUsers(ctx, ids) + if err != nil { + return nil, err + } + } else { + for _, id := range ids { + identity := accounts.UserDisplay{UserID: id, DisplayName: "已注销用户"} + user, err := passport.GetMockData().GetByID(id) + if err != nil { + return nil, apperrors.ErrDependency + } + if user != nil { + identity.UserName, identity.DisplayName = user.UserName, user.UserName + } + identities[id] = identity + } + } + for id, identity := range identities { + if identity.DisplayName == "" { + identity.DisplayName = identity.UserName + if identity.DisplayName == "" { + identity.DisplayName = "用户 " + strconv.FormatInt(id, 10) + } + identities[id] = identity + } + } + return identities, nil +} + func List(ctx context.Context, projectID int64, p *utils.Principal, status string, cursor int64, limit int, request string) (*Page, error) { if status != "" && status != "all" && status != "open" && status != "resolved" { return nil, apperrors.ErrInvalid @@ -73,6 +108,21 @@ func List(ctx context.Context, projectID int64, p *utils.Principal, status strin result.Items = result.Items[:limit] result.NextCursor = strconv.FormatInt(result.Items[limit-1].ID, 10) } + ids := make([]int64, 0, len(result.Items)) + for _, thread := range result.Items { + ids = append(ids, thread.AuthorUserID) + } + identities, err := commentUsers(ctx, ids) + if err != nil { + return nil, err + } + for _, thread := range result.Items { + identity := identities[thread.AuthorUserID] + thread.AuthorUser = &identity + if thread.AuthorApplicationID == 0 { + thread.AuthorNameSnapshot = identity.DisplayName + } + } return result, nil } @@ -89,6 +139,15 @@ func Detail(ctx context.Context, projectID, threadID int64, p *utils.Principal) if err != nil { return nil, apperrors.ErrDependency } + identities, e := commentUsers(ctx, []int64{thread.AuthorUserID}) + if e != nil { + return nil, e + } + identity := identities[thread.AuthorUserID] + thread.AuthorUser = &identity + if thread.AuthorApplicationID == 0 { + thread.AuthorNameSnapshot = identity.DisplayName + } return &thread, nil } @@ -119,6 +178,21 @@ func Events(ctx context.Context, projectID, threadID int64, p *utils.Principal, event.RequestID = "" } } + ids := make([]int64, 0, len(result.Items)) + for _, event := range result.Items { + ids = append(ids, event.ActorUserID) + } + identities, e := commentUsers(ctx, ids) + if e != nil { + return nil, e + } + for _, event := range result.Items { + identity := identities[event.ActorUserID] + event.ActorUser = &identity + if event.ActorApplicationID == 0 { + event.ActorNameSnapshot = identity.DisplayName + } + } return result, nil } @@ -291,7 +365,19 @@ func Mutate(ctx context.Context, projectID, threadID, revision int64, p *utils.P } return nil }) - return &result, err + if err != nil { + return nil, err + } + identities, err := commentUsers(ctx, []int64{result.AuthorUserID}) + if err != nil { + return nil, err + } + identity := identities[result.AuthorUserID] + result.AuthorUser = &identity + if result.AuthorApplicationID == 0 { + result.AuthorNameSnapshot = identity.DisplayName + } + return &result, nil } // A pruned release loses its file row, but comment metadata remains durable. diff --git a/domain/service/webcomments/types.go b/domain/service/webcomments/types.go index b8302ce..1cd6551 100644 --- a/domain/service/webcomments/types.go +++ b/domain/service/webcomments/types.go @@ -9,6 +9,7 @@ import ( "time" "unicode/utf8" + "github.com/mcoder2014/home_server/domain/model" apperrors "github.com/mcoder2014/home_server/errors" ) @@ -40,6 +41,8 @@ type Thread struct { AuthorNameSnapshot string `json:"author_name_snapshot"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + + AuthorUser *model.UserDisplay `gorm:"-" json:"author_user,omitempty"` } type Event struct { @@ -59,6 +62,8 @@ type Event struct { RequestID string `json:"request_id,omitempty"` PayloadHash string `json:"-"` CreatedAt time.Time `json:"created_at"` + + ActorUser *model.UserDisplay `gorm:"-" json:"actor_user,omitempty"` } type Input struct { diff --git a/domain/service/webprojects/projects.go b/domain/service/webprojects/projects.go index e9a35aa..e1a0678 100644 --- a/domain/service/webprojects/projects.go +++ b/domain/service/webprojects/projects.go @@ -92,8 +92,10 @@ type ReleasePage struct { } type EligibleUser struct { - ID string `json:"id"` - UserName string `json:"user_name"` + ID string `json:"id"` + UserName string `json:"user_name"` + DisplayName string `json:"display_name"` + AvatarURL string `json:"avatar_url"` } // ValidateProjectInput checks the complete post-update value set before a diff --git a/front_vue/README.md b/front_vue/README.md index d9f8a44..33df166 100644 --- a/front_vue/README.md +++ b/front_vue/README.md @@ -7,10 +7,11 @@ Vue 3 与 Element Plus 页面。浏览器使用同源 HttpOnly Cookie 登录, | 入口 | 用户可执行的操作 | 接口范围 | | --- | --- | --- | | `/login`、`/register` | 密码登录、校验邀请码、受邀注册 | `/api/auth/login`、`registration-policy`、`invitations/validate`、`register` | -| `/account` | 更新显示名称、联系邮箱和电话;查看账号与功能权限 | `GET /api/auth/me`、`PATCH /api/account/profile` | +| `/account` | 更新昵称、联系邮箱和电话;裁剪上传/移除头像;查看账号与功能权限 | `GET /api/auth/me`、`PATCH /api/account/profile`、`POST/DELETE /api/account/avatar` | | `/account/security` | 验证当前密码后改密,退出全部会话;初始密码账号必须先改密 | `/api/auth/change-password`、`logout-all` | +| `/account/sessions` | 查看当前及其他有效登录;退出选中或其他全部登录 | `GET /api/account/sessions`、`sessions/revoke`、`sessions/revoke-others` | | `/invitations` | 查看本月剩余额度、生成单次邀请码、复制一次性邀请码或链接、撤销未用邀请 | `/api/account/invitations`、`/:id/revoke` | -| `/admin/users` | 搜索和分页查询用户;创建、封禁、恢复、删除、重置密码、退出全部会话;管理员、藏书和 WebDAV 授权 | `/api/admin/users`、`/:id` 及管理动作;`/:id/invitations/:invitation_id/revoke` | +| `/admin/users` | 搜索用户、有效会话统计与详情;创建、封禁、恢复、删除、重置密码、昵称/头像、退出全部会话;管理员、藏书和 WebDAV 授权 | `/api/admin/users`、`/:id`、`/:id/sessions`、`/:id/reset-profile` 及原管理动作 | | `/admin/web-share` | 按所有者、名称、发布状态、可见范围和审核状态筛选全站网页;版本预览、下架、删除、恢复、解除审核锁 | `/api/admin/web-share`、`/:id`、`/:id/:action`、管理员版本预览路径 | | `/admin/config` | 根据 schema 生成分组表单;校验、查看差异、发布、查看历史、回滚、检查运行生效状态 | `/api/admin/config/schema`、`config`、`status`、`/:namespace`、`validate`、`history`、`rollback` | | `/admin/audit-logs` | 按目标类型与数字 ID 查询操作记录及脱敏变化 | `/api/admin/audit-logs` | @@ -27,6 +28,12 @@ Vue 3 与 Element Plus 页面。浏览器使用同源 HttpOnly Cookie 登录, 个人联系方式不自动变成登录别名或找回密码渠道。管理员身份不自动开通家庭藏书或 WebDAV。WebDAV Basic 客户端独立于网页会话和首次改密流程,获准账号可直接使用未过期密码;网页登录失败限速不连带影响 Basic。HTML 预览继续同源执行,界面明确提示这一已接受的信任边界;预览不声称提供脚本沙箱。 +昵称最多 64 个 Unicode 字符,留空显示用户名。头像仅支持 JPEG/PNG、2 MiB、4096×4096 以内且总像素不超过 1600 万;客户端处理方向并裁剪,服务端独立检查并输出 256×256 JPEG。冲突时保留昵称与裁剪草稿;跨标签页仅广播刷新事件,不广播凭据。资料、成员、邀请、所有者与评论统一显示当前身份;管理记录保留稳定用户名/ID,普通评论不会回退历史违规昵称。 + +有效会话按服务端认证条件统计,受限改密会话单列;剩余时间根据服务器响应计算,所有时间显示 UTC+8。登录 IP 与 UA 是登录时的描述,不能作为设备指纹。选择退出保护当前会话并覆盖所选记录;退出其他全部涵盖未加载页。管理员数量达到 10 仅提示核查,不自动封禁。配置用户模式保留既有登录与评论;数据库专属管理接口拒绝调用。 + +“站点设置 → 账号策略 → 每个账号同时有效的网站登录上限”默认 5,允许 1–100 个网站会话,普通与受限改密会话均占名额。达到上限保留已有登录并拒绝新登录;调低上限不会自动退出旧会话。登录管理显示有效数量和网站上限。应用凭证、Basic 和同一 Token 的 Cookie 兑换不占名额。 + ## 动态配置表单 配置字段由服务端 schema 定义。布尔值使用开关、整数使用带范围的输入框、文本使用普通文本表单;固定邀请规则只读且不进入发布请求。发布前显示字段差异和生效影响,回滚创建新版本。数据库中已保存的版本与运行中已加载的版本分别展示,页面每 15 秒刷新运行状态。 diff --git a/front_vue/package.json b/front_vue/package.json index 27afb25..68c62b3 100644 --- a/front_vue/package.json +++ b/front_vue/package.json @@ -7,6 +7,7 @@ "build": "vue-cli-service build", "lint": "vue-cli-service lint", "test:frontend": "node --test test/*.test.cjs", + "test:account-center-browser": "node --test test/account-center-browser.cjs", "test:web-projects": "node --test test/web-projects-*.test.cjs" }, "dependencies": { diff --git a/front_vue/src/api/accounts.cjs b/front_vue/src/api/accounts.cjs index dc9ead5..f77cca3 100644 --- a/front_vue/src/api/accounts.cjs +++ b/front_vue/src/api/accounts.cjs @@ -12,6 +12,11 @@ function createAccountsApi(transport, getCSRF) { logoutAll: () => request('post', '/api/auth/logout-all'), changePassword: data => request('post', '/api/auth/change-password', data), profile: (revision, data) => request('patch', '/api/account/profile', data, revision), + uploadAvatar: (revision, form) => request('post', '/api/account/avatar', form, revision), + removeAvatar: revision => request('delete', '/api/account/avatar', undefined, revision), + sessions: params => request('get', '/api/account/sessions', undefined, undefined, params), + revokeSessions: sessionIDs => request('post', '/api/account/sessions/revoke', {session_ids: sessionIDs}), + revokeOtherSessions: () => request('post', '/api/account/sessions/revoke-others'), registrationPolicy: () => request('get', '/api/auth/registration-policy'), validateInvitation: code => request('post', '/api/auth/invitations/validate', {code}), register: data => request('post', '/api/auth/register', data), @@ -20,6 +25,7 @@ function createAccountsApi(transport, getCSRF) { revokeInvitation: invitationID => request('post', `/api/account/invitations/${id(invitationID)}/revoke`), users: params => request('get', '/api/admin/users', undefined, undefined, params), user: userID => request('get', `/api/admin/users/${id(userID)}`), + userSessions: (userID, params) => request('get', `/api/admin/users/${id(userID)}/sessions`, undefined, undefined, params), createUser: data => request('post', '/api/admin/users', data), userAction: (userID, action, revision, data) => request(['role', 'library-permission', 'webdav-permission'].includes(action) ? 'put' : 'post', `/api/admin/users/${id(userID)}/${action}`, data, revision), revokeUserInvitation: (userID, invitationID, revision, data) => request('post', `/api/admin/users/${id(userID)}/invitations/${id(invitationID)}/revoke`, data, revision), diff --git a/front_vue/src/api/browser_client.cjs b/front_vue/src/api/browser_client.cjs index 1937bc6..2d49e49 100644 --- a/front_vue/src/api/browser_client.cjs +++ b/front_vue/src/api/browser_client.cjs @@ -14,7 +14,8 @@ function browserHeaders(extra, getCSRF = csrfProvider) { function responseError(response) { const body = response && response.data || {} - const error = new Error(body.message || body.msg || '请求失败,请稍后重试') + const statusMessage = response && response.status === 413 ? '请求内容过大,请缩小文件后重试(头像最多 2 MiB)' : response && response.status === 429 ? '请求过于频繁,请稍后重试' : '请求失败,请稍后重试' + const error = new Error(body.message || body.msg || statusMessage) error.code = body.code error.status = response && response.status return error diff --git a/front_vue/src/components/AvatarCropper.vue b/front_vue/src/components/AvatarCropper.vue new file mode 100644 index 0000000..c9d83c6 --- /dev/null +++ b/front_vue/src/components/AvatarCropper.vue @@ -0,0 +1,115 @@ + + + diff --git a/front_vue/src/components/LoginSessions.vue b/front_vue/src/components/LoginSessions.vue new file mode 100644 index 0000000..7075fb2 --- /dev/null +++ b/front_vue/src/components/LoginSessions.vue @@ -0,0 +1,106 @@ + + + diff --git a/front_vue/src/components/MyHeader.vue b/front_vue/src/components/MyHeader.vue index f8464f2..c2fd7cf 100644 --- a/front_vue/src/components/MyHeader.vue +++ b/front_vue/src/components/MyHeader.vue @@ -10,7 +10,7 @@
+ diff --git a/front_vue/src/components/UserIdentity.vue b/front_vue/src/components/UserIdentity.vue new file mode 100644 index 0000000..21ca35e --- /dev/null +++ b/front_vue/src/components/UserIdentity.vue @@ -0,0 +1,25 @@ + + + diff --git a/front_vue/src/router/index.js b/front_vue/src/router/index.js index 790c36c..e584a74 100644 --- a/front_vue/src/router/index.js +++ b/front_vue/src/router/index.js @@ -15,6 +15,7 @@ const routes = [ {path: '/register', name: 'Register', component: () => import('@/views/Register.vue'), meta: {title: '受邀注册'}}, {path: '/account', name: 'Account', component: () => import('@/views/Account.vue'), meta: {title: '个人中心', requireAuth: true}}, {path: '/account/security', name: 'AccountSecurity', component: () => import('@/views/Account.vue'), meta: {title: '账户安全', requireAuth: true}}, + {path: '/account/sessions', name: 'AccountSessions', component: () => import('@/views/Account.vue'), meta: {title: '登录管理', requireAuth: true}}, {path: '/invitations', name: 'Invitations', component: () => import('@/views/Invitations.vue'), meta: {title: '邀请朋友', requireAuth: true}}, {path: '/forbidden', name: 'Forbidden', component: () => import('@/views/AccessState.vue'), meta: {title: '功能未开通'}}, {path: '/unavailable', name: 'Unavailable', component: () => import('@/views/AccessState.vue'), meta: {title: '服务暂不可用'}}, diff --git a/front_vue/src/store/index.js b/front_vue/src/store/index.js index a0cb75b..e127fa7 100644 --- a/front_vue/src/store/index.js +++ b/front_vue/src/store/index.js @@ -2,12 +2,14 @@ import {createStore} from 'vuex' import {config} from '@/global' const {accountsApi} = require('@/api/accounts.cjs') const {configureBrowserSession} = require('@/api/browser_client.cjs') +const {startAccountUpdates} = require('@/utils/account_updates.cjs') // Remove browser credentials from the former login flow; identity is restored from the HttpOnly cookie. for (const key of ['token', 'user_name']) localStorage.removeItem(key) sessionStorage.removeItem('userInfo') let pendingSession = null let pendingSessionEpoch = -1 +let accountUpdates = null const store = createStore({ state: { @@ -42,6 +44,7 @@ const store = createStore({ }, getters: {getUser: state => state.userInfo}, actions: { + publishProfileUpdate() { if (accountUpdates) accountUpdates.publish() }, async refreshSession({commit, state}) { if (!pendingSession || pendingSessionEpoch !== state.sessionEpoch) { const epoch = state.sessionEpoch @@ -50,8 +53,7 @@ const store = createStore({ return state.userInfo }).catch(error => { if (epoch !== state.sessionEpoch) return state.userInfo - commit('REMOVE_INFO') - if (error.status === 401) return null + if (error.status === 401) { commit('REMOVE_INFO'); return null } throw error }).finally(() => { if (pendingSession === request) pendingSession = null }) pendingSession = request @@ -67,4 +69,5 @@ const store = createStore({ }, }) configureBrowserSession(() => store.state.userInfo && store.state.userInfo.csrf_token || '', () => { store.commit('REMOVE_INFO'); window.dispatchEvent(new Event('account-session-expired')) }) +if (typeof window !== 'undefined') accountUpdates = startAccountUpdates(window, () => store.dispatch('refreshSession')) export default store diff --git a/front_vue/src/utils/account_updates.cjs b/front_vue/src/utils/account_updates.cjs new file mode 100644 index 0000000..a798757 --- /dev/null +++ b/front_vue/src/utils/account_updates.cjs @@ -0,0 +1,30 @@ +// 标签页只交换资料变化事件,身份与凭据必须通过各自 Cookie 从 /api/auth/me 重新读取。 +function startAccountUpdates(host, refresh) { + const key = 'home-server-profile-update' + let channel = null + const update = () => Promise.resolve(refresh()).catch(() => {}) + const message = event => { if (event.data && event.data.type === 'profile-updated') update() } + const storage = event => { if (event.key === key && event.newValue) update() } + const visible = () => { if (host.document.visibilityState === 'visible') update() } + try { + if (host.BroadcastChannel) { + channel = new host.BroadcastChannel(key) + channel.addEventListener('message', message) + } + } catch (_) { /* 浏览器禁用消息通道时使用 storage 事件。 */ } + host.addEventListener('storage', storage) + host.document.addEventListener('visibilitychange', visible) + return { + publish() { + if (channel) channel.postMessage({type: 'profile-updated'}) + else { try { host.localStorage.setItem(key, String(Date.now()) + ':' + Math.random()) } catch (_) { /* 隐私模式下仍保留当前标签页状态。 */ } } + }, + dispose() { + host.removeEventListener('storage', storage) + host.document.removeEventListener('visibilitychange', visible) + if (channel) { channel.removeEventListener('message', message); channel.close() } + }, + } +} + +module.exports = {startAccountUpdates} diff --git a/front_vue/src/utils/accounts_behavior.cjs b/front_vue/src/utils/accounts_behavior.cjs index dc037ba..d2b1541 100644 --- a/front_vue/src/utils/accounts_behavior.cjs +++ b/front_vue/src/utils/accounts_behavior.cjs @@ -28,17 +28,129 @@ function passwordError(password, confirmation, minimum = 15) { return '' } -function profilePayload(value) { - return {display_name: value.display_name || '', contact_email: value.contact_email || '', contact_mobile: value.contact_mobile || ''} +function profilePayload(value, before) { + const fields = {display_name: (value.display_name || '').trim(), contact_email: value.contact_email || '', contact_mobile: value.contact_mobile || ''} + if (before) { + const saved = profilePayload(before) + for (const key of Object.keys(fields)) if (fields[key] === saved[key]) delete fields[key] + } + return fields } function profileError(value) { - if (!value.display_name.trim() || Array.from(value.display_name).length > 64) return '显示名称需要 1–64 个字符' + if (Array.from((value.display_name || '').trim()).length > 64) return '昵称不能超过 64 个字符' if (value.contact_email && (value.contact_email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.contact_email))) return '请填写有效的联系邮箱' if (value.contact_mobile && !/^[+0-9 -]{1,32}$/.test(value.contact_mobile)) return '请填写有效的联系电话' return '' } +function userIdentity(value = {}) { + const clean = text => String(text || '').replace(/[\p{Cc}\p{Cf}]/gu, character => character === '\u200d' ? character : '').trim() + const name = clean(value.display_name) || clean(value.user_name) || (value.user_id || value.id ? '用户 ' + (value.user_id || value.id) : '用户') + const visible = clean(value.display_name) || clean(value.user_name) + let initial = Array.from(visible)[0] || '' + if (visible && typeof Intl.Segmenter === 'function') initial = new Intl.Segmenter('zh', {granularity: 'grapheme'}).segment(visible)[Symbol.iterator]().next().value.segment + const avatar = /^\/api\/account\/avatars\/[1-9][0-9]*\/[1-9][0-9]*$/.test(value.avatar_url || '') ? value.avatar_url : '' + return {name, initial: initial.toUpperCase(), avatar} +} + +function preserveProfileDraft(draft, before, latest) { + const next = profilePayload(latest) + for (const key of Object.keys(profilePayload(draft, before))) next[key] = draft[key] + return next +} + +function selectedSessionIDs(selected, items, current) { + const available = new Set(items.map(item => String(item.id))) + const currentID = current && String(current.id) + return [...new Set(selected.map(String))].filter(id => id !== currentID && available.has(id)).slice(0, 100) +} + +function sessionRemaining(session, receivedAt, now) { + const seconds = Number(session.remaining_seconds) + if (!Number.isFinite(seconds)) return null + return Math.max(0, Math.ceil(seconds - Math.max(0, now - receivedAt) / 1000)) +} + +function remainingText(seconds) { + if (seconds == null) return '剩余时间未知' + if (seconds <= 0) return '已到期,等待刷新确认' + const days = Math.floor(seconds / 86400), hours = Math.floor(seconds % 86400 / 3600), minutes = Math.floor(seconds % 3600 / 60) + if (days) return `剩余 ${days} 天 ${hours} 小时` + if (hours) return `剩余 ${hours} 小时 ${minutes} 分钟` + return seconds < 60 ? '剩余不足 1 分钟' : `剩余 ${minutes} 分钟` +} + +function avatarFileError(file) { + if (!file || !['image/jpeg', 'image/png'].includes(file.type)) return '头像只支持 JPEG 或 PNG 图片' + if (!file.size || file.size > 2 * 1024 * 1024) return '头像文件必须非空,且不能超过 2 MiB' + return '' +} + +// 读取有界文件头,先拒绝像素炸弹和 APNG,再交给浏览器解码;实际图片仍由服务端独立校验。 +function avatarMetadata(input) { + const bytes = input instanceof Uint8Array ? input : new Uint8Array(input) + const data = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + let width = 0, height = 0, orientation = 1, type = '' + if (bytes.length >= 33 && [137, 80, 78, 71, 13, 10, 26, 10].every((value, index) => bytes[index] === value)) { + type = 'image/png' + if (data.getUint32(8) !== 13 || String.fromCharCode(...bytes.slice(12, 16)) !== 'IHDR') throw new Error('PNG 文件头无效') + width = data.getUint32(16); height = data.getUint32(20) + for (let offset = 8; offset + 12 <= bytes.length;) { + const length = data.getUint32(offset), chunk = String.fromCharCode(...bytes.slice(offset + 4, offset + 8)) + if (chunk === 'acTL') throw new Error('头像不支持动画 PNG') + if (length > bytes.length - offset - 12) throw new Error('PNG 图片内容不完整') + offset += length + 12 + } + } else if (bytes.length > 4 && bytes[0] === 255 && bytes[1] === 216) { + type = 'image/jpeg' + for (let offset = 2; offset + 4 <= bytes.length;) { + if (bytes[offset] !== 255) throw new Error('JPEG 图片内容无效') + while (bytes[offset] === 255) offset++ + const marker = bytes[offset++] + if (marker === 217 || marker === 218) break + if (marker === 1 || marker >= 208 && marker <= 215) continue + const length = data.getUint16(offset) + if (length < 2 || offset + length > bytes.length) throw new Error('JPEG 图片内容不完整') + if ([192, 193, 194, 195, 197, 198, 199, 201, 202, 203, 205, 206, 207].includes(marker) && length >= 8) { + height = data.getUint16(offset + 3); width = data.getUint16(offset + 5) + } + if (marker === 225) orientation = avatarOrientation(bytes, offset + 2, length - 2) + offset += length + } + } + if (!width || !height) throw new Error('无法识别 JPEG 或 PNG 图片尺寸') + if (width > 4096 || height > 4096) throw new Error('图片宽高均不能超过 4096 像素') + if (width * height > 16000000) throw new Error('图片总像素不能超过 1600 万') + return {width, height, orientation, type} +} + +function avatarOrientation(bytes, start, length) { + if (length < 14 || String.fromCharCode(...bytes.slice(start, start + 6)) !== 'Exif\u0000\u0000') return 1 + const data = new DataView(bytes.buffer, bytes.byteOffset + start + 6, length - 6) + const endian = data.getUint16(0), little = endian === 0x4949 + if ((!little && endian !== 0x4d4d) || data.getUint16(2, little) !== 42) return 1 + const directory = data.getUint32(4, little) + if (directory + 2 > data.byteLength) return 1 + const count = data.getUint16(directory, little) + for (let index = 0; index < count; index++) { + const offset = directory + 2 + index * 12 + if (offset + 12 > data.byteLength) break + if (data.getUint16(offset, little) === 0x112 && data.getUint16(offset + 2, little) === 3 && data.getUint32(offset + 4, little) === 1) { + const orientation = data.getUint16(offset + 8, little) + return orientation >= 1 && orientation <= 8 ? orientation : 1 + } + } + return 1 +} + +function avatarCrop(width, height, zoom, offsetX, offsetY, stage = 280) { + const scale = stage / Math.min(width, height) * Math.max(1, Math.min(3, zoom)) + const boundX = (width * scale - stage) / 2, boundY = (height * scale - stage) / 2 + offsetX = Math.max(-boundX, Math.min(boundX, offsetX)) || 0; offsetY = Math.max(-boundY, Math.min(boundY, offsetY)) || 0 + return {x: (boundX - offsetX) / scale, y: (boundY - offsetY) / scale, size: stage / scale, offsetX, offsetY, scale} +} + function configValues(schema, draft) { const values = {} for (const field of schema.fields || []) { @@ -82,4 +194,4 @@ function dateTime(value) { return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString('zh-CN', {timeZone: 'Asia/Singapore', hour12: false}) } -module.exports = {routeDecision, passwordError, profilePayload, profileError, configValues, configChanges, clearOneTimeSecret, requestID, dateTime} +module.exports = {routeDecision, passwordError, profilePayload, profileError, userIdentity, preserveProfileDraft, selectedSessionIDs, sessionRemaining, remainingText, avatarFileError, avatarMetadata, avatarCrop, configValues, configChanges, clearOneTimeSecret, requestID, dateTime} diff --git a/front_vue/src/utils/web_projects_navigation.cjs b/front_vue/src/utils/web_projects_navigation.cjs index cce34e2..9fa1f5d 100644 --- a/front_vue/src/utils/web_projects_navigation.cjs +++ b/front_vue/src/utils/web_projects_navigation.cjs @@ -41,7 +41,7 @@ function normalizeInternalRedirect(value, inheritedHash = '') { return '/' } if (!PROJECT_PATH.test(parsed.pathname) - && !['/account', '/account/security', '/invitations', '/book/list', '/book/add', '/book/info', '/scanCodePage', '/admin/users', '/admin/web-share', '/admin/config', '/admin/audit-logs'].includes(parsed.pathname) + && !['/account', '/account/security', '/account/sessions', '/invitations', '/book/list', '/book/add', '/book/info', '/scanCodePage', '/admin/users', '/admin/web-share', '/admin/config', '/admin/audit-logs'].includes(parsed.pathname) && parsed.pathname !== '/applications' && parsed.pathname !== '/web-share' && !parsed.pathname.startsWith('/web-share/') diff --git a/front_vue/src/views/Account.vue b/front_vue/src/views/Account.vue index 4b6c984..25adb64 100644 --- a/front_vue/src/views/Account.vue +++ b/front_vue/src/views/Account.vue @@ -8,23 +8,25 @@ - + + + + + diff --git a/front_vue/src/views/AdminAudit.vue b/front_vue/src/views/AdminAudit.vue index e66819f..04bd842 100644 --- a/front_vue/src/views/AdminAudit.vue +++ b/front_vue/src/views/AdminAudit.vue @@ -3,16 +3,17 @@
查询 - +
+ + diff --git a/front_vue/src/views/Index.vue b/front_vue/src/views/Index.vue index f9e35d5..e3bcab6 100644 --- a/front_vue/src/views/Index.vue +++ b/front_vue/src/views/Index.vue @@ -6,7 +6,7 @@
你的家庭工作台 -

欢迎回来,{{ username }}

+

欢迎回来,访客

收藏好书,记录想法,让实用的小工具随时可用。
你的家庭服务,从这里开始。

进入网页托管
@@ -55,11 +55,12 @@ ' +before(async () => { + server = http.createServer((req,res) => { + const artifact = artifacts[req.url] + res.setHeader('Content-Type', artifact ? req.url.endsWith('.css') ? 'text/css' : 'application/javascript' : 'text/html; charset=utf-8') + res.end(artifact || html) + }) + await new Promise(resolve => server.listen(0,'127.0.0.1',resolve)); origin = 'http://127.0.0.1:' + server.address().port + const chrome = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' + const executablePath = process.env.CHROME_PATH || (process.platform === 'darwin' && fs.existsSync(chrome) ? chrome : undefined) + browser = await chromium.launch({headless:true, ...(executablePath ? {executablePath} : {})}) +}) +after(async () => { if(browser)await browser.close(); if(server)await new Promise(resolve=>server.close(resolve)) }) + +function fixture() { + const user = {id:'42',user_name:'friend',display_name:'朋友',contact_email:'friend@example.com',contact_mobile:'',revision:'7',avatar_version:0,avatar_url:'',status:'active',role:'admin',csrf_token:'fixture-csrf',password_policy:{min_length:15},capabilities:{},create_time:'2026-09-17T01:00:00Z'} + const session = {id:'9223372036854775807',client_name:'Chrome',os_name:'Linux',device_type:'desktop',login_ip:'2001:db8::1',login_source:'web',purpose:'user',authenticated_at:'2026-09-17T01:00:00Z',expire_time:'2026-09-18T01:00:00Z',remaining_seconds:86400,user_agent:'Mozilla/5.0 fixture'} + return {user,writes:[],requests:[],current:{...session,id:'current'},sessions:[session],maxActiveSessions:5,failSessions:false,avatar:null} +} +async function setup(options = {}) { + const state = options.state || fixture(), context = options.context || await browser.newContext({viewport:{width:options.width || 1280,height:950}}), page = await context.newPage() + page.setDefaultTimeout(10000) + const errors=[];page.on('pageerror',error=>{errors.push(error.message);console.error(error.message)}) + await page.route('**/api/**',async route => { + const req=route.request(),url=new URL(req.url());state.requests.push(url.pathname) + let data + if(url.pathname==='/api/auth/me')data=state.user + else if(url.pathname==='/api/admin/config/schema')data={namespaces:state.configSchemas} + else if(url.pathname==='/api/admin/config' && req.method()==='GET')data={items:[state.configRecord]} + else if(url.pathname==='/api/admin/config/status')data={apply_state:'applied',persisted_generation:state.configRecord.revision,loaded_generation:state.configRecord.revision,namespaces:[{namespace:'account_policy',loaded_revision:state.configRecord.revision,apply_state:'applied'}]} + else if(url.pathname==='/api/admin/config/account_policy/history')data={items:state.configHistory,has_more:false} + else if(url.pathname==='/api/admin/config/account_policy/validate') { + const values=req.postDataJSON().values;state.configValidations.push(values) + data={revision:state.configRecord.revision,values,changes:[{key:'max_active_sessions',before:state.configRecord.values.max_active_sessions,after:values.max_active_sessions}],effects:['new_operation']} + } + else if(url.pathname.endsWith('/sessions')) { + if(state.failSessions){await route.fulfill({status:503,json:{code:503,message:'合成连接失败'}});return} + data={current_session:url.pathname.startsWith('/api/admin')?null:state.current,items:state.sessions,total_count:state.sessions.length+1,restricted_count:0,max_active_sessions:state.maxActiveSessions,server_time:'2026-09-17T01:00:00Z',has_more:false} + } else if(url.pathname==='/api/admin/users')data={items:[{...state.user,active_session_count:11,restricted_session_count:0}],session_warning_threshold:10} + else if(url.pathname==='/api/admin/users/42')data={user:state.user,applications:[],invitations:[],projects:[]} + else if(req.method()!=='GET') { + let body;try{body=req.postDataJSON()}catch(_){body=req.postDataBuffer()} + state.writes.push({path:url.pathname,headers:req.headers(),body}) + if(url.pathname.endsWith('/profile')) {Object.assign(state.user,body);state.user.revision=String(Number(state.user.revision)+1);data=state.user} + else if(url.pathname==='/api/account/avatar') { + if(state.avatarConflict){state.avatarConflict=false;state.user.revision='8';state.user.display_name='';await route.fulfill({status:409,json:{code:409,message:'合成资料版本冲突'}});return} + state.avatar=body;state.user.avatar_url='/api/account/avatars/42/8';state.user.revision='8';data=state.user + } + else if(url.pathname.endsWith('/revoke')){state.sessions=state.sessions.filter(item=>!body.session_ids.includes(item.id));data={revoked_count:1,already_inactive_count:0}} + else if(url.pathname.endsWith('/reset-profile')) {if(body.reset_display_name)state.user.display_name='';if(body.reset_avatar)state.user.avatar_url='';data={user:state.user}} + else if(url.pathname==='/api/admin/config/account_policy/rollback') {state.configRecord={...state.configRecord,revision:state.configRecord.revision+1,values:{...state.configHistory[0].values,max_active_sessions:5}};data=state.configRecord} + else data={} + } else if(url.pathname.startsWith('/api/account/avatars/')) {await route.fulfill({status:404,body:'removed'});return} + else data={} + await route.fulfill({json:{code:0,data}}) + }) + await page.goto(origin+(options.path || '/account'));await page.waitForFunction(()=>!!window.account).catch(error=>{error.message+='; '+errors.join('; ');throw error}) + assert.deepEqual(errors,[]) + return {page,context,state,errors} +} + +test('昵称清空只提交昵称,跨标签更新保留已编辑草稿,320px页面没有水平溢出',async()=>{ + const {page,context,state}=await setup({width:320}) + try { + await page.getByLabel('昵称',{exact:true}).fill('😀'.repeat(64)) + assert.equal(await page.getByLabel('昵称',{exact:true}).inputValue(),'😀'.repeat(64)) + await page.getByLabel('昵称',{exact:true}).fill('') + await page.getByRole('button',{name:'保存资料',exact:true}).click() + await page.waitForFunction(()=>window.account.revision==='8') + assert.deepEqual(state.writes[0].body,{display_name:''}) + const second=await setup({context,state}) + await second.page.getByLabel('昵称',{exact:true}).fill('尚未保存的草稿') + await page.getByLabel('昵称',{exact:true}).fill('另一标签更新') + await page.getByRole('button',{name:'保存资料',exact:true}).click() + await second.page.getByText('资料已发生变化,草稿已保留',{exact:true}).waitFor() + assert.equal(await second.page.getByLabel('昵称',{exact:true}).inputValue(),'尚未保存的草稿') + assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false) + }finally{await context.close()} +}) + +test('真实照片按EXIF方向裁剪成256px PNG,拖动缩放可用,取消不上传',async()=>{ + const {page,context,state}=await setup({width:320}) + try { + const input=await page.evaluate(async()=>{ + const canvas=document.createElement('canvas');canvas.width=100;canvas.height=50;const draw=canvas.getContext('2d');draw.fillStyle='blue';draw.fillRect(0,0,50,50);draw.fillStyle='red';draw.fillRect(50,0,50,50) + const blob=await new Promise(resolve=>canvas.toBlob(resolve,'image/jpeg',.95)),jpeg=new Uint8Array(await blob.arrayBuffer()) + const app=new Uint8Array([255,225,0,34,69,120,105,102,0,0,73,73,42,0,8,0,0,0,1,0,18,1,3,0,1,0,0,0,6,0,0,0,0,0,0,0]) + return Array.from(new Uint8Array([...jpeg.slice(0,2),...app,...jpeg.slice(2)])) + }) + await page.getByRole('button',{name:'更换头像',exact:true}).click() + await page.getByLabel('选择头像图片').setInputFiles({name:'oriented.jpg',mimeType:'image/jpeg',buffer:Buffer.from(input)}) + const canvas=page.getByRole('img',{name:'头像裁剪预览,可拖动或使用方向键移动'}) + await canvas.waitFor() + const bounds=await canvas.boundingBox();assert.ok(Math.abs(bounds.width-bounds.height)<0.5) + assert.ok((await page.getByRole('button',{name:'保存头像',exact:true}).boundingBox()).height>=44) + await canvas.focus();await page.keyboard.press('ArrowDown') + await page.getByRole('slider',{name:'头像缩放'}).focus();await page.keyboard.press('ArrowRight') + assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false) + await page.getByRole('button',{name:'取消',exact:true}).last().click() + assert.equal(state.writes.length,0) + await page.getByRole('button',{name:'更换头像',exact:true}).click() + await page.getByLabel('选择头像图片').setInputFiles({name:'oriented.jpg',mimeType:'image/jpeg',buffer:Buffer.from(input)}) + await canvas.waitFor() + const colors=await canvas.evaluate(node=>{const c=node.getContext('2d');return [Array.from(c.getImageData(140,50,1,1).data),Array.from(c.getImageData(140,230,1,1).data)]}) + assert.ok(colors[0][2]>200&&colors[0][0]<50);assert.ok(colors[1][0]>200&&colors[1][2]<50) + await page.getByRole('button',{name:'保存头像',exact:true}).click() + await page.waitForFunction(()=>window.account.revision==='8') + assert.ok(state.avatar.includes(Buffer.from('image/png'))) + const index=state.avatar.indexOf(Buffer.from([137,80,78,71,13,10,26,10]));assert.ok(index>=0) + assert.equal(state.avatar.readUInt32BE(index+16),256);assert.equal(state.avatar.readUInt32BE(index+20),256) + assert.equal(state.writes[0].headers['if-match'],'7') + }finally{await context.close()} +}) + +test('登录管理只退出选中会话,刷新失败保留数量及卡片,初始改密仍限制入口',async()=>{ + const {page,context,state}=await setup({path:'/account/sessions',width:320}) + try { + await page.getByText('有效登录会话 2 个',{exact:false}).waitFor() + const policy = page.getByText('本网站每个账号的有效登录上限为',{exact:false}) + assert.match(await policy.textContent(), /上限为 5 个会话,当前 2 \/ 5/) + assert.match(await policy.textContent(), /达到上限时会拒绝新的登录;已存在的超额会话保留,可主动退出或等待过期/) + await page.getByLabel('选择 Chrome · Linux · 桌面').check() + await page.getByRole('button',{name:'退出选中 1',exact:true}).click() + await page.getByRole('button',{name:'确认退出选中',exact:true}).click() + await page.getByText('暂无其他有效登录',{exact:true}).waitFor() + assert.deepEqual(state.writes[0].body,{session_ids:['9223372036854775807']}) + state.failSessions=true + await page.getByRole('button',{name:'刷新',exact:true}).click() + await page.getByText('刷新失败,保留上次确认的数据',{exact:true}).waitFor() + assert.ok(await page.getByText('有效登录会话 1 个',{exact:false}).count()) + assert.match(await policy.textContent(), /上限为 5 个会话,当前 1 \/ 5/) + state.user.must_change_password=true + await page.evaluate(()=>window.store.dispatch('refreshSession')) + assert.match(await page.getByRole('tab',{name:'登录管理'}).getAttribute('class'),/is-disabled/) + assert.equal(await page.getByRole('button',{name:'更换头像',exact:true}).isVisible(),false) + assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false) + }finally{await context.close()} +}) + +test('头像版本冲突可查看最新资料后保留裁剪和昵称草稿继续保存',async()=>{ + const {page,context,state}=await setup({width:390}) + try { + await page.getByLabel('昵称',{exact:true}).fill('未保存昵称') + const input=await page.evaluate(()=>{const canvas=document.createElement('canvas');canvas.width=canvas.height=64;canvas.getContext('2d').fillRect(0,0,64,64);return canvas.toDataURL().split(',')[1]}) + await page.getByRole('button',{name:'更换头像',exact:true}).click() + await page.getByLabel('选择头像图片').setInputFiles({name:'avatar.png',mimeType:'image/png',buffer:Buffer.from(input,'base64')}) + const preview=page.getByRole('img',{name:'头像裁剪预览,可拖动或使用方向键移动'}) + await preview.waitFor() + state.avatarConflict=true + await page.getByRole('button',{name:'保存头像',exact:true}).click() + await page.getByRole('dialog',{name:'最新个人资料',exact:true}).waitFor() + await page.getByRole('button',{name:'保留草稿继续编辑',exact:true}).click() + await preview.waitFor() + assert.equal(await page.getByLabel('昵称',{exact:true}).inputValue(),'未保存昵称') + await page.getByRole('button',{name:'保存头像',exact:true}).click() + await page.waitForFunction(()=>window.account.user.avatar_url) + assert.equal(state.writes.length,2) + assert.equal(state.writes[1].headers['if-match'],'8') + assert.equal(await page.getByLabel('昵称',{exact:true}).inputValue(),'未保存昵称') + }finally{await context.close()} +}) + +test('管理员数量10仅提示,资料重置可勾选头像并携带密码、原因和revision',async()=>{ + const {page,context,state}=await setup({path:'/admin/users'}) + try { + await page.getByText('数量偏多,建议核查',{exact:true}).waitFor() + await page.evaluate(()=>window.account.beginAction(window.account.items[0],'reset-profile')) + await page.getByText('头像',{exact:true}).click() + await page.getByLabel('操作原因').fill('合成资料处置') + await page.getByLabel('你的当前密码').fill('fixture-current-password') + await page.getByRole('button',{name:'确认重置昵称 / 头像',exact:true}).click() + await page.waitForFunction(()=>!window.account.actionVisible) + assert.deepEqual(state.writes[0].body,{reset_avatar:true,reset_display_name:false,reason:'合成资料处置',current_password:'fixture-current-password'}) + assert.equal(state.writes[0].headers['if-match'],'7') + }finally{await context.close()} +}) + +test('站点设置可回滚旧四字段账号策略,校验补5且历史原始记录不变',async()=>{ + const state=fixture(),legacy={session_ttl_seconds:604800,temporary_password_ttl_days:7,min_password_length:15,bcrypt_cost:12} + const defaults={...legacy,max_active_sessions:5} + const label='每个账号同时有效的网站登录上限' + state.configSchemas=[{namespace:'account_policy',label:'账号策略',fields:Object.entries(defaults).map(([key,default_value])=>({key,label:key==='max_active_sessions'?label:key,type:'integer',default_value,minimum:1,maximum:key==='max_active_sessions'?100:2592000,effect:'new_operation'}))}] + state.configRecord={namespace:'account_policy',revision:2,values:{...legacy,max_active_sessions:3},apply_state:'applied',loaded_revision:2} + state.configHistory=[{namespace:'account_policy',revision:1,values:legacy,reason:'合成旧配置',actor_user_id:'42'}] + state.configValidations=[] + const {page,context}=await setup({path:'/admin/config',state,width:390}) + try { + const limit=page.locator('.config-field').filter({hasText:label}).getByRole('spinbutton') + assert.equal(await limit.inputValue(),'3') + assert.ok(await page.getByText('降低上限不会主动退出已有会话',{exact:false}).isVisible()) + await page.getByRole('button',{name:'版本历史',exact:true}).click() + const history=page.getByRole('dialog',{name:'账号策略 · 版本历史',exact:true}) + await history.locator('.el-table__expand-icon').click() + assert.equal(await history.getByText(label,{exact:true}).count(),0) + await history.getByRole('button',{name:'回滚',exact:true}).click() + const confirmation=page.getByRole('dialog',{name:'回滚配置',exact:true}) + await confirmation.waitFor() + assert.equal(state.configValidations[0].max_active_sessions,5) + assert.equal(Object.hasOwn(legacy,'max_active_sessions'),false) + await confirmation.getByLabel('操作原因').fill('合成旧策略回滚') + await confirmation.getByLabel('你的当前密码').fill('fixture-current-password') + await confirmation.getByRole('button',{name:'确认回滚配置',exact:true}).click() + await page.waitForFunction(()=>window.account.current.revision===3) + assert.equal(await limit.inputValue(),'5') + assert.equal(state.writes[0].path,'/api/admin/config/account_policy/rollback') + assert.equal(state.writes[0].body.target_revision,1) + assert.equal(state.writes[0].headers['if-match'],'2') + assert.equal(Object.hasOwn(state.writes[0].body,'values'),false) + assert.equal(Object.hasOwn(legacy,'max_active_sessions'),false) + assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false) + }finally{await context.close()} +}) diff --git a/front_vue/test/account-center.test.cjs b/front_vue/test/account-center.test.cjs new file mode 100644 index 0000000..63ffbd4 --- /dev/null +++ b/front_vue/test/account-center.test.cjs @@ -0,0 +1,258 @@ +const assert = require('node:assert/strict') +const test = require('node:test') +const fs = require('node:fs') +const path = require('node:path') +const vm = require('node:vm') +const behavior = require('../src/utils/accounts_behavior.cjs') +const {createAccountsApi} = require('../src/api/accounts.cjs') + +test('login redirect restores the sessions page after authentication', () => { + const {normalizeInternalRedirect} = require('../src/utils/web_projects_navigation.cjs') + assert.equal(normalizeInternalRedirect('/account/sessions'), '/account/sessions') +}) + +test('HTML proxy errors for uploads and rate limits produce actionable Chinese messages', async () => { + for (const [status, message] of [[413,/过大/],[429,/稍后/]]) { + const api = createAccountsApi({async request() {throw Object.assign(new Error('HTTP failure'),{response:{status,data:'proxy error'}})}}) + await assert.rejects(api.uploadAvatar('7',new FormData()),error=>error.status===status&&message.test(error.message)) + } +}) + +function component(file, api = {}, props = {}, globals = {}) { + const source = fs.readFileSync(path.join(__dirname, '../src/', file), 'utf8') + const script = source.match(/