Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,按分组发布并保留历史;显示已保存与已加载版本,支持校验、冲突保护和回滚;数据库连接、存储根与密钥仍是启动配置。 |
Expand Down
167 changes: 167 additions & 0 deletions api/accounts/account_center_test.go
Original file line number Diff line number Diff line change
@@ -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("<svg/>"), 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")
}
}
4 changes: 4 additions & 0 deletions api/accounts/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading