449 lines
15 KiB
Go
449 lines
15 KiB
Go
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
|
|
}
|