package service import ( "context" "crypto/sha256" "encoding/base64" "encoding/binary" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/url" "sync" "time" "github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/webauthn" "gorm.io/gorm" "oci-portal/internal/model" ) // 通行密钥流程错误;api 层映射为用户可读提示。 var ( // ErrPasskeyNoAppURL 表示面板地址缺失,RP ID 无从派生。 ErrPasskeyNoAppURL = errors.New("面板地址未设置,请先在「设置 → 安全 → 网络与地址」保存面板地址") // ErrPasskeySession 表示挑战会话无效或已过期(一次性消费)。 ErrPasskeySession = errors.New("通行密钥会话无效或已过期,请重新发起") // ErrPasskeyVerify 表示凭据校验失败;不透出库层细节防探测。 ErrPasskeyVerify = errors.New("通行密钥校验失败") // ErrPasskeyLimit 表示已达单账号凭据数量上限。 ErrPasskeyLimit = errors.New("通行密钥数量已达上限,请先删除不用的") ) const ( // passkeyPendingTTL 是挑战会话有效期(WebAuthn 默认超时同量级)。 passkeyPendingTTL = 5 * time.Minute // passkeyMaxPerUser 是单账号凭据上限,防滥用堆积。 passkeyMaxPerUser = 10 // passkeyGuardUser 是登录守卫的用户名占位:discoverable 登录失败时账号未知。 passkeyGuardUser = "__passkey__" ) // passkeyPending 是一次进行中的 WebAuthn 仪式上下文;sessionId 一次性使用。 type passkeyPending struct { session webauthn.SessionData username string // 注册会话的归属账号;登录会话为空串 expires time.Time } // PasskeyService 承接通行密钥(WebAuthn)注册、登录与凭据管理。 type PasskeyService struct { db *gorm.DB settings *SettingService auth *AuthService mu sync.Mutex pending map[string]passkeyPending } // NewPasskeyService 组装依赖。 func NewPasskeyService(db *gorm.DB, settings *SettingService, auth *AuthService) *PasskeyService { return &PasskeyService{db: db, settings: settings, auth: auth, pending: map[string]passkeyPending{}} } // rp 按当前面板地址构造 WebAuthn 实例(RP ID = 域名,Origin = 完整来源); // 每次现算使「面板地址」设置变更即时生效,构造仅做配置校验、代价可忽略。 func (p *PasskeyService) rp() (*webauthn.WebAuthn, error) { app := p.settings.EffectiveAppURL() if app == "" { return nil, ErrPasskeyNoAppURL } u, err := url.Parse(app) if err != nil || u.Hostname() == "" { return nil, fmt.Errorf("parse app url: %w", err) } return webauthn.New(&webauthn.Config{ RPID: u.Hostname(), RPDisplayName: "OCI Portal", RPOrigins: []string{u.Scheme + "://" + u.Host}, }) } // passkeyUserHandle 是 WebAuthn user.id:用户主键的 8 字节大端序(改用户名不漂移)。 func passkeyUserHandle(id uint) []byte { b := make([]byte, 8) binary.BigEndian.PutUint64(b, uint64(id)) return b } // passkeyUser 以账号与其凭据集实现 webauthn.User。 type passkeyUser struct { user model.User keys []model.UserPasskey } func (u passkeyUser) WebAuthnID() []byte { return passkeyUserHandle(u.user.ID) } func (u passkeyUser) WebAuthnName() string { return u.user.Username } func (u passkeyUser) WebAuthnDisplayName() string { return u.user.Username } // WebAuthnCredentials 反序列化各行凭据 JSON;坏行跳过不阻断整体。 func (u passkeyUser) WebAuthnCredentials() []webauthn.Credential { out := make([]webauthn.Credential, 0, len(u.keys)) for _, k := range u.keys { var c webauthn.Credential if json.Unmarshal([]byte(k.Credential), &c) == nil { out = append(out, c) } } return out } // exclusions 生成注册排除清单,阻止同一验证器重复注册。 func (u passkeyUser) exclusions() []protocol.CredentialDescriptor { out := []protocol.CredentialDescriptor{} for _, c := range u.WebAuthnCredentials() { out = append(out, c.Descriptor()) } return out } // putPending 登记一次性挑战会话,返回下发给前端的 sessionId。 func (p *PasskeyService) putPending(session webauthn.SessionData, username string) (string, error) { id, err := randHex(16) if err != nil { return "", err } p.mu.Lock() p.gcPasskeyLocked() p.pending[id] = passkeyPending{session: session, username: username, expires: time.Now().Add(passkeyPendingTTL)} p.mu.Unlock() return id, nil } // takePending 取出并消费会话(一次性);不存在或过期视为无效。 func (p *PasskeyService) takePending(id string) (passkeyPending, error) { p.mu.Lock() defer p.mu.Unlock() entry, ok := p.pending[id] delete(p.pending, id) if !ok || time.Now().After(entry.expires) { return passkeyPending{}, ErrPasskeySession } return entry, nil } // gcPasskeyLocked 清理过期会话;调用方须持锁。 func (p *PasskeyService) gcPasskeyLocked() { now := time.Now() for k, e := range p.pending { if now.After(e.expires) { delete(p.pending, k) } } } // loadUser 载入账号与全部凭据。 func (p *PasskeyService) loadUser(ctx context.Context, username string) (passkeyUser, error) { user, err := p.auth.findUser(ctx, username) if err != nil { return passkeyUser{}, err } return p.attachKeys(ctx, *user) } // attachKeys 挂载账号的凭据行。 func (p *PasskeyService) attachKeys(ctx context.Context, user model.User) (passkeyUser, error) { keys := []model.UserPasskey{} err := p.db.WithContext(ctx).Where("user_id = ?", user.ID).Order("id").Find(&keys).Error if err != nil { return passkeyUser{}, fmt.Errorf("list passkeys: %w", err) } return passkeyUser{user: user, keys: keys}, nil } // BeginRegister 生成注册 options;ResidentKey 与用户验证均必需—— // 可发现凭据是免用户名登录的前提,UV 是 Passkey 登录豁免 TOTP 的前提。 func (p *PasskeyService) BeginRegister(ctx context.Context, username string) (string, *protocol.CredentialCreation, error) { w, err := p.rp() if err != nil { return "", nil, err } u, err := p.loadUser(ctx, username) if err != nil { return "", nil, err } if len(u.keys) >= passkeyMaxPerUser { return "", nil, ErrPasskeyLimit } opts, session, err := w.BeginRegistration(u, webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{ ResidentKey: protocol.ResidentKeyRequirementRequired, UserVerification: protocol.VerificationRequired, }), webauthn.WithExclusions(u.exclusions()), ) if err != nil { return "", nil, fmt.Errorf("begin registration: %w", err) } id, err := p.putPending(*session, username) return id, opts, err } // FinishRegister 校验注册响应并落库;成功后令牌版本递增(旧会话全部失效)。 func (p *PasskeyService) FinishRegister(ctx context.Context, username, sessionID, name string, body io.Reader, proof TokenProof) error { entry, err := p.takePending(sessionID) if err != nil { return err } if entry.username != username { return ErrPasskeySession } w, err := p.rp() if err != nil { return err } u, err := p.loadUser(ctx, username) if err != nil { return err } parsed, err := protocol.ParseCredentialCreationResponseBody(body) if err != nil { return fmt.Errorf("%w: %v", ErrPasskeyVerify, err) } cred, err := w.CreateCredential(u, entry.session, parsed) if err != nil { return fmt.Errorf("%w: %v", ErrPasskeyVerify, err) } // 凭据落库与令牌版本递增同事务提交,不留「因子已生效而旧令牌仍有效」的 // 半程状态;行锁下复核 proof,请求挂起期间令牌被撤销则整体拒绝 return p.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { user, err := lockUserForAuthChange(tx, username) if err != nil { return err } if err := p.auth.ensureTokenCurrentTx(tx, user, proof); err != nil { return err } if err := saveCredentialTx(tx, user.ID, name, p.currentOrigin(), cred); err != nil { return err } return bumpTokenVersionTx(tx, username) }) } // currentOrigin 是当前面板地址的 WebAuthn origin(scheme://host); // 注册时随凭据落库,作为该凭据可用性的判定依据。 func (p *PasskeyService) currentOrigin() string { return originOf(p.settings.EffectiveAppURL()) } // passkeyCredHash 是凭据 ID 的 SHA-256 hex:原始 ID 最长可达 1023 字节, // 编码后超出可索引长度,唯一约束与登录反查一律走定长哈希列。 func passkeyCredHash(id []byte) string { sum := sha256.Sum256(id) return hex.EncodeToString(sum[:]) } // saveCredentialTx 序列化凭据整段落库(事务内);名称缺省给默认值。 func saveCredentialTx(tx *gorm.DB, userID uint, name, origin string, cred *webauthn.Credential) error { raw, err := json.Marshal(cred) if err != nil { return fmt.Errorf("marshal credential: %w", err) } if name == "" { name = "通行密钥" } row := model.UserPasskey{ UserID: userID, Name: name, CredentialID: base64.RawURLEncoding.EncodeToString(cred.ID), CredentialIDHash: passkeyCredHash(cred.ID), Origin: origin, Credential: string(raw), } if err := tx.Create(&row).Error; err != nil { return fmt.Errorf("save passkey: %w", err) } return nil } // BeginLogin 生成断言 options(公开端点;无凭据也正常下发,不泄露账号状态)。 func (p *PasskeyService) BeginLogin(_ context.Context) (string, *protocol.CredentialAssertion, error) { w, err := p.rp() if err != nil { return "", nil, err } opts, session, err := w.BeginDiscoverableLogin( webauthn.WithUserVerification(protocol.VerificationRequired)) if err != nil { return "", nil, fmt.Errorf("begin login: %w", err) } id, err := p.putPending(*session, "") return id, opts, err } // FinishLogin 校验断言并签发 JWT(落地会话),返回登录账号名供留痕; // 失败按 IP 计入登录守卫(账号未知用占位名),锁定期内一律 ErrLoginLocked。 func (p *PasskeyService) FinishLogin(ctx context.Context, sessionID string, meta SessionMeta, body io.Reader) (string, time.Time, string, error) { key := guardKey(meta.ClientIP, passkeyGuardUser) now := time.Now() sec := securityOf(p.auth.settings) if p.auth.guard.locked(key, now, time.Duration(sec.LoginLockMinutes)*time.Minute) { return "", time.Time{}, "", ErrLoginLocked } user, err := p.validateLogin(ctx, sessionID, body) if err != nil { if lockErr := p.auth.failLogin(key, now, passkeyGuardUser, meta.ClientIP, sec); errors.Is(lockErr, ErrLoginLocked) { return "", time.Time{}, "", ErrLoginLocked } return "", time.Time{}, "", err } p.auth.guard.success(key) meta.Method = "passkey" token, expires, err := p.auth.signSessionToken(ctx, user, meta) return token, expires, user.Username, err } // validateLogin 消费会话并校验断言;成功后回写凭据状态。 func (p *PasskeyService) validateLogin(ctx context.Context, sessionID string, body io.Reader) (*model.User, error) { entry, err := p.takePending(sessionID) if err != nil { return nil, err } if entry.username != "" { return nil, ErrPasskeySession // 注册会话不可用于登录 } w, err := p.rp() if err != nil { return nil, err } parsed, err := protocol.ParseCredentialRequestResponseBody(body) if err != nil { return nil, fmt.Errorf("%w: %v", ErrPasskeyVerify, err) } var owner *model.User handler := func(_, userHandle []byte) (webauthn.User, error) { u, err := p.userByHandle(ctx, userHandle) if err != nil { return nil, err } owner = &u.user return u, nil } cred, err := w.ValidateDiscoverableLogin(handler, entry.session, parsed) if err != nil || owner == nil { return nil, ErrPasskeyVerify // 统一文案,不透出细节防探测 } p.touchCredential(ctx, cred) return owner, nil } // userByHandle 按 userHandle(用户主键大端 8 字节)载入用户与凭据。 func (p *PasskeyService) userByHandle(ctx context.Context, handle []byte) (passkeyUser, error) { if len(handle) != 8 { return passkeyUser{}, ErrPasskeyVerify } var user model.User err := p.db.WithContext(ctx).First(&user, uint(binary.BigEndian.Uint64(handle))).Error if err != nil { return passkeyUser{}, fmt.Errorf("find user by handle: %w", err) } return p.attachKeys(ctx, user) } // touchCredential 回写校验后的 signCount/flags 与最后使用时间;失败不阻断登录。 func (p *PasskeyService) touchCredential(ctx context.Context, cred *webauthn.Credential) { raw, err := json.Marshal(cred) if err != nil { return } p.db.WithContext(ctx).Model(&model.UserPasskey{}). Where("credential_id_hash = ?", passkeyCredHash(cred.ID)). Updates(map[string]any{"credential": string(raw), "last_used_at": time.Now()}) } // List 列出账号的通行密钥。 func (p *PasskeyService) List(ctx context.Context, username string) ([]model.UserPasskey, error) { u, err := p.loadUser(ctx, username) if err != nil { return nil, err } return u.keys, nil } // Remove 删除通行密钥(校验归属)并递增令牌版本; // 密码登录禁用期间通行密钥计入「至少一种登录方式」不变量,删除受防自锁保护。 func (p *PasskeyService) Remove(ctx context.Context, username string, id uint, proof TokenProof) error { err := p.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { user, err := lockUserForAuthChange(tx, username) if err != nil { return err } if err := p.auth.ensureTokenCurrentTx(tx, user, proof); err != nil { return err } if err := ensureNotLastPasskey(tx, user.ID, id, p.currentOrigin()); err != nil { return err } res := tx.Where("id = ? AND user_id = ?", id, user.ID).Delete(&model.UserPasskey{}) if res.Error != nil { return fmt.Errorf("remove passkey: %w", res.Error) } if res.RowsAffected == 0 { return gorm.ErrRecordNotFound } // 删除属敏感变更:版本递增与删除同事务提交,不留「已删而旧令牌仍有效」半程 return bumpTokenVersionTx(tx, username) }) return err } // ensureNotLastPasskey 事务内校验不变量:密码登录已禁用时,删除该钥匙后 // 须仍存在可实际登录的免密方式(provider 被禁用的身份不算),否则拒绝; // 开关读取失败按失败关闭处理,不允许失败放行。 func ensureNotLastPasskey(tx *gorm.DB, userID, passkeyID uint, origin string) error { off, err := settingValueTx(tx, settingSecPasswordLoginOff) if err != nil || off != "1" { return err } ok, err := usablePasswordlessTx(tx, userID, 0, passkeyID, origin) if err != nil { return err } if !ok { return ErrLastIdentity } return nil } // HasAny 报告当前面板 origin 下是否存在可用凭据;地址缺失或查询失败按无处理。 func (p *PasskeyService) HasAny(ctx context.Context) bool { if p.settings == nil { return false } origin := p.currentOrigin() if origin == "" { return false } var count int64 err := p.db.WithContext(ctx).Model(&model.UserPasskey{}). Where("origin = ?", origin).Count(&count).Error if err != nil { return false } return count > 0 }