// 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}) }