Files
ONE/backend/internal/auth/passkey.go
T
Sakurasan f1e639e0ba 账户中心:站主资料可编辑 + 身份绑定 + 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 天后台会话失效 —— 要修得加一张吊销表。
2026-09-30 01:08:55 +08:00

278 lines
8.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Passkey(WebAuthn)封装。
//
// 两件事分开:注册必须在管理员会话之下发起(否则任何人都能往站主账号上塞凭据),
// 登录是公开的、发现式的(不需要先输用户名,浏览器直接问系统要凭据)。
//
// challenge 与会话放进程内存而不是签名 cookie:单进程博客够用,重启只会让
// 正在进行中的注册/登录作废(下次重点即可),不会留下可复用的状态。
// 用完即删 —— challenge 是一次性的,留着就有重放面。
package auth
import (
"bytes"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strconv"
"sync"
"time"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"oneblog/internal/model"
)
const sessionTTL = 5 * time.Minute
var ErrSessionExpired = errors.New("passkey session expired or already used")
type Passkeys struct {
w *webauthn.WebAuthn
mu sync.Mutex
sessions map[string]*pkSession
}
type pkSession struct {
data webauthn.SessionData
userID int64 // 注册时=目标账号;登录时发现式则为 0
exp time.Time
}
// userAdapter 把我们的账号 + 凭据行喂给库的 webauthn.User 接口。
type userAdapter struct {
id int64
name string
disp string
creds []webauthn.Credential
}
func (u userAdapter) WebAuthnID() []byte { return []byte(strconv.FormatInt(u.id, 10)) }
func (u userAdapter) WebAuthnName() string { return u.name }
func (u userAdapter) WebAuthnDisplayName() string {
if u.disp != "" {
return u.disp
}
return u.name
}
func (u userAdapter) WebAuthnCredentials() []webauthn.Credential { return u.creds }
// NewPasskeys 构造一个 passkey 服务。rpID 是域名(不含 scheme/port),
// origin 是完整来源(开发时是 http://localhost:3000,两者可以不同)。
func NewPasskeys(displayName, rpID string, origins []string) (*Passkeys, error) {
w, err := webauthn.New(&webauthn.Config{
RPDisplayName: displayName,
RPID: rpID,
RPOrigins: origins,
// 站内凭据不需要证明来源可信:attestation 一律 none,
// 既避免解析各家认证报告的复杂度,也不把它当授权依据。
AttestationPreference: protocol.PreferNoAttestation,
AuthenticatorSelection: protocol.AuthenticatorSelection{
UserVerification: protocol.VerificationRequired,
// 可发现凭据:登录时不用先报用户名。
ResidentKey: protocol.ResidentKeyRequirementRequired,
},
})
if err != nil {
return nil, err
}
return &Passkeys{w: w, sessions: make(map[string]*pkSession)}, nil
}
func toCredentials(pks []model.Passkey) ([]webauthn.Credential, error) {
out := make([]webauthn.Credential, 0, len(pks))
for _, p := range pks {
key, err := base64.StdEncoding.DecodeString(p.PublicKey)
if err != nil {
return nil, fmt.Errorf("passkey %d public key: %w", p.ID, err)
}
id, err := base64.StdEncoding.DecodeString(p.CredentialID)
if err != nil {
// 兼容:credential_id 也可能是原始字符串(非 base64)
id = []byte(p.CredentialID)
}
out = append(out, webauthn.Credential{
ID: id,
PublicKey: key,
Authenticator: webauthn.Authenticator{
SignCount: p.SignCount,
},
})
}
return out, nil
}
// BeginRegistration 发起注册。返回给前端的创建参数与一次性 token。
func (p *Passkeys) BeginRegistration(userID int64, name, display string, existing []model.Passkey) (creationJSON json.RawMessage, token string, err error) {
creds, err := toCredentials(existing)
if err != nil {
return nil, "", err
}
u := userAdapter{id: userID, name: name, disp: display, creds: creds}
// 已存在的凭据要排除,免得同一台设备被重复登记
opts := []webauthn.RegistrationOption{}
if len(creds) > 0 {
exclude := make([]protocol.CredentialDescriptor, 0, len(creds))
for _, c := range creds {
exclude = append(exclude, protocol.CredentialDescriptor{
Type: protocol.PublicKeyCredentialType,
CredentialID: c.ID,
})
}
opts = append(opts, webauthn.WithExclusions(exclude))
}
creation, session, err := p.w.BeginRegistration(u, opts...)
if err != nil {
return nil, "", err
}
tok, err := randomToken()
if err != nil {
return nil, "", err
}
p.put(tok, &pkSession{data: *session, userID: userID, exp: time.Now().Add(sessionTTL)})
b, err := json.Marshal(creation)
if err != nil {
return nil, "", err
}
return b, tok, nil
}
// FinishRegistration 校验浏览器返回的凭据并交回要落库的字段。
// credJSON 是 PublicKeyCredential 的 JSON —— challenge/token 走外层字段,
// 凭据对象原样交给库解析(库自己校验 clientDataJSON.challenge 是否对得上)。
func (p *Passkeys) FinishRegistration(token string, userID int64, name, display string, existing []model.Passkey, credJSON []byte) (model.Passkey, error) {
sess, err := p.take(token, userID)
if err != nil {
return model.Passkey{}, err
}
creds, err := toCredentials(existing)
if err != nil {
return model.Passkey{}, err
}
u := userAdapter{id: userID, name: name, disp: display, creds: creds}
parsed, err := protocol.ParseCredentialCreationResponseBody(bytes.NewReader(credJSON))
if err != nil {
return model.Passkey{}, err
}
cred, err := p.w.CreateCredential(u, *sess, parsed)
if err != nil {
return model.Passkey{}, err
}
return model.Passkey{
UserID: userID,
CredentialID: base64.StdEncoding.EncodeToString(cred.ID),
PublicKey: base64.StdEncoding.EncodeToString(cred.PublicKey),
SignCount: cred.Authenticator.SignCount,
}, nil
}
// BeginLogin 发起发现式登录(不预先指定账号,由凭据自己带出身份)。
func (p *Passkeys) BeginLogin() (assertionJSON json.RawMessage, token string, err error) {
assertion, session, err := p.w.BeginDiscoverableLogin()
if err != nil {
return nil, "", err
}
tok, err := randomToken()
if err != nil {
return nil, "", err
}
p.put(tok, &pkSession{data: *session, exp: time.Now().Add(sessionTTL)})
b, err := json.Marshal(assertion)
if err != nil {
return nil, "", err
}
return b, tok, nil
}
// LoginResult 是一次 passkey 登录的结果。
// CloneWarning 透传库的判定:新计数 ≤ 已存计数时库认为凭据可能被克隆。
// 这里只上报不拦 —— 云同步的 passkey(iCloud Keychain 等)计数本就不单调,
// 硬拦会把合法用户挡在门外;但它是必须让站主看得见的信号。
type LoginResult struct {
CredentialID string // base64,交给 resolver 时用的同一个键
UserID int64
SignCount uint32
CloneWarning bool
}
// FinishLogin 完成登录:resolver 按 credential id 反查凭据(含所属账号),
// 验签通过后返回该凭据与新计数,调用方据此决定发哪种会话。
// credJSON 同 FinishRegistration,是客户端原样回传的 PublicKeyCredential JSON。
func (p *Passkeys) FinishLogin(token string, credJSON []byte, resolve func(credentialIDB64 string) (model.Passkey, error)) (LoginResult, error) {
var out LoginResult
sess, err := p.take(token, 0)
if err != nil {
return out, err
}
handler := func(rawID, userHandle []byte) (webauthn.User, error) {
pk, err := resolve(base64.StdEncoding.EncodeToString(rawID))
if err != nil {
return nil, err
}
creds, err := toCredentials([]model.Passkey{pk})
if err != nil {
return nil, err
}
out.CredentialID = pk.CredentialID
out.UserID = pk.UserID
// 名字只用于日志/报错,登录路径不展示
return userAdapter{id: pk.UserID, name: strconv.FormatInt(pk.UserID, 10), creds: creds}, nil
}
parsed, err := protocol.ParseCredentialRequestResponseBytes(credJSON)
if err != nil {
return out, err
}
cred, err := p.w.ValidateDiscoverableLogin(handler, *sess, parsed)
if err != nil {
return out, err
}
out.SignCount = cred.Authenticator.SignCount
out.CloneWarning = cred.Authenticator.CloneWarning
return out, nil
}
// put 存会话并顺手清掉过期的:map 只在这几个流程里活几分钟,
// 清扫是防泄漏而不是防攻击。
func (p *Passkeys) put(token string, s *pkSession) {
p.mu.Lock()
defer p.mu.Unlock()
now := time.Now()
for k, v := range p.sessions {
if v.exp.Before(now) {
delete(p.sessions, k)
}
}
p.sessions[token] = s
}
// take 取出并删除会话(一次性),并核对发起时的账号。
func (p *Passkeys) take(token string, userID int64) (*webauthn.SessionData, error) {
p.mu.Lock()
defer p.mu.Unlock()
s, ok := p.sessions[token]
if !ok {
return nil, ErrSessionExpired
}
delete(p.sessions, token)
if time.Now().After(s.exp) {
return nil, ErrSessionExpired
}
// 注册会话绑账号:不能拿自己发起的 challenge 去给别人的账号注册
if userID != 0 && s.userID != userID {
return nil, ErrSessionExpired
}
return &s.data, nil
}
func randomToken() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}