账户中心:站主资料可编辑 + 身份绑定 + 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,254 @@
|
||||
// 账户页的后端:站主资料、身份绑定列表、passkey 管理。
|
||||
//
|
||||
// 和「站点设置」的分工:站点设置管站点(标题、皮肤、评论开关),账户页管
|
||||
// 「你是谁 + 你能用什么方式登录」。昵称/简介仍是 author_name/author_bio
|
||||
// 两个 settings 键(单一来源,前端各处照旧读),头像另用 owner_avatar_key
|
||||
// 单键写入,避开 UpdateSettings 的全量替换。
|
||||
package admin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"oneblog/internal/httpx"
|
||||
"oneblog/internal/model"
|
||||
"oneblog/internal/storage"
|
||||
"oneblog/internal/store"
|
||||
)
|
||||
|
||||
const maxAvatarKeyLen = 160
|
||||
|
||||
// accountView 是账户页一次拉取的全部数据。
|
||||
type accountView struct {
|
||||
Name string `json:"name"`
|
||||
Bio string `json:"bio"`
|
||||
AvatarKey string `json:"avatar_key"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
Handle string `json:"handle"`
|
||||
Password passwordInfo `json:"password"`
|
||||
Identities []model.UserIdentity `json:"identities"`
|
||||
Passkeys []model.Passkey `json:"passkeys"`
|
||||
// Providers 告诉前端哪些平台可以绑(未配凭据的平台不出现)。
|
||||
Providers []string `json:"providers"`
|
||||
}
|
||||
|
||||
type passwordInfo struct {
|
||||
// 站主密码由环境变量管理,不进库也不做哈希 —— 这条退路保证
|
||||
// 「解绑所有身份 + 删光 passkey」也不会把自已锁在门外。
|
||||
ManagedBy string `json:"managed_by"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
func (a *API) account(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
httpx.Error(w, http.StatusMethodNotAllowed, "GET required")
|
||||
return
|
||||
}
|
||||
v, err := a.buildAccount()
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, v)
|
||||
}
|
||||
|
||||
func (a *API) buildAccount() (accountView, error) {
|
||||
owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
|
||||
if err != nil {
|
||||
return accountView{}, err
|
||||
}
|
||||
st, err := a.Store.GetSettings()
|
||||
if err != nil {
|
||||
return accountView{}, err
|
||||
}
|
||||
ids, err := a.Store.ListIdentities(owner.ID)
|
||||
if err != nil {
|
||||
return accountView{}, err
|
||||
}
|
||||
pks, err := a.Store.ListPasskeys(owner.ID)
|
||||
if err != nil {
|
||||
return accountView{}, err
|
||||
}
|
||||
v := accountView{
|
||||
// 昵称以站主行的 name 为准;老数据里它是空的,回落到站点设置的作者名。
|
||||
Name: firstNonEmptyStr(owner.Name, st.AuthorName),
|
||||
Bio: st.AuthorBio,
|
||||
AvatarKey: st.AuthorAvatarKey,
|
||||
AvatarURL: a.avatarURL(st.AuthorAvatarKey),
|
||||
Handle: owner.Handle,
|
||||
Password: passwordInfo{ManagedBy: "env:ONE_ADMIN_PASSWORD", Username: a.Cfg.AdminUser},
|
||||
Identities: ids,
|
||||
Passkeys: pks,
|
||||
}
|
||||
// 可绑定的平台:只有跳转式 OAuth 能在后台发起。Telegram 是评论区里的
|
||||
// 登录 widget,后台没有它的入口,所以不进这个列表(已绑的记录仍会显示)。
|
||||
for _, p := range []string{"github", "google"} {
|
||||
if a.providerEnabled(p) {
|
||||
v.Providers = append(v.Providers, p)
|
||||
}
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// providerEnabled 判断某个第三方平台是否配了凭据。绑定入口只列已配置的,
|
||||
// 否则点了必然报错。
|
||||
func (a *API) providerEnabled(name string) bool {
|
||||
switch name {
|
||||
case "github":
|
||||
return a.Cfg.GitHubClientID != "" && a.Cfg.GitHubClientSecret != ""
|
||||
case "google":
|
||||
return a.Cfg.GoogleClientID != "" && a.Cfg.GoogleClientSecret != ""
|
||||
case "telegram":
|
||||
return a.Cfg.TelegramBot != "" && a.Cfg.TelegramToken != ""
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// avatarURL 把 files key 解析成可访问 URL。key 指向的文件已删除时返回空串
|
||||
// (前端会自动回落到站标),不留一个打不开的链接。
|
||||
func (a *API) avatarURL(key string) string {
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
f, err := a.Store.GetFileByKey(key)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return storage.FileURL(f.Store, f.Key, a.Cfg.UploadsPublicBase)
|
||||
}
|
||||
|
||||
type patchAccountRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Bio *string `json:"bio"`
|
||||
AvatarKey *string `json:"avatar_key"`
|
||||
}
|
||||
|
||||
// patchAccount 改资料。只动传了的字段;头像 key 必须是 files 表里真实存在的
|
||||
// 图片,免得存一个指向任意字符串的死链。
|
||||
func (a *API) patchAccount(w http.ResponseWriter, r *http.Request) {
|
||||
var in patchAccountRequest
|
||||
if err := httpx.Decode(r, &in); err != nil {
|
||||
httpx.BadRequest(w, "invalid body")
|
||||
return
|
||||
}
|
||||
owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
st, err := a.Store.GetSettings()
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
changed := false
|
||||
|
||||
if in.Name != nil {
|
||||
name := strings.TrimSpace(*in.Name)
|
||||
if len([]rune(name)) > 40 {
|
||||
httpx.BadRequest(w, "昵称最多 40 字")
|
||||
return
|
||||
}
|
||||
if name == "" {
|
||||
httpx.BadRequest(w, "昵称不能为空")
|
||||
return
|
||||
}
|
||||
// 站主行与站点设置两处都要写:前者是身份来源,后者是既有前端读的地方。
|
||||
if _, err := a.Store.UpdateProfile(owner.ID, name); err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
st.AuthorName = name
|
||||
changed = true
|
||||
}
|
||||
if in.Bio != nil {
|
||||
bio := strings.TrimSpace(*in.Bio)
|
||||
if len([]rune(bio)) > 200 {
|
||||
httpx.BadRequest(w, "简介最多 200 字")
|
||||
return
|
||||
}
|
||||
st.AuthorBio = bio
|
||||
changed = true
|
||||
}
|
||||
if in.AvatarKey != nil {
|
||||
key := strings.TrimSpace(*in.AvatarKey)
|
||||
if len(key) > maxAvatarKeyLen {
|
||||
httpx.BadRequest(w, "头像 key 过长")
|
||||
return
|
||||
}
|
||||
if key != "" {
|
||||
f, err := a.Store.GetFileByKey(key)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
httpx.BadRequest(w, "头像文件不存在,请重新上传")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(f.Mime, "image/") {
|
||||
httpx.BadRequest(w, "头像必须是图片")
|
||||
return
|
||||
}
|
||||
}
|
||||
st.AuthorAvatarKey = key
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
httpx.BadRequest(w, "没有要更新的字段")
|
||||
return
|
||||
}
|
||||
// AuthorAvatarURL 是算出来的,不入库;写库前清掉免得误读。
|
||||
st.AuthorAvatarURL = ""
|
||||
if err := a.Store.UpdateSettings(st); err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
// 头像键单独写:UpdateSettings 是全量替换,不含这个键。
|
||||
if in.AvatarKey != nil {
|
||||
if err := a.Store.SetSetting("owner_avatar_key", st.AuthorAvatarKey); err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
v, err := a.buildAccount()
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, v)
|
||||
}
|
||||
|
||||
// unbindIdentity 解绑一个第三方登录方式。
|
||||
// 站主始终有环境变量密码兜底,所以这里不需要「不能解绑唯一登录方式」的护栏。
|
||||
func (a *API) unbindIdentity(w http.ResponseWriter, r *http.Request) {
|
||||
provider := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/account/identities/"), "/")
|
||||
if provider == "" || strings.Contains(provider, "/") {
|
||||
httpx.BadRequest(w, "bad provider")
|
||||
return
|
||||
}
|
||||
owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
if err := a.Store.UnbindIdentity(owner.ID, provider); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func firstNonEmptyStr(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"oneblog/internal/auth"
|
||||
"oneblog/internal/config"
|
||||
"oneblog/internal/httpx"
|
||||
"oneblog/internal/hub"
|
||||
@@ -45,6 +46,8 @@ type API struct {
|
||||
// Thumbs 是缩略图磁盘缓存(main.go 装配)。删上传文件时顺手清掉它的
|
||||
// 缩略图产物,否则已删图片会一直占着缓存。
|
||||
Thumbs *thumbs.Store
|
||||
// Passkeys 是 WebAuthn 服务(main.go 装配;未配置时为 nil,相关端点直接 503)
|
||||
Passkeys *auth.Passkeys
|
||||
|
||||
loginOnce sync.Once
|
||||
logins *loginLimiter
|
||||
@@ -85,6 +88,22 @@ func (a *API) Routes() http.Handler {
|
||||
mux.HandleFunc("/api/admin/files/import", a.guard(a.importFiles))
|
||||
mux.HandleFunc("/api/admin/files/", a.guard(a.fileByID))
|
||||
mux.HandleFunc("/api/admin/settings", a.guard(a.settings))
|
||||
// 账户页:资料 + 身份绑定 + passkey
|
||||
mux.HandleFunc("/api/admin/account", a.guard(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
a.account(w, r)
|
||||
case http.MethodPatch, http.MethodPut:
|
||||
a.patchAccount(w, r)
|
||||
default:
|
||||
httpx.Error(w, http.StatusMethodNotAllowed, "GET/PATCH required")
|
||||
}
|
||||
}))
|
||||
mux.HandleFunc("/api/admin/account/identities/", a.guard(a.unbindIdentity))
|
||||
mux.HandleFunc("/api/admin/account/passkeys", a.guard(a.listPasskeys))
|
||||
mux.HandleFunc("/api/admin/account/passkeys/begin", a.guard(a.beginPasskey))
|
||||
mux.HandleFunc("/api/admin/account/passkeys/finish", a.guard(a.finishPasskey))
|
||||
mux.HandleFunc("/api/admin/account/passkeys/", a.guard(a.deletePasskey))
|
||||
mux.HandleFunc("/api/admin/comments", a.guard(a.adminComments))
|
||||
mux.HandleFunc("/api/admin/comments/", a.guard(a.adminCommentByID))
|
||||
mux.HandleFunc("/api/admin/readers", a.guard(a.adminReaders))
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// Passkey 的管理端点:列出、注册(两步)、删除。
|
||||
//
|
||||
// 全部在 guard 之后 —— 注册凭据等于发放永久登录方式,必须已是管理员。
|
||||
// 删光 passkey 也不会把自已锁死:站主密码走环境变量,不在这张表里。
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"oneblog/internal/auth"
|
||||
"oneblog/internal/httpx"
|
||||
"oneblog/internal/store"
|
||||
)
|
||||
|
||||
// passkeyName 是站主给这把凭据起的名字(「MacBook 指纹」「iPhone」)。
|
||||
type passkeyNameRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (a *API) listPasskeys(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
httpx.Error(w, http.StatusMethodNotAllowed, "GET required")
|
||||
return
|
||||
}
|
||||
owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
list, err := a.Store.ListPasskeys(owner.ID)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, map[string]any{"passkeys": list})
|
||||
}
|
||||
|
||||
func (a *API) beginPasskey(w http.ResponseWriter, r *http.Request) {
|
||||
if a.Passkeys == nil {
|
||||
httpx.Error(w, http.StatusServiceUnavailable, "passkey 未启用")
|
||||
return
|
||||
}
|
||||
owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
existing, err := a.Store.ListPasskeys(owner.ID)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
creation, token, err := a.Passkeys.BeginRegistration(owner.ID, owner.Handle, owner.Name, existing)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, map[string]any{"options": creation, "token": token})
|
||||
}
|
||||
|
||||
func (a *API) finishPasskey(w http.ResponseWriter, r *http.Request) {
|
||||
if a.Passkeys == nil {
|
||||
httpx.Error(w, http.StatusServiceUnavailable, "passkey 未启用")
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Token string `json:"token"`
|
||||
Name string `json:"name"`
|
||||
Credential json.RawMessage `json:"credential"`
|
||||
}
|
||||
if err := httpx.Decode(r, &in); err != nil || in.Token == "" || len(in.Credential) == 0 {
|
||||
httpx.BadRequest(w, "token 与 credential 都要传")
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(in.Name)
|
||||
if len([]rune(name)) > 40 {
|
||||
httpx.BadRequest(w, "名称最多 40 字")
|
||||
return
|
||||
}
|
||||
if name == "" {
|
||||
name = "未命名设备"
|
||||
}
|
||||
owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
existing, err := a.Store.ListPasskeys(owner.ID)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
pk, err := a.Passkeys.FinishRegistration(in.Token, owner.ID, owner.Handle, owner.Name, existing, in.Credential)
|
||||
if errors.Is(err, auth.ErrSessionExpired) {
|
||||
httpx.Error(w, http.StatusGone, "注册已过期,请重新开始")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
httpx.Error(w, http.StatusBadRequest, "passkey 校验失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
pk.Name = name
|
||||
created, err := a.Store.AddPasskey(pk)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
// 公钥不回传:前端不需要,少一处能误用的字段
|
||||
created.PublicKey = ""
|
||||
httpx.Created(w, created)
|
||||
}
|
||||
|
||||
func (a *API) deletePasskey(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
httpx.Error(w, http.StatusMethodNotAllowed, "DELETE required")
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/account/passkeys/"), "/"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
httpx.BadRequest(w, "bad passkey id")
|
||||
return
|
||||
}
|
||||
owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
if err := a.Store.DeletePasskey(id, owner.ID); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, map[string]any{"ok": true})
|
||||
}
|
||||
Reference in new issue
Block a user