后台新增 /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 天后台会话失效 —— 要修得加一张吊销表。
278 lines
8.8 KiB
Go
278 lines
8.8 KiB
Go
// 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
|
||
}
|