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