后台新增 /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 天后台会话失效 —— 要修得加一张吊销表。
181 lines
4.9 KiB
Go
181 lines
4.9 KiB
Go
package store
|
||
|
||
import (
|
||
"errors"
|
||
"testing"
|
||
|
||
"oneblog/internal/model"
|
||
)
|
||
|
||
func TestEnsureOwner(t *testing.T) {
|
||
s := openTestStore(t)
|
||
|
||
owner, err := s.EnsureOwner("admin")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if owner.Role != model.RoleOwner || owner.Provider != "admin" {
|
||
t.Fatalf("owner row wrong: %+v", owner)
|
||
}
|
||
// 幂等:再取一次是同一行
|
||
again, err := s.EnsureOwner("admin")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if again.ID != owner.ID {
|
||
t.Fatalf("重复 EnsureOwner 建了两行: %d vs %d", owner.ID, again.ID)
|
||
}
|
||
// 改了环境变量里的用户名,handle 要跟上
|
||
got, err := s.EnsureOwner("newname")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got.ID != owner.ID || got.Handle != "newname" {
|
||
t.Fatalf("handle 未同步: %+v", got)
|
||
}
|
||
// 全库只应有一个 owner
|
||
all, err := s.ListReaders()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
n := 0
|
||
for _, r := range all {
|
||
if r.Role == model.RoleOwner {
|
||
n++
|
||
}
|
||
}
|
||
if n != 1 {
|
||
t.Fatalf("owner 行数 = %d, want 1", n)
|
||
}
|
||
}
|
||
|
||
func TestOwnerNotClobberedByReaderUpsert(t *testing.T) {
|
||
// 站主行是 provider=admin,OAuth 登录建的是 provider=github 行,
|
||
// 两者不能互相覆盖 —— 这是「站主能自定义资料」成立的前提。
|
||
s := openTestStore(t)
|
||
owner, err := s.EnsureOwner("admin")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := s.UpsertReader(model.Reader{Provider: "github", Handle: "someone", Name: "路人"}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
got, err := s.GetOwner()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got.ID != owner.ID || got.Provider != "admin" {
|
||
t.Fatalf("站主行被改动: %+v", got)
|
||
}
|
||
}
|
||
|
||
func TestBindIdentityGuards(t *testing.T) {
|
||
s := openTestStore(t)
|
||
owner, _ := s.EnsureOwner("admin")
|
||
other, err := s.UpsertReader(model.Reader{Provider: "github", Handle: "intruder", Name: "n"})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
if err := s.BindIdentity(owner.ID, "github", "12345", "littleckin"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// 重复绑同一个:幂等
|
||
if err := s.BindIdentity(owner.ID, "github", "12345", "littleckin"); err != nil {
|
||
t.Fatalf("重复绑定应幂等,got %v", err)
|
||
}
|
||
// 关键护栏:同一个外部账号不能被第二个用户占走
|
||
if err := s.BindIdentity(other.ID, "github", "12345", "hijack"); !errors.Is(err, ErrConflict) {
|
||
t.Fatalf("抢占他人身份应 ErrConflict, got %v", err)
|
||
}
|
||
// 换平台可以(UNIQUE 是 user_id+provider)
|
||
if err := s.BindIdentity(owner.ID, "telegram", "999", "tg"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
ids, err := s.ListIdentities(owner.ID)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(ids) != 2 {
|
||
t.Fatalf("绑定数 = %d, want 2", len(ids))
|
||
}
|
||
// 登录查找:按外部身份找到站主,且 role 正确
|
||
u, err := s.GetUserByIdentity("github", "12345")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if u.ID != owner.ID || u.Role != model.RoleOwner {
|
||
t.Fatalf("身份反查结果错: %+v", u)
|
||
}
|
||
if _, err := s.GetUserByIdentity("github", "nope"); !errors.Is(err, ErrNotFound) {
|
||
t.Fatalf("未绑定应 ErrNotFound, got %v", err)
|
||
}
|
||
// 解绑
|
||
if err := s.UnbindIdentity(owner.ID, "github"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := s.GetUserByIdentity("github", "12345"); !errors.Is(err, ErrNotFound) {
|
||
t.Fatal("解绑后仍能查到")
|
||
}
|
||
if err := s.UnbindIdentity(owner.ID, "github"); !errors.Is(err, ErrNotFound) {
|
||
t.Fatalf("重复解绑应 ErrNotFound, got %v", err)
|
||
}
|
||
}
|
||
|
||
func TestPasskeyLifecycle(t *testing.T) {
|
||
s := openTestStore(t)
|
||
owner, _ := s.EnsureOwner("admin")
|
||
other, _ := s.UpsertReader(model.Reader{Provider: "github", Handle: "x", Name: "x"})
|
||
|
||
p, err := s.AddPasskey(model.Passkey{UserID: owner.ID, CredentialID: "cred-1", PublicKey: "base64key", Name: "MacBook", SignCount: 3})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if p.ID == 0 {
|
||
t.Fatal("没拿到自增 id")
|
||
}
|
||
// 同一把凭据不能注册两次
|
||
if _, err := s.AddPasskey(model.Passkey{UserID: other.ID, CredentialID: "cred-1", PublicKey: "k", Name: "dup"}); err == nil {
|
||
t.Fatal("重复 credential_id 应被拒")
|
||
}
|
||
|
||
list, err := s.ListPasskeys(owner.ID)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(list) != 1 || list[0].Name != "MacBook" {
|
||
t.Fatalf("list=%+v", list)
|
||
}
|
||
if list[0].PublicKey != "" {
|
||
t.Fatal("列表接口不该回传公钥")
|
||
}
|
||
|
||
got, err := s.GetPasskeyByCredentialID("cred-1")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got.PublicKey != "base64key" || got.SignCount != 3 {
|
||
t.Fatalf("got=%+v", got)
|
||
}
|
||
|
||
if err := s.TouchPasskey(got.ID, 9); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
after, _ := s.GetPasskeyByCredentialID("cred-1")
|
||
if after.SignCount != 9 || after.LastUsedAt == "" {
|
||
t.Fatalf("TouchPasskey 未生效: %+v", after)
|
||
}
|
||
|
||
// 越权删除必须失败:id 是站主的,user_id 给别人
|
||
if err := s.DeletePasskey(after.ID, other.ID); !errors.Is(err, ErrNotFound) {
|
||
t.Fatalf("跨用户删除应 ErrNotFound, got %v", err)
|
||
}
|
||
if err := s.DeletePasskey(after.ID, owner.ID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if rest, _ := s.ListPasskeys(owner.ID); len(rest) != 0 {
|
||
t.Fatalf("删除后仍有 %d 条", len(rest))
|
||
}
|
||
}
|