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.
125 lines
3.9 KiB
Go
125 lines
3.9 KiB
Go
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")
|
|
}
|
|
}
|