mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-05-11 18:10:23 +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.
180 lines
4.3 KiB
Go
180 lines
4.3 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"time"
|
|
|
|
"backupx/server/internal/model"
|
|
)
|
|
|
|
const (
|
|
mfaChallengeTTL = 5 * time.Minute
|
|
trustedDeviceTTL = 30 * 24 * time.Hour
|
|
maxTrustedDeviceName = 128
|
|
maxTrustedDevices = 10
|
|
)
|
|
|
|
type WebAuthnCredentialRecord struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
CredentialID string `json:"credentialId"`
|
|
PublicKeyX string `json:"publicKeyX"`
|
|
PublicKeyY string `json:"publicKeyY"`
|
|
SignCount uint32 `json:"signCount"`
|
|
CreatedAt string `json:"createdAt"`
|
|
LastUsedAt string `json:"lastUsedAt,omitempty"`
|
|
}
|
|
|
|
type WebAuthnCredentialOutput struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
CreatedAt string `json:"createdAt"`
|
|
LastUsedAt string `json:"lastUsedAt,omitempty"`
|
|
}
|
|
|
|
type webAuthnChallengeState struct {
|
|
Type string `json:"type"`
|
|
Challenge string `json:"challenge"`
|
|
RPID string `json:"rpId"`
|
|
Origin string `json:"origin"`
|
|
ExpiresAt time.Time `json:"expiresAt"`
|
|
}
|
|
|
|
type TrustedDeviceRecord struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
TokenHash string `json:"tokenHash"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
LastUsedAt time.Time `json:"lastUsedAt"`
|
|
ExpiresAt time.Time `json:"expiresAt"`
|
|
LastIP string `json:"lastIp"`
|
|
}
|
|
|
|
type TrustedDeviceOutput struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
CreatedAt string `json:"createdAt"`
|
|
LastUsedAt string `json:"lastUsedAt"`
|
|
ExpiresAt string `json:"expiresAt"`
|
|
LastIP string `json:"lastIp"`
|
|
}
|
|
|
|
type pendingOutOfBandOTP struct {
|
|
Channel string `json:"channel"`
|
|
CodeHash string `json:"codeHash"`
|
|
ExpiresAt time.Time `json:"expiresAt"`
|
|
}
|
|
|
|
func userMFAEnabled(user *model.User) bool {
|
|
if user == nil {
|
|
return false
|
|
}
|
|
return user.TwoFactorEnabled ||
|
|
strings.TrimSpace(user.WebAuthnCredentials) != "" ||
|
|
user.EmailOTPEnabled ||
|
|
user.SMSOTPEnabled
|
|
}
|
|
|
|
func clearTrustedDevicesIfMFAOff(user *model.User) {
|
|
if user == nil || userMFAEnabled(user) {
|
|
return
|
|
}
|
|
user.TrustedDevices = ""
|
|
user.OutOfBandOTPCiphertext = ""
|
|
user.WebAuthnChallengeCiphertext = ""
|
|
}
|
|
|
|
func parseWebAuthnCredentials(value string) ([]WebAuthnCredentialRecord, error) {
|
|
if strings.TrimSpace(value) == "" {
|
|
return nil, nil
|
|
}
|
|
var credentials []WebAuthnCredentialRecord
|
|
if err := json.Unmarshal([]byte(value), &credentials); err != nil {
|
|
return nil, err
|
|
}
|
|
return credentials, nil
|
|
}
|
|
|
|
func encodeWebAuthnCredentials(credentials []WebAuthnCredentialRecord) (string, error) {
|
|
if len(credentials) == 0 {
|
|
return "", nil
|
|
}
|
|
encoded, err := json.Marshal(credentials)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(encoded), nil
|
|
}
|
|
|
|
func webAuthnCredentialCount(user *model.User) int {
|
|
if user == nil {
|
|
return 0
|
|
}
|
|
credentials, err := parseWebAuthnCredentials(user.WebAuthnCredentials)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return len(credentials)
|
|
}
|
|
|
|
func parseTrustedDevices(value string) ([]TrustedDeviceRecord, error) {
|
|
if strings.TrimSpace(value) == "" {
|
|
return nil, nil
|
|
}
|
|
var devices []TrustedDeviceRecord
|
|
if err := json.Unmarshal([]byte(value), &devices); err != nil {
|
|
return nil, err
|
|
}
|
|
return devices, nil
|
|
}
|
|
|
|
func encodeTrustedDevices(devices []TrustedDeviceRecord) (string, error) {
|
|
if len(devices) == 0 {
|
|
return "", nil
|
|
}
|
|
encoded, err := json.Marshal(devices)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(encoded), nil
|
|
}
|
|
|
|
func trustedDeviceCount(user *model.User) int {
|
|
if user == nil {
|
|
return 0
|
|
}
|
|
devices, err := parseTrustedDevices(user.TrustedDevices)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
now := time.Now().UTC()
|
|
count := 0
|
|
for _, device := range devices {
|
|
if device.ExpiresAt.After(now) {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
func toWebAuthnCredentialOutput(record WebAuthnCredentialRecord) WebAuthnCredentialOutput {
|
|
return WebAuthnCredentialOutput{
|
|
ID: record.ID,
|
|
Name: record.Name,
|
|
CreatedAt: record.CreatedAt,
|
|
LastUsedAt: record.LastUsedAt,
|
|
}
|
|
}
|
|
|
|
func toTrustedDeviceOutput(record TrustedDeviceRecord) TrustedDeviceOutput {
|
|
return TrustedDeviceOutput{
|
|
ID: record.ID,
|
|
Name: record.Name,
|
|
CreatedAt: record.CreatedAt.Format(time.RFC3339),
|
|
LastUsedAt: record.LastUsedAt.Format(time.RFC3339),
|
|
ExpiresAt: record.ExpiresAt.Format(time.RFC3339),
|
|
LastIP: record.LastIP,
|
|
}
|
|
}
|