mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-07-12 16:02:18 +08:00
feat: add complete MFA support
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.
This commit is contained in:
124
server/internal/service/user_service_test.go
Normal file
124
server/internal/service/user_service_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"backupx/server/internal/model"
|
||||
"backupx/server/internal/security"
|
||||
)
|
||||
|
||||
func TestUserServiceUpdatePasswordClearsTrustedDeviceState(t *testing.T) {
|
||||
hash, err := security.HashPassword("old-password")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword: %v", err)
|
||||
}
|
||||
repo := &fakeUserRepository{users: []*model.User{{
|
||||
ID: 1,
|
||||
Username: "admin",
|
||||
PasswordHash: hash,
|
||||
DisplayName: "Admin",
|
||||
Email: "admin@example.com",
|
||||
Role: model.UserRoleAdmin,
|
||||
TwoFactorEnabled: true,
|
||||
TrustedDevices: `[{"id":"device"}]`,
|
||||
OutOfBandOTPCiphertext: "pending",
|
||||
WebAuthnChallengeCiphertext: "challenge",
|
||||
}}}
|
||||
svc := NewUserService(repo)
|
||||
|
||||
if _, err := svc.Update(context.Background(), 1, UserUpsertInput{
|
||||
Username: "admin",
|
||||
Password: "new-password",
|
||||
DisplayName: "Admin",
|
||||
Email: "admin@example.com",
|
||||
Role: model.UserRoleAdmin,
|
||||
}); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
updated := repo.users[0]
|
||||
if security.ComparePassword(updated.PasswordHash, "new-password") != nil {
|
||||
t.Fatalf("expected password hash to be updated")
|
||||
}
|
||||
if updated.TrustedDevices != "" || updated.OutOfBandOTPCiphertext != "" || updated.WebAuthnChallengeCiphertext != "" {
|
||||
t.Fatalf("expected password update to clear trusted device state, got trusted=%q otp=%q challenge=%q", updated.TrustedDevices, updated.OutOfBandOTPCiphertext, updated.WebAuthnChallengeCiphertext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserServiceUpdateContactClearsUnavailableOTP(t *testing.T) {
|
||||
hash, err := security.HashPassword("password-123")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword: %v", err)
|
||||
}
|
||||
repo := &fakeUserRepository{users: []*model.User{{
|
||||
ID: 1,
|
||||
Username: "admin",
|
||||
PasswordHash: hash,
|
||||
DisplayName: "Admin",
|
||||
Email: "admin@example.com",
|
||||
Phone: "+15550000000",
|
||||
Role: model.UserRoleAdmin,
|
||||
EmailOTPEnabled: true,
|
||||
SMSOTPEnabled: true,
|
||||
TrustedDevices: `[{"id":"device"}]`,
|
||||
OutOfBandOTPCiphertext: "pending",
|
||||
}}}
|
||||
svc := NewUserService(repo)
|
||||
|
||||
summary, err := svc.Update(context.Background(), 1, UserUpsertInput{
|
||||
Username: "admin",
|
||||
DisplayName: "Admin",
|
||||
Role: model.UserRoleAdmin,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
updated := repo.users[0]
|
||||
if updated.EmailOTPEnabled || updated.SMSOTPEnabled || summary.MFAEnabled {
|
||||
t.Fatalf("expected unavailable OTP channels to be disabled")
|
||||
}
|
||||
if updated.TrustedDevices != "" || updated.OutOfBandOTPCiphertext != "" || updated.WebAuthnChallengeCiphertext != "" {
|
||||
t.Fatalf("expected last MFA removal to clear temporary state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserServiceUpdateContactChangeClearsPendingOTP(t *testing.T) {
|
||||
hash, err := security.HashPassword("password-123")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword: %v", err)
|
||||
}
|
||||
repo := &fakeUserRepository{users: []*model.User{{
|
||||
ID: 1,
|
||||
Username: "admin",
|
||||
PasswordHash: hash,
|
||||
DisplayName: "Admin",
|
||||
Email: "old@example.com",
|
||||
Role: model.UserRoleAdmin,
|
||||
EmailOTPEnabled: true,
|
||||
OutOfBandOTPCiphertext: "pending",
|
||||
}}}
|
||||
svc := NewUserService(repo)
|
||||
|
||||
summary, err := svc.Update(context.Background(), 1, UserUpsertInput{
|
||||
Username: "admin",
|
||||
DisplayName: "Admin",
|
||||
Email: "new@example.com",
|
||||
Role: model.UserRoleAdmin,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
updated := repo.users[0]
|
||||
if updated.Email != "new@example.com" || summary.Email != "new@example.com" {
|
||||
t.Fatalf("expected email to be updated")
|
||||
}
|
||||
if !updated.EmailOTPEnabled {
|
||||
t.Fatalf("expected email OTP to remain enabled")
|
||||
}
|
||||
if updated.OutOfBandOTPCiphertext != "" {
|
||||
t.Fatalf("expected contact change to clear pending OTP")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user