mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-05-12 02:20:36 +08:00
Add complete MFA support with TOTP, recovery codes, WebAuthn, trusted-device cookie flow, and email/SMS OTP delivery via notification channels. Security follow-up: trusted device tokens are stored in HttpOnly cookies, and SMS OTP reuses the existing Webhook notifier to avoid introducing a new dynamic URL sink.
367 lines
14 KiB
Go
367 lines
14 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"backupx/server/internal/apperror"
|
|
"backupx/server/internal/model"
|
|
"backupx/server/internal/security"
|
|
)
|
|
|
|
type WebAuthnRequestContext struct {
|
|
RPID string
|
|
Origin string
|
|
}
|
|
|
|
type WebAuthnRegistrationOptionsInput struct {
|
|
CurrentPassword string `json:"currentPassword" binding:"required,min=8,max=128"`
|
|
}
|
|
|
|
type WebAuthnRegistrationFinishInput struct {
|
|
Name string `json:"name" binding:"omitempty,max=128"`
|
|
Credential security.WebAuthnRegistrationResponse `json:"credential" binding:"required"`
|
|
}
|
|
|
|
type WebAuthnCredentialDeleteInput struct {
|
|
CurrentPassword string `json:"currentPassword" binding:"required,min=8,max=128"`
|
|
}
|
|
|
|
type WebAuthnLoginOptionsInput struct {
|
|
Username string `json:"username" binding:"required,min=3,max=64"`
|
|
Password string `json:"password" binding:"required,min=8,max=128"`
|
|
}
|
|
|
|
type webAuthnPublicKeyCredentialParam struct {
|
|
Type string `json:"type"`
|
|
Alg int `json:"alg"`
|
|
}
|
|
|
|
type webAuthnRelyingParty struct {
|
|
Name string `json:"name"`
|
|
ID string `json:"id"`
|
|
}
|
|
|
|
type webAuthnUserEntity struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
DisplayName string `json:"displayName"`
|
|
}
|
|
|
|
type webAuthnCredentialDescriptor struct {
|
|
Type string `json:"type"`
|
|
ID string `json:"id"`
|
|
}
|
|
|
|
type webAuthnAuthenticatorSelection struct {
|
|
UserVerification string `json:"userVerification"`
|
|
}
|
|
|
|
type WebAuthnRegistrationOptions struct {
|
|
Challenge string `json:"challenge"`
|
|
RP webAuthnRelyingParty `json:"rp"`
|
|
User webAuthnUserEntity `json:"user"`
|
|
PubKeyCredParams []webAuthnPublicKeyCredentialParam `json:"pubKeyCredParams"`
|
|
Timeout int `json:"timeout"`
|
|
Attestation string `json:"attestation"`
|
|
AuthenticatorSelection webAuthnAuthenticatorSelection `json:"authenticatorSelection"`
|
|
ExcludeCredentials []webAuthnCredentialDescriptor `json:"excludeCredentials"`
|
|
}
|
|
|
|
type WebAuthnLoginOptions struct {
|
|
Challenge string `json:"challenge"`
|
|
RPID string `json:"rpId"`
|
|
Timeout int `json:"timeout"`
|
|
UserVerification string `json:"userVerification"`
|
|
AllowCredentials []webAuthnCredentialDescriptor `json:"allowCredentials"`
|
|
}
|
|
|
|
func (s *AuthService) BeginWebAuthnRegistration(ctx context.Context, subject string, input WebAuthnRegistrationOptionsInput, request WebAuthnRequestContext) (*WebAuthnRegistrationOptions, error) {
|
|
user, err := s.userBySubject(ctx, subject)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := security.ComparePassword(user.PasswordHash, input.CurrentPassword); err != nil {
|
|
return nil, apperror.BadRequest("AUTH_WRONG_PASSWORD", "当前密码不正确", err)
|
|
}
|
|
credentials, err := parseWebAuthnCredentials(user.WebAuthnCredentials)
|
|
if err != nil {
|
|
return nil, apperror.Internal("AUTH_WEBAUTHN_INVALID", "通行密钥配置异常", err)
|
|
}
|
|
challenge, err := security.GenerateWebAuthnChallenge()
|
|
if err != nil {
|
|
return nil, apperror.Internal("AUTH_WEBAUTHN_CHALLENGE_FAILED", "无法生成通行密钥挑战", err)
|
|
}
|
|
state := webAuthnChallengeState{
|
|
Type: "register",
|
|
Challenge: challenge,
|
|
RPID: request.RPID,
|
|
Origin: request.Origin,
|
|
ExpiresAt: time.Now().UTC().Add(mfaChallengeTTL),
|
|
}
|
|
if err := s.saveWebAuthnChallenge(ctx, user, state); err != nil {
|
|
return nil, err
|
|
}
|
|
exclude := make([]webAuthnCredentialDescriptor, 0, len(credentials))
|
|
for _, credential := range credentials {
|
|
exclude = append(exclude, webAuthnCredentialDescriptor{Type: "public-key", ID: credential.CredentialID})
|
|
}
|
|
return &WebAuthnRegistrationOptions{
|
|
Challenge: challenge,
|
|
RP: webAuthnRelyingParty{Name: "BackupX", ID: request.RPID},
|
|
User: webAuthnUserEntity{
|
|
ID: security.EncodeBase64URL([]byte(fmt.Sprintf("%d", user.ID))),
|
|
Name: user.Username,
|
|
DisplayName: user.DisplayName,
|
|
},
|
|
PubKeyCredParams: []webAuthnPublicKeyCredentialParam{
|
|
{Type: "public-key", Alg: -7},
|
|
},
|
|
Timeout: int(mfaChallengeTTL / time.Millisecond),
|
|
Attestation: "none",
|
|
AuthenticatorSelection: webAuthnAuthenticatorSelection{UserVerification: "preferred"},
|
|
ExcludeCredentials: exclude,
|
|
}, nil
|
|
}
|
|
|
|
func (s *AuthService) FinishWebAuthnRegistration(ctx context.Context, subject string, input WebAuthnRegistrationFinishInput) (*UserOutput, error) {
|
|
user, err := s.userBySubject(ctx, subject)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
state, err := s.loadWebAuthnChallenge(user, "register")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
parsed, err := security.VerifyWebAuthnRegistration(input.Credential, state.Challenge, state.RPID, state.Origin)
|
|
if err != nil {
|
|
return nil, apperror.BadRequest("AUTH_WEBAUTHN_VERIFY_FAILED", "通行密钥注册校验失败", err)
|
|
}
|
|
credentials, err := parseWebAuthnCredentials(user.WebAuthnCredentials)
|
|
if err != nil {
|
|
return nil, apperror.Internal("AUTH_WEBAUTHN_INVALID", "通行密钥配置异常", err)
|
|
}
|
|
for _, credential := range credentials {
|
|
if credential.CredentialID == parsed.CredentialID {
|
|
return nil, apperror.Conflict("AUTH_WEBAUTHN_EXISTS", "该通行密钥已注册", nil)
|
|
}
|
|
}
|
|
id, err := randomURLToken(16)
|
|
if err != nil {
|
|
return nil, apperror.Internal("AUTH_WEBAUTHN_SAVE_FAILED", "无法生成通行密钥编号", err)
|
|
}
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
name := strings.TrimSpace(input.Name)
|
|
if name == "" {
|
|
name = "通行密钥"
|
|
}
|
|
credentials = append(credentials, WebAuthnCredentialRecord{
|
|
ID: id,
|
|
Name: normalizeTrustedDeviceName(name),
|
|
CredentialID: parsed.CredentialID,
|
|
PublicKeyX: parsed.PublicKeyX,
|
|
PublicKeyY: parsed.PublicKeyY,
|
|
SignCount: parsed.SignCount,
|
|
CreatedAt: now,
|
|
})
|
|
encoded, err := encodeWebAuthnCredentials(credentials)
|
|
if err != nil {
|
|
return nil, apperror.Internal("AUTH_WEBAUTHN_SAVE_FAILED", "无法保存通行密钥", err)
|
|
}
|
|
user.WebAuthnCredentials = encoded
|
|
user.WebAuthnChallengeCiphertext = ""
|
|
if err := s.users.Update(ctx, user); err != nil {
|
|
return nil, apperror.Internal("AUTH_WEBAUTHN_SAVE_FAILED", "无法保存通行密钥", err)
|
|
}
|
|
if s.auditService != nil {
|
|
s.auditService.Record(AuditEntry{
|
|
UserID: user.ID, Username: user.Username,
|
|
Category: "auth", Action: "webauthn_register",
|
|
TargetType: "webauthn_credential", TargetID: id, TargetName: name,
|
|
Detail: "注册通行密钥",
|
|
})
|
|
}
|
|
return ToUserOutput(user), nil
|
|
}
|
|
|
|
func (s *AuthService) BeginWebAuthnLogin(ctx context.Context, input WebAuthnLoginOptionsInput, request WebAuthnRequestContext, clientKey string) (*WebAuthnLoginOptions, error) {
|
|
user, err := s.verifyPasswordForMFAStart(ctx, input.Username, input.Password, clientKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
credentials, err := parseWebAuthnCredentials(user.WebAuthnCredentials)
|
|
if err != nil {
|
|
return nil, apperror.Internal("AUTH_WEBAUTHN_INVALID", "通行密钥配置异常", err)
|
|
}
|
|
if len(credentials) == 0 {
|
|
return nil, apperror.BadRequest("AUTH_WEBAUTHN_NOT_ENABLED", "当前账号未注册通行密钥", nil)
|
|
}
|
|
challenge, err := security.GenerateWebAuthnChallenge()
|
|
if err != nil {
|
|
return nil, apperror.Internal("AUTH_WEBAUTHN_CHALLENGE_FAILED", "无法生成通行密钥挑战", err)
|
|
}
|
|
state := webAuthnChallengeState{
|
|
Type: "login",
|
|
Challenge: challenge,
|
|
RPID: request.RPID,
|
|
Origin: request.Origin,
|
|
ExpiresAt: time.Now().UTC().Add(mfaChallengeTTL),
|
|
}
|
|
if err := s.saveWebAuthnChallenge(ctx, user, state); err != nil {
|
|
return nil, err
|
|
}
|
|
allowed := make([]webAuthnCredentialDescriptor, 0, len(credentials))
|
|
for _, credential := range credentials {
|
|
allowed = append(allowed, webAuthnCredentialDescriptor{Type: "public-key", ID: credential.CredentialID})
|
|
}
|
|
return &WebAuthnLoginOptions{
|
|
Challenge: challenge,
|
|
RPID: request.RPID,
|
|
Timeout: int(mfaChallengeTTL / time.Millisecond),
|
|
UserVerification: "preferred",
|
|
AllowCredentials: allowed,
|
|
}, nil
|
|
}
|
|
|
|
func (s *AuthService) VerifyWebAuthnLogin(ctx context.Context, user *model.User, assertion security.WebAuthnLoginAssertion, clientKey string) error {
|
|
state, err := s.loadWebAuthnChallenge(user, "login")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
credentials, err := parseWebAuthnCredentials(user.WebAuthnCredentials)
|
|
if err != nil {
|
|
return apperror.Internal("AUTH_WEBAUTHN_INVALID", "通行密钥配置异常", err)
|
|
}
|
|
rawID := strings.TrimSpace(assertion.RawID)
|
|
if rawID == "" {
|
|
rawID = strings.TrimSpace(assertion.ID)
|
|
}
|
|
for i := range credentials {
|
|
credential := &credentials[i]
|
|
if credential.CredentialID != rawID {
|
|
continue
|
|
}
|
|
nextSignCount, err := security.VerifyWebAuthnAssertion(assertion, state.Challenge, state.RPID, state.Origin, security.WebAuthnCredentialMaterial{
|
|
CredentialID: credential.CredentialID,
|
|
PublicKeyX: credential.PublicKeyX,
|
|
PublicKeyY: credential.PublicKeyY,
|
|
SignCount: credential.SignCount,
|
|
})
|
|
if err != nil {
|
|
return apperror.Unauthorized("AUTH_WEBAUTHN_INVALID", "通行密钥校验失败", err)
|
|
}
|
|
credential.SignCount = nextSignCount
|
|
credential.LastUsedAt = time.Now().UTC().Format(time.RFC3339)
|
|
encoded, err := encodeWebAuthnCredentials(credentials)
|
|
if err != nil {
|
|
return apperror.Internal("AUTH_WEBAUTHN_SAVE_FAILED", "无法更新通行密钥", err)
|
|
}
|
|
user.WebAuthnCredentials = encoded
|
|
user.WebAuthnChallengeCiphertext = ""
|
|
if err := s.users.Update(ctx, user); err != nil {
|
|
return apperror.Internal("AUTH_WEBAUTHN_SAVE_FAILED", "无法更新通行密钥", err)
|
|
}
|
|
if s.auditService != nil {
|
|
s.auditService.Record(AuditEntry{
|
|
UserID: user.ID, Username: user.Username,
|
|
Category: "auth", Action: "webauthn_used",
|
|
TargetType: "webauthn_credential", TargetID: credential.ID, TargetName: credential.Name,
|
|
Detail: "使用通行密钥完成多因素验证", ClientIP: clientKey,
|
|
})
|
|
}
|
|
return nil
|
|
}
|
|
return apperror.Unauthorized("AUTH_WEBAUTHN_INVALID", "通行密钥不存在", nil)
|
|
}
|
|
|
|
func (s *AuthService) ListWebAuthnCredentials(ctx context.Context, subject string) ([]WebAuthnCredentialOutput, error) {
|
|
user, err := s.userBySubject(ctx, subject)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
credentials, err := parseWebAuthnCredentials(user.WebAuthnCredentials)
|
|
if err != nil {
|
|
return nil, apperror.Internal("AUTH_WEBAUTHN_INVALID", "通行密钥配置异常", err)
|
|
}
|
|
output := make([]WebAuthnCredentialOutput, 0, len(credentials))
|
|
for _, credential := range credentials {
|
|
output = append(output, toWebAuthnCredentialOutput(credential))
|
|
}
|
|
return output, nil
|
|
}
|
|
|
|
func (s *AuthService) DeleteWebAuthnCredential(ctx context.Context, subject string, id string, input WebAuthnCredentialDeleteInput) (*UserOutput, error) {
|
|
user, err := s.userBySubject(ctx, subject)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := security.ComparePassword(user.PasswordHash, input.CurrentPassword); err != nil {
|
|
return nil, apperror.BadRequest("AUTH_WRONG_PASSWORD", "当前密码不正确", err)
|
|
}
|
|
credentials, err := parseWebAuthnCredentials(user.WebAuthnCredentials)
|
|
if err != nil {
|
|
return nil, apperror.Internal("AUTH_WEBAUTHN_INVALID", "通行密钥配置异常", err)
|
|
}
|
|
found := false
|
|
filtered := make([]WebAuthnCredentialRecord, 0, len(credentials))
|
|
for _, credential := range credentials {
|
|
if credential.ID == strings.TrimSpace(id) {
|
|
found = true
|
|
} else {
|
|
filtered = append(filtered, credential)
|
|
}
|
|
}
|
|
if !found {
|
|
return nil, apperror.New(404, "AUTH_WEBAUTHN_NOT_FOUND", "通行密钥不存在", nil)
|
|
}
|
|
encoded, err := encodeWebAuthnCredentials(filtered)
|
|
if err != nil {
|
|
return nil, apperror.Internal("AUTH_WEBAUTHN_SAVE_FAILED", "无法更新通行密钥", err)
|
|
}
|
|
user.WebAuthnCredentials = encoded
|
|
clearTrustedDevicesIfMFAOff(user)
|
|
if err := s.users.Update(ctx, user); err != nil {
|
|
return nil, apperror.Internal("AUTH_WEBAUTHN_DELETE_FAILED", "无法删除通行密钥", err)
|
|
}
|
|
if s.auditService != nil {
|
|
s.auditService.Record(AuditEntry{
|
|
UserID: user.ID, Username: user.Username,
|
|
Category: "auth", Action: "webauthn_delete",
|
|
TargetType: "webauthn_credential", TargetID: strings.TrimSpace(id),
|
|
Detail: "删除通行密钥",
|
|
})
|
|
}
|
|
return ToUserOutput(user), nil
|
|
}
|
|
|
|
func (s *AuthService) saveWebAuthnChallenge(ctx context.Context, user *model.User, state webAuthnChallengeState) error {
|
|
ciphertext, err := s.twoFactorCipher.EncryptJSON(state)
|
|
if err != nil {
|
|
return apperror.Internal("AUTH_WEBAUTHN_CHALLENGE_FAILED", "无法保存通行密钥挑战", err)
|
|
}
|
|
user.WebAuthnChallengeCiphertext = ciphertext
|
|
if err := s.users.Update(ctx, user); err != nil {
|
|
return apperror.Internal("AUTH_WEBAUTHN_CHALLENGE_FAILED", "无法保存通行密钥挑战", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *AuthService) loadWebAuthnChallenge(user *model.User, challengeType string) (*webAuthnChallengeState, error) {
|
|
if strings.TrimSpace(user.WebAuthnChallengeCiphertext) == "" {
|
|
return nil, apperror.BadRequest("AUTH_WEBAUTHN_CHALLENGE_MISSING", "请先发起通行密钥验证", nil)
|
|
}
|
|
var state webAuthnChallengeState
|
|
if err := s.twoFactorCipher.DecryptJSON(user.WebAuthnChallengeCiphertext, &state); err != nil {
|
|
return nil, apperror.Internal("AUTH_WEBAUTHN_CHALLENGE_INVALID", "通行密钥挑战状态异常", err)
|
|
}
|
|
if state.Type != challengeType {
|
|
return nil, apperror.BadRequest("AUTH_WEBAUTHN_CHALLENGE_INVALID", "通行密钥挑战类型不匹配", nil)
|
|
}
|
|
if state.ExpiresAt.Before(time.Now().UTC()) {
|
|
return nil, apperror.BadRequest("AUTH_WEBAUTHN_CHALLENGE_EXPIRED", "通行密钥挑战已过期", nil)
|
|
}
|
|
return &state, nil
|
|
}
|