mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-05-28 06:49:39 +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.
222 lines
7.1 KiB
Go
222 lines
7.1 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"backupx/server/internal/apperror"
|
|
"backupx/server/internal/model"
|
|
"backupx/server/internal/security"
|
|
)
|
|
|
|
func (s *AuthService) ListTrustedDevices(ctx context.Context, subject string) ([]TrustedDeviceOutput, error) {
|
|
user, err := s.userBySubject(ctx, subject)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
devices, err := parseTrustedDevices(user.TrustedDevices)
|
|
if err != nil {
|
|
return nil, apperror.Internal("AUTH_TRUSTED_DEVICE_INVALID", "可信设备配置异常", err)
|
|
}
|
|
now := time.Now().UTC()
|
|
output := make([]TrustedDeviceOutput, 0, len(devices))
|
|
for _, device := range devices {
|
|
if device.ExpiresAt.Before(now) {
|
|
continue
|
|
}
|
|
output = append(output, toTrustedDeviceOutput(device))
|
|
}
|
|
return output, nil
|
|
}
|
|
|
|
type TrustedDeviceRevokeInput struct {
|
|
CurrentPassword string `json:"currentPassword" binding:"required,min=8,max=128"`
|
|
}
|
|
|
|
func (s *AuthService) RevokeTrustedDevice(ctx context.Context, subject string, id string, input TrustedDeviceRevokeInput) error {
|
|
user, err := s.userBySubject(ctx, subject)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := security.ComparePassword(user.PasswordHash, input.CurrentPassword); err != nil {
|
|
return apperror.BadRequest("AUTH_WRONG_PASSWORD", "当前密码不正确", err)
|
|
}
|
|
devices, err := parseTrustedDevices(user.TrustedDevices)
|
|
if err != nil {
|
|
return apperror.Internal("AUTH_TRUSTED_DEVICE_INVALID", "可信设备配置异常", err)
|
|
}
|
|
found := false
|
|
filtered := make([]TrustedDeviceRecord, 0, len(devices))
|
|
for _, device := range devices {
|
|
if device.ID == strings.TrimSpace(id) {
|
|
found = true
|
|
} else {
|
|
filtered = append(filtered, device)
|
|
}
|
|
}
|
|
if !found {
|
|
return apperror.New(404, "AUTH_TRUSTED_DEVICE_NOT_FOUND", "可信设备不存在", nil)
|
|
}
|
|
encoded, err := encodeTrustedDevices(filtered)
|
|
if err != nil {
|
|
return apperror.Internal("AUTH_TRUSTED_DEVICE_INVALID", "可信设备配置异常", err)
|
|
}
|
|
user.TrustedDevices = encoded
|
|
if err := s.users.Update(ctx, user); err != nil {
|
|
return apperror.Internal("AUTH_TRUSTED_DEVICE_REVOKE_FAILED", "无法移除可信设备", err)
|
|
}
|
|
if s.auditService != nil {
|
|
s.auditService.Record(AuditEntry{
|
|
UserID: user.ID, Username: user.Username,
|
|
Category: "auth", Action: "trusted_device_revoke",
|
|
TargetType: "trusted_device", TargetID: strings.TrimSpace(id),
|
|
Detail: "移除可信设备",
|
|
})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *AuthService) verifyTrustedDevice(ctx context.Context, user *model.User, token string, clientKey string) (bool, error) {
|
|
token = strings.TrimSpace(token)
|
|
if token == "" {
|
|
return false, nil
|
|
}
|
|
devices, err := parseTrustedDevices(user.TrustedDevices)
|
|
if err != nil {
|
|
return false, apperror.Internal("AUTH_TRUSTED_DEVICE_INVALID", "可信设备配置异常", err)
|
|
}
|
|
now := time.Now().UTC()
|
|
hash := trustedDeviceTokenHash(token)
|
|
changed := false
|
|
for i := range devices {
|
|
device := &devices[i]
|
|
if device.ExpiresAt.Before(now) {
|
|
changed = true
|
|
continue
|
|
}
|
|
if subtle.ConstantTimeCompare([]byte(device.TokenHash), []byte(hash)) != 1 {
|
|
continue
|
|
}
|
|
device.LastUsedAt = now
|
|
device.LastIP = clientKey
|
|
changed = true
|
|
encoded, err := encodeTrustedDevices(filterActiveTrustedDevices(devices, now))
|
|
if err != nil {
|
|
return false, apperror.Internal("AUTH_TRUSTED_DEVICE_INVALID", "可信设备配置异常", err)
|
|
}
|
|
user.TrustedDevices = encoded
|
|
if err := s.users.Update(ctx, user); err != nil {
|
|
return false, apperror.Internal("AUTH_TRUSTED_DEVICE_UPDATE_FAILED", "无法更新可信设备", err)
|
|
}
|
|
if s.auditService != nil {
|
|
s.auditService.Record(AuditEntry{
|
|
UserID: user.ID, Username: user.Username,
|
|
Category: "auth", Action: "trusted_device_used",
|
|
TargetType: "trusted_device", TargetID: device.ID, TargetName: device.Name,
|
|
Detail: "使用可信设备跳过多因素验证", ClientIP: clientKey,
|
|
})
|
|
}
|
|
return true, nil
|
|
}
|
|
if changed {
|
|
encoded, err := encodeTrustedDevices(filterActiveTrustedDevices(devices, now))
|
|
if err != nil {
|
|
return false, apperror.Internal("AUTH_TRUSTED_DEVICE_INVALID", "可信设备配置异常", err)
|
|
}
|
|
user.TrustedDevices = encoded
|
|
if err := s.users.Update(ctx, user); err != nil {
|
|
return false, apperror.Internal("AUTH_TRUSTED_DEVICE_UPDATE_FAILED", "无法更新可信设备", err)
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
func (s *AuthService) issueTrustedDevice(ctx context.Context, user *model.User, name string, clientKey string) (string, *TrustedDeviceOutput, error) {
|
|
token, err := randomURLToken(32)
|
|
if err != nil {
|
|
return "", nil, apperror.Internal("AUTH_TRUSTED_DEVICE_CREATE_FAILED", "无法生成可信设备令牌", err)
|
|
}
|
|
id, err := randomURLToken(16)
|
|
if err != nil {
|
|
return "", nil, apperror.Internal("AUTH_TRUSTED_DEVICE_CREATE_FAILED", "无法生成可信设备编号", err)
|
|
}
|
|
now := time.Now().UTC()
|
|
deviceName := normalizeTrustedDeviceName(name)
|
|
device := TrustedDeviceRecord{
|
|
ID: id,
|
|
Name: deviceName,
|
|
TokenHash: trustedDeviceTokenHash(token),
|
|
CreatedAt: now,
|
|
LastUsedAt: now,
|
|
ExpiresAt: now.Add(trustedDeviceTTL),
|
|
LastIP: clientKey,
|
|
}
|
|
devices, err := parseTrustedDevices(user.TrustedDevices)
|
|
if err != nil {
|
|
return "", nil, apperror.Internal("AUTH_TRUSTED_DEVICE_INVALID", "可信设备配置异常", err)
|
|
}
|
|
devices = append(filterActiveTrustedDevices(devices, now), device)
|
|
if len(devices) > maxTrustedDevices {
|
|
devices = devices[len(devices)-maxTrustedDevices:]
|
|
}
|
|
encoded, err := encodeTrustedDevices(devices)
|
|
if err != nil {
|
|
return "", nil, apperror.Internal("AUTH_TRUSTED_DEVICE_INVALID", "可信设备配置异常", err)
|
|
}
|
|
user.TrustedDevices = encoded
|
|
if err := s.users.Update(ctx, user); err != nil {
|
|
return "", nil, apperror.Internal("AUTH_TRUSTED_DEVICE_CREATE_FAILED", "无法保存可信设备", err)
|
|
}
|
|
output := toTrustedDeviceOutput(device)
|
|
if s.auditService != nil {
|
|
s.auditService.Record(AuditEntry{
|
|
UserID: user.ID, Username: user.Username,
|
|
Category: "auth", Action: "trusted_device_create",
|
|
TargetType: "trusted_device", TargetID: device.ID, TargetName: device.Name,
|
|
Detail: fmt.Sprintf("添加可信设备,有效期至 %s", device.ExpiresAt.Format(time.RFC3339)), ClientIP: clientKey,
|
|
})
|
|
}
|
|
return token, &output, nil
|
|
}
|
|
|
|
func filterActiveTrustedDevices(devices []TrustedDeviceRecord, now time.Time) []TrustedDeviceRecord {
|
|
active := make([]TrustedDeviceRecord, 0, len(devices))
|
|
for _, device := range devices {
|
|
if device.ExpiresAt.After(now) {
|
|
active = append(active, device)
|
|
}
|
|
}
|
|
return active
|
|
}
|
|
|
|
func trustedDeviceTokenHash(token string) string {
|
|
sum := sha256.Sum256([]byte(strings.TrimSpace(token)))
|
|
return base64.RawURLEncoding.EncodeToString(sum[:])
|
|
}
|
|
|
|
func randomURLToken(size int) (string, error) {
|
|
buf := make([]byte, size)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", err
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(buf), nil
|
|
}
|
|
|
|
func normalizeTrustedDeviceName(name string) string {
|
|
trimmed := strings.TrimSpace(name)
|
|
if trimmed == "" {
|
|
return "当前设备"
|
|
}
|
|
if len([]rune(trimmed)) <= maxTrustedDeviceName {
|
|
return trimmed
|
|
}
|
|
runes := []rune(trimmed)
|
|
return string(runes[:maxTrustedDeviceName])
|
|
}
|