账户中心:站主资料可编辑 + 身份绑定 + 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:
1 parent
7e302c51a6
commit
f1e639e0ba
30 files changed
+2523
-105
No files matched your search
@@ -0,0 +1,180 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,9 @@ func readingMinutes(md string) int { return render.ReadingMinutes(md) }
|
||||
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
// ErrConflict 表示要建的唯一键已被占(例如某个外部身份已绑到别的账号)。
|
||||
var ErrConflict = errors.New("conflict")
|
||||
|
||||
type Store struct {
|
||||
db *db.DB
|
||||
}
|
||||
@@ -120,6 +123,30 @@ func (s *Store) migrate() error {
|
||||
created_at TEXT NOT NULL,
|
||||
edited_at TEXT NOT NULL DEFAULT ''
|
||||
)`, ai),
|
||||
// 第三方身份绑定:一个账号每个平台只能绑一条(UNIQUE(user_id,provider)),
|
||||
// 同一个外部账号也只能属于一个用户(UNIQUE(provider,extern_uid))——
|
||||
// 后者是防接管的关键:不能靠「先用我的 GitHub 登录、再把你的账号绑上来」占位。
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS user_identities (
|
||||
id %s,
|
||||
user_id INTEGER NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
extern_uid TEXT NOT NULL,
|
||||
display TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(provider, extern_uid),
|
||||
UNIQUE(user_id, provider)
|
||||
)`, ai),
|
||||
// Passkey 一人可多把(笔记本 + 手机),所以不加 UNIQUE(user_id)
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS passkeys (
|
||||
id %s,
|
||||
user_id INTEGER NOT NULL,
|
||||
credential_id TEXT NOT NULL UNIQUE,
|
||||
public_key TEXT NOT NULL,
|
||||
sign_count INTEGER NOT NULL DEFAULT 0,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
last_used_at TEXT NOT NULL DEFAULT ''
|
||||
)`, ai),
|
||||
}
|
||||
for _, q := range stmts {
|
||||
if _, err := s.db.Exec(s.db.Q(q)); err != nil {
|
||||
@@ -137,6 +164,8 @@ func (s *Store) migrate() error {
|
||||
`ALTER TABLE posts ADD COLUMN images TEXT NOT NULL DEFAULT '[]'`,
|
||||
// 正文首个外链的预览卡片(og 标题/描述/封面),JSON 对象或空串
|
||||
`ALTER TABLE posts ADD COLUMN link_card TEXT NOT NULL DEFAULT ''`,
|
||||
// 账号角色:owner(站主,全库唯一)/ reader(评论区访客)
|
||||
`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'reader'`,
|
||||
}
|
||||
for _, q := range columnAdds {
|
||||
if _, err := s.db.Exec(s.db.Q(q)); err != nil && !strings.Contains(err.Error(), "already exists") &&
|
||||
@@ -157,6 +186,8 @@ func (s *Store) migrate() error {
|
||||
{"idx_comments_post", `CREATE INDEX IF NOT EXISTS idx_comments_post ON comments(post_id, created_at)`},
|
||||
{"idx_comments_user", `CREATE INDEX IF NOT EXISTS idx_comments_user ON comments(user_id)`},
|
||||
{"idx_comments_status", `CREATE INDEX IF NOT EXISTS idx_comments_status ON comments(status, created_at DESC)`},
|
||||
{"idx_identities_user", `CREATE INDEX IF NOT EXISTS idx_identities_user ON user_identities(user_id)`},
|
||||
{"idx_passkeys_user", `CREATE INDEX IF NOT EXISTS idx_passkeys_user ON passkeys(user_id)`},
|
||||
}
|
||||
for _, ix := range indexes {
|
||||
if _, err := s.db.Exec(s.db.Q(ix.ddl)); err != nil && !strings.Contains(err.Error(), "already exists") {
|
||||
@@ -240,6 +271,7 @@ func settingsFromMap(m map[string]string) model.Settings {
|
||||
st.CustomCSS = decodeCSSMap(m["custom_css"])
|
||||
st.CustomJS = m["custom_js"]
|
||||
st.SocialLinks = decodeSocialLinks(m["social_links"])
|
||||
st.AuthorAvatarKey = m["owner_avatar_key"]
|
||||
// 开关类:'1' / 'true' 都算开,其余(含空)算关
|
||||
st.CommentsEnabled = m["comments_enabled"] == "1" || strings.EqualFold(m["comments_enabled"], "true")
|
||||
st.CommentsReview = m["comments_review"] == "1" || strings.EqualFold(m["comments_review"], "true")
|
||||
@@ -447,6 +479,19 @@ func (s *Store) UpdateSettings(st model.Settings) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSetting 只写一个 KV,绕开 UpdateSettings 的全量替换。
|
||||
// 账户页改头像用它的自己的键,免得「站点设置」保存时被顺带清掉。
|
||||
func (s *Store) SetSetting(key, value string) error {
|
||||
if s.db.Dialect == db.Postgres {
|
||||
_, err := s.db.Exec(s.db.Q(`INSERT INTO settings(key,value) VALUES (?,?)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`), key, value)
|
||||
return err
|
||||
}
|
||||
_, err := s.db.Exec(s.db.Q(`INSERT INTO settings(key,value) VALUES (?,?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`), key, value)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------- posts ----------
|
||||
|
||||
type ListOptions struct {
|
||||
@@ -1655,8 +1700,15 @@ func (s *Store) UpsertReader(r model.Reader) (model.Reader, error) {
|
||||
return s.GetReaderByProviderHandle(r.Provider, r.Handle)
|
||||
}
|
||||
|
||||
// userCols 是 users 表的读取列清单。列在多处 SELECT 复用,抽出来免得加一列
|
||||
// 就要同步改一遍(漏一处就是扫错位置)。
|
||||
const userCols = `id,provider,handle,name,avatar_url,url,banned,role,created_at`
|
||||
|
||||
// userColsU 是 JOIN 查询里带 u. 前缀的同一份列清单。和 userCols 成对改。
|
||||
const userColsU = `u.id,u.provider,u.handle,u.name,u.avatar_url,u.url,u.banned,u.role,u.created_at`
|
||||
|
||||
func (s *Store) GetReader(id int64) (model.Reader, error) {
|
||||
r, err := scanReader(s.db.QueryRow(s.db.Q(`SELECT id,provider,handle,name,avatar_url,url,banned,created_at FROM users WHERE id = ?`), id))
|
||||
r, err := scanReader(s.db.QueryRow(s.db.Q(`SELECT `+userCols+` FROM users WHERE id = ?`), id))
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return model.Reader{}, ErrNotFound
|
||||
@@ -1667,7 +1719,7 @@ func (s *Store) GetReader(id int64) (model.Reader, error) {
|
||||
}
|
||||
|
||||
func (s *Store) GetReaderByProviderHandle(provider, handle string) (model.Reader, error) {
|
||||
r, err := scanReader(s.db.QueryRow(s.db.Q(`SELECT id,provider,handle,name,avatar_url,url,banned,created_at FROM users WHERE provider = ? AND handle = ?`), provider, handle))
|
||||
r, err := scanReader(s.db.QueryRow(s.db.Q(`SELECT `+userCols+` FROM users WHERE provider = ? AND handle = ?`), provider, handle))
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return model.Reader{}, ErrNotFound
|
||||
@@ -1688,11 +1740,11 @@ func (s *Store) SetReaderBanned(id int64, banned bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListReaders 后台的用户列表:带评论数,禁言中的排前面
|
||||
// ListReaders 后台的用户列表:带评论数,站主最前、禁言中的排前面
|
||||
func (s *Store) ListReaders() ([]model.Reader, error) {
|
||||
rows, err := s.db.Query(s.db.Q(`SELECT u.id,u.provider,u.handle,u.name,u.avatar_url,u.url,u.banned,u.created_at,
|
||||
rows, err := s.db.Query(s.db.Q(`SELECT u.id,u.provider,u.handle,u.name,u.avatar_url,u.url,u.banned,u.role,u.created_at,
|
||||
(SELECT COUNT(*) FROM comments c WHERE c.user_id = u.id AND c.is_deleted = 0) AS cnt
|
||||
FROM users u ORDER BY u.banned DESC, u.created_at DESC`))
|
||||
FROM users u ORDER BY CASE WHEN u.role = 'owner' THEN 0 ELSE 1 END, u.banned DESC, u.created_at DESC`))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1701,7 +1753,7 @@ func (s *Store) ListReaders() ([]model.Reader, error) {
|
||||
for rows.Next() {
|
||||
var r model.Reader
|
||||
var cnt int64
|
||||
if err := rows.Scan(&r.ID, &r.Provider, &r.Handle, &r.Name, &r.AvatarURL, &r.URL, &r.Banned, &r.CreatedAt, &cnt); err != nil {
|
||||
if err := rows.Scan(&r.ID, &r.Provider, &r.Handle, &r.Name, &r.AvatarURL, &r.URL, &r.Banned, &r.Role, &r.CreatedAt, &cnt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.CommentCount = cnt
|
||||
@@ -1710,6 +1762,182 @@ func (s *Store) ListReaders() ([]model.Reader, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UpdateProfile 改站主行的显示名。只碰 name —— provider/handle/role 是身份锚,
|
||||
// 不随资料编辑变动。
|
||||
func (s *Store) UpdateProfile(userID int64, name string) (model.Reader, error) {
|
||||
if _, err := s.db.Exec(s.db.Q(`UPDATE users SET name = ? WHERE id = ?`), name, userID); err != nil {
|
||||
return model.Reader{}, err
|
||||
}
|
||||
return s.GetReader(userID)
|
||||
}
|
||||
|
||||
// ---------- 站主账号 / 身份绑定 / passkey ----------
|
||||
|
||||
// EnsureOwner 取(或创建)站主账号行:provider=admin、role=owner。
|
||||
// handle 跟着 ONE_ADMIN_USER 走——改了环境变量后这里会同步,
|
||||
// 但 role=owner 只此一行,是「哪些身份登录算管理员」的锚点。
|
||||
func (s *Store) EnsureOwner(handle string) (model.Reader, error) {
|
||||
cur, err := s.GetOwner()
|
||||
if err == nil {
|
||||
if cur.Handle != handle {
|
||||
if _, uerr := s.db.Exec(s.db.Q(`UPDATE users SET handle = ? WHERE id = ?`), handle, cur.ID); uerr != nil {
|
||||
return model.Reader{}, uerr
|
||||
}
|
||||
cur.Handle = handle
|
||||
}
|
||||
return cur, nil
|
||||
}
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
return model.Reader{}, err
|
||||
}
|
||||
if _, err := s.db.Exec(s.db.Q(`INSERT INTO users (provider,handle,name,avatar_url,url,banned,role,created_at)
|
||||
VALUES (?,?,?,?,?,0,'owner',?)`), "admin", handle, "", "", "", now()); err != nil {
|
||||
return model.Reader{}, err
|
||||
}
|
||||
return s.GetOwner()
|
||||
}
|
||||
|
||||
// GetOwner 取站主行。role='owner' 全库唯一,按 provider 兜底兼容老数据
|
||||
// (老库里站主行只有 provider='admin',没有 role)。
|
||||
func (s *Store) GetOwner() (model.Reader, error) {
|
||||
r, err := scanReader(s.db.QueryRow(s.db.Q(
|
||||
`SELECT ` + userCols + ` FROM users WHERE role = 'owner' OR provider = 'admin' ORDER BY id LIMIT 1`)))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return model.Reader{}, ErrNotFound
|
||||
}
|
||||
return r, err
|
||||
}
|
||||
|
||||
// BindIdentity 把 (provider, extern_uid) 绑到某个用户上。
|
||||
// 外部账号已被别人占用时返回 ErrConflict —— 调用方必须原样拒绝,
|
||||
// 不能「后来者覆盖」,否则任何人都能抢先把别人的 GitHub 账号登记成自己的。
|
||||
func (s *Store) BindIdentity(userID int64, provider, externUID, display string) error {
|
||||
owner, err := s.GetUserByIdentity(provider, externUID)
|
||||
if err == nil {
|
||||
if owner.ID == userID {
|
||||
return nil // 重复绑定同一个,幂等放过
|
||||
}
|
||||
return ErrConflict
|
||||
}
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.Exec(s.db.Q(`INSERT INTO user_identities (user_id,provider,extern_uid,display,created_at)
|
||||
VALUES (?,?,?,?,?)`), userID, provider, externUID, display, now())
|
||||
return err
|
||||
}
|
||||
|
||||
// UnbindIdentity 解绑某平台的绑定。站主始终还有环境变量密码这条退路,
|
||||
// 所以这里不需要「不能解绑唯一登录方式」的护栏。
|
||||
func (s *Store) UnbindIdentity(userID int64, provider string) error {
|
||||
res, err := s.db.Exec(s.db.Q(`DELETE FROM user_identities WHERE user_id = ? AND provider = ?`), userID, provider)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ListIdentities(userID int64) ([]model.UserIdentity, error) {
|
||||
rows, err := s.db.Query(s.db.Q(`SELECT id,user_id,provider,extern_uid,display,created_at
|
||||
FROM user_identities WHERE user_id = ? ORDER BY created_at`), userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []model.UserIdentity{}
|
||||
for rows.Next() {
|
||||
var it model.UserIdentity
|
||||
if err := rows.Scan(&it.ID, &it.UserID, &it.Provider, &it.ExternUID, &it.Display, &it.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, it)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetUserByIdentity 按外部身份找用户:登录时先问它,命中即知道该发哪种会话。
|
||||
func (s *Store) GetUserByIdentity(provider, externUID string) (model.Reader, error) {
|
||||
r, err := scanReader(s.db.QueryRow(s.db.Q(
|
||||
`SELECT `+userColsU+`
|
||||
FROM user_identities i JOIN users u ON u.id = i.user_id
|
||||
WHERE i.provider = ? AND i.extern_uid = ?`), provider, externUID))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return model.Reader{}, ErrNotFound
|
||||
}
|
||||
return r, err
|
||||
}
|
||||
|
||||
// ---------- passkey ----------
|
||||
|
||||
func (s *Store) AddPasskey(p model.Passkey) (model.Passkey, error) {
|
||||
p.CreatedAt = now()
|
||||
res, err := s.db.Exec(s.db.Q(`INSERT INTO passkeys (user_id,credential_id,public_key,sign_count,name,created_at,last_used_at)
|
||||
VALUES (?,?,?,?,?,?,?)`), p.UserID, p.CredentialID, p.PublicKey, int64(p.SignCount), p.Name, p.CreatedAt, "")
|
||||
if err != nil {
|
||||
return model.Passkey{}, err
|
||||
}
|
||||
p.ID, _ = res.LastInsertId()
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// ListPasskeys 不返回 public_key:管理页只列名字与时间,凭据公钥
|
||||
// 没必要顺着列表接口到处走。
|
||||
func (s *Store) ListPasskeys(userID int64) ([]model.Passkey, error) {
|
||||
rows, err := s.db.Query(s.db.Q(`SELECT id,user_id,credential_id,'',sign_count,name,created_at,last_used_at
|
||||
FROM passkeys WHERE user_id = ? ORDER BY created_at`), userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []model.Passkey{}
|
||||
for rows.Next() {
|
||||
var p model.Passkey
|
||||
var sc int64
|
||||
if err := rows.Scan(&p.ID, &p.UserID, &p.CredentialID, &p.PublicKey, &sc, &p.Name, &p.CreatedAt, &p.LastUsedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.SignCount = uint32(sc)
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetPasskeyByCredentialID(credentialID string) (model.Passkey, error) {
|
||||
var p model.Passkey
|
||||
var sc int64
|
||||
err := s.db.QueryRow(s.db.Q(`SELECT id,user_id,credential_id,public_key,sign_count,name,created_at,last_used_at
|
||||
FROM passkeys WHERE credential_id = ?`), credentialID).
|
||||
Scan(&p.ID, &p.UserID, &p.CredentialID, &p.PublicKey, &sc, &p.Name, &p.CreatedAt, &p.LastUsedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return model.Passkey{}, ErrNotFound
|
||||
}
|
||||
p.SignCount = uint32(sc)
|
||||
return p, err
|
||||
}
|
||||
|
||||
// TouchPasskey 回写签名计数与使用时间。计数只增不减:
|
||||
// 新计数比库里记的小,说明凭据被克隆到多个 authenticator 上用过。
|
||||
func (s *Store) TouchPasskey(id int64, signCount uint32) error {
|
||||
_, err := s.db.Exec(s.db.Q(`UPDATE passkeys SET sign_count = ?, last_used_at = ? WHERE id = ?`),
|
||||
int64(signCount), now(), id)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeletePasskey 带 user_id 条件删:免得拿别人的 id 越权删凭据。
|
||||
func (s *Store) DeletePasskey(id, userID int64) error {
|
||||
res, err := s.db.Exec(s.db.Q(`DELETE FROM passkeys WHERE id = ? AND user_id = ?`), id, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func b2i(b bool) int64 {
|
||||
if b {
|
||||
return 1
|
||||
@@ -1720,7 +1948,7 @@ func b2i(b bool) int64 {
|
||||
func scanReader(sc interface{ Scan(...any) error }) (model.Reader, error) {
|
||||
var r model.Reader
|
||||
var banned int64
|
||||
err := sc.Scan(&r.ID, &r.Provider, &r.Handle, &r.Name, &r.AvatarURL, &r.URL, &banned, &r.CreatedAt)
|
||||
err := sc.Scan(&r.ID, &r.Provider, &r.Handle, &r.Name, &r.AvatarURL, &r.URL, &banned, &r.Role, &r.CreatedAt)
|
||||
r.Banned = banned == 1
|
||||
return r, err
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user