账户中心:站主资料可编辑 + 身份绑定 + passkey 登录

后台新增 /admin/account 一页,四块:公开资料、密码、第三方账号、Passkey。

-  schema:users 加 role(默认 reader),新表 user_identities、passkeys。
  user_identities 上双 UNIQUE —— (provider, extern_uid) 防一个外部账号顶两个身份,
  (user_id, provider) 防一站主绑两个同平台号,绑错也劫持不了。
  extern_uid 存平台稳定 ID,不存用户名(用户名可改)。
- 头像存 files 里的 key 而非 URL,换存储/CDN 不失效;单 key SetSetting 写入,
  避开 UpdateSettings 的整表替换会把它抹掉。站主名/简介从设置页挪到账户页,
  一个字段只留一个编辑入口。
- OAuth 绑定要求先有后台会话(绑定动作本身是提权路径);已绑的站主身份登录后
  直接发 one_session,读者身份仍发 one_reader。
- passkey 走 go-webauthn v0.15.0(最后一条吃 go 1.24 的版本线),可发现凭据登录。
  必须显式设 ONE_WEBAUTHN_ORIGINS 才启用,不配就安静关掉。
  签名计数只记克隆警告、不硬拦 —— 云同步 passkey 的计数本就不单调。
- 密码故意留在 ONE_ADMIN_PASSWORD,不做哈希入库:这是「解绑一切、删光 passkey
  也还能进门」的保底,比 env 明文更值得守。memos 那个 SSO 建号随机密码无重置
  入口的坑,从设计上绕开。

已知限制:会话仍是有状态无关的 HMAC cookie,删 passkey / 解绑不会让已发出的
7 天后台会话失效 —— 要修得加一张吊销表。
This commit is contained in:
Sakurasan committed 2026-09-30 01:08:55 +08:00
1 parent 7e302c51a6
commit f1e639e0ba
30 files changed
+2523 -105

No files matched your search

+166
View File
@@ -0,0 +1,166 @@
package admin
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"oneblog/internal/model"
)
// doAs 带着有效后台会话发一个请求。
func doAs(t *testing.T, h http.Handler, method, path string, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
// 用与 newTestAPI 里 NewSessions 相同的 secret 签一个会话
sess := NewSessions("test-secret", time.Hour)
tok, _ := sess.Issue("admin")
req.AddCookie(&http.Cookie{Name: cookieName, Value: tok})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestAccountGET(t *testing.T) {
a, h := newTestAPI(t)
if _, err := a.Store.EnsureOwner("admin"); err != nil {
t.Fatal(err)
}
rec := doAs(t, h, http.MethodGet, "/api/admin/account", "")
if rec.Code != http.StatusOK {
t.Fatalf("got %d %s", rec.Code, rec.Body.String())
}
var v struct {
Name string `json:"name"`
Handle string `json:"handle"`
Password struct{} `json:"password"`
Providers []string `json:"providers"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil {
t.Fatal(err)
}
if v.Handle != "admin" {
t.Fatalf("handle=%q", v.Handle)
}
// 测试配置里没有 OAuth 凭据,可绑平台应为空
if len(v.Providers) != 0 {
t.Fatalf("providers=%v, want empty", v.Providers)
}
}
func TestAccountPATCHProfile(t *testing.T) {
a, h := newTestAPI(t)
if _, err := a.Store.EnsureOwner("admin"); err != nil {
t.Fatal(err)
}
rec := doAs(t, h, http.MethodPatch, "/api/admin/account", `{"name":"麻衣","bio":"活着就是为了樱岛麻衣"}`)
if rec.Code != http.StatusOK {
t.Fatalf("got %d %s", rec.Code, rec.Body.String())
}
// 昵称要同时落在站主行与 settings(前端各处仍读 settings.author_name)
owner, err := a.Store.GetOwner()
if err != nil {
t.Fatal(err)
}
if owner.Name != "麻衣" {
t.Fatalf("owner.name=%q", owner.Name)
}
st, err := a.Store.GetSettings()
if err != nil {
t.Fatal(err)
}
if st.AuthorName != "麻衣" || st.AuthorBio != "活着就是为了樱岛麻衣" {
t.Fatalf("settings 未同步: %+v", st)
}
}
func TestAccountPATCHAvatarKey(t *testing.T) {
a, h := newTestAPI(t)
if _, err := a.Store.EnsureOwner("admin"); err != nil {
t.Fatal(err)
}
// 不存在的 key 必须拒:否则会存下一个永远解析不出的头像
rec := doAs(t, h, http.MethodPatch, "/api/admin/account", `{"avatar_key":"2026/09/nope.png"}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("不存在的 key: got %d, want 400", rec.Code)
}
// 真实存在但不是图片的也要拒
f, err := a.Store.CreateFile(model.File{
Key: "2026/09/notes.txt", Name: "notes.txt", Mime: "text/plain",
Size: 4, SHA256: strings.Repeat("a", 64), Store: "local",
})
if err != nil {
t.Fatal(err)
}
rec = doAs(t, h, http.MethodPatch, "/api/admin/account", `{"avatar_key":"`+f.Key+`"}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("非图片: got %d, want 400", rec.Code)
}
// 图片就放行,并且单独写 owner_avatar_key(绕开 UpdateSettings 全量替换)
img, err := a.Store.CreateFile(model.File{
Key: "2026/09/me.png", Name: "me.png", Mime: "image/png",
Size: 4, SHA256: strings.Repeat("b", 64), Store: "local",
})
if err != nil {
t.Fatal(err)
}
rec = doAs(t, h, http.MethodPatch, "/api/admin/account", `{"avatar_key":"`+img.Key+`"}`)
if rec.Code != http.StatusOK {
t.Fatalf("图片头像: got %d %s", rec.Code, rec.Body.String())
}
st, _ := a.Store.GetSettings()
if st.AuthorAvatarKey != img.Key {
t.Fatalf("avatar key=%q", st.AuthorAvatarKey)
}
if !strings.Contains(rec.Body.String(), "/uploads/"+img.Key) {
t.Fatalf("响应里没解析出头像 URL: %s", rec.Body.String())
}
// 站点设置的整体 PUT 不该把头像键冲掉(两者写入路径分开)
if err := a.Store.UpdateSettings(st); err != nil {
t.Fatal(err)
}
after, _ := a.Store.GetSettings()
if after.AuthorAvatarKey != img.Key {
t.Fatalf("UpdateSettings 把头像键清了: %q", after.AuthorAvatarKey)
}
}
func TestAccountRejectsAnonymous(t *testing.T) {
_, h := newTestAPI(t)
for _, path := range []string{"/api/admin/account", "/api/admin/account/passkeys"} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusUnauthorized {
t.Errorf("%s: got %d, want 401", path, rec.Code)
}
}
}
func TestAccountUnbindUnknown(t *testing.T) {
a, h := newTestAPI(t)
if _, err := a.Store.EnsureOwner("admin"); err != nil {
t.Fatal(err)
}
rec := doAs(t, h, http.MethodDelete, "/api/admin/account/identities/github", "")
if rec.Code != http.StatusNotFound {
t.Fatalf("没绑过还解绑: got %d, want 404", rec.Code)
}
}
// passkey 未配置时必须明确不可用,而不是假装成功
func TestPasskeysUnavailableWhenNil(t *testing.T) {
a, h := newTestAPI(t)
if a.Passkeys != nil {
t.Skip("测试构造里不该有 Passkeys")
}
rec := doAs(t, h, http.MethodPost, "/api/admin/account/passkeys/begin", "")
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("got %d, want 503", rec.Code)
}
}