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.
618 lines
18 KiB
Go
618 lines
18 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"backupx/server/internal/apperror"
|
|
"backupx/server/internal/model"
|
|
"backupx/server/internal/security"
|
|
"backupx/server/internal/storage/codec"
|
|
"github.com/pquerna/otp/totp"
|
|
)
|
|
|
|
type fakeUserRepository struct {
|
|
users []*model.User
|
|
}
|
|
|
|
func (r *fakeUserRepository) Count(context.Context) (int64, error) {
|
|
return int64(len(r.users)), nil
|
|
}
|
|
|
|
func (r *fakeUserRepository) Create(_ context.Context, user *model.User) error {
|
|
user.ID = uint(len(r.users) + 1)
|
|
r.users = append(r.users, user)
|
|
return nil
|
|
}
|
|
|
|
func (r *fakeUserRepository) FindByUsername(_ context.Context, username string) (*model.User, error) {
|
|
for _, user := range r.users {
|
|
if user.Username == username {
|
|
return user, nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (r *fakeUserRepository) FindByID(_ context.Context, id uint) (*model.User, error) {
|
|
for _, user := range r.users {
|
|
if user.ID == id {
|
|
return user, nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (r *fakeUserRepository) Update(_ context.Context, user *model.User) error {
|
|
for i, u := range r.users {
|
|
if u.ID == user.ID {
|
|
r.users[i] = user
|
|
return nil
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *fakeUserRepository) CountByRole(_ context.Context, role string) (int64, error) {
|
|
var n int64
|
|
for _, u := range r.users {
|
|
if u.Role == role && !u.Disabled {
|
|
n++
|
|
}
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
func (r *fakeUserRepository) List(_ context.Context) ([]model.User, error) {
|
|
result := make([]model.User, 0, len(r.users))
|
|
for _, u := range r.users {
|
|
result = append(result, *u)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (r *fakeUserRepository) Delete(_ context.Context, id uint) error {
|
|
for i, u := range r.users {
|
|
if u.ID == id {
|
|
r.users = append(r.users[:i], r.users[i+1:]...)
|
|
return nil
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type fakeSystemConfigRepository struct{}
|
|
|
|
func (r *fakeSystemConfigRepository) GetByKey(context.Context, string) (*model.SystemConfig, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (r *fakeSystemConfigRepository) List(context.Context) ([]model.SystemConfig, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (r *fakeSystemConfigRepository) Upsert(context.Context, *model.SystemConfig) error {
|
|
return nil
|
|
}
|
|
|
|
func TestAuthServiceSetupAndLogin(t *testing.T) {
|
|
users := &fakeUserRepository{}
|
|
service := NewAuthService(
|
|
users,
|
|
&fakeSystemConfigRepository{},
|
|
security.NewJWTManager("test-secret", time.Hour),
|
|
security.NewLoginRateLimiter(5, time.Minute),
|
|
codec.NewConfigCipher("test-encryption-secret"),
|
|
)
|
|
|
|
setupResult, err := service.Setup(context.Background(), SetupInput{
|
|
Username: "admin",
|
|
Password: "password-123",
|
|
DisplayName: "Admin",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Setup returned error: %v", err)
|
|
}
|
|
if setupResult.User.Username != "admin" {
|
|
t.Fatalf("expected username admin, got %s", setupResult.User.Username)
|
|
}
|
|
|
|
loginResult, err := service.Login(context.Background(), LoginInput{
|
|
Username: "admin",
|
|
Password: "password-123",
|
|
}, "127.0.0.1")
|
|
if err != nil {
|
|
t.Fatalf("Login returned error: %v", err)
|
|
}
|
|
if loginResult.Token == "" {
|
|
t.Fatalf("expected non-empty token")
|
|
}
|
|
}
|
|
|
|
func newTestAuthService() (*AuthService, *fakeUserRepository) {
|
|
users := &fakeUserRepository{}
|
|
svc := NewAuthService(
|
|
users,
|
|
&fakeSystemConfigRepository{},
|
|
security.NewJWTManager("test-secret", time.Hour),
|
|
security.NewLoginRateLimiter(5, time.Minute),
|
|
codec.NewConfigCipher("test-encryption-secret"),
|
|
)
|
|
return svc, users
|
|
}
|
|
|
|
func TestChangePassword(t *testing.T) {
|
|
svc, _ := newTestAuthService()
|
|
_, err := svc.Setup(context.Background(), SetupInput{
|
|
Username: "admin", Password: "password-123", DisplayName: "Admin",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Setup: %v", err)
|
|
}
|
|
|
|
err = svc.ChangePassword(context.Background(), "1", ChangePasswordInput{
|
|
OldPassword: "password-123",
|
|
NewPassword: "new-password-456",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ChangePassword: %v", err)
|
|
}
|
|
|
|
// Old password should no longer work
|
|
_, err = svc.Login(context.Background(), LoginInput{
|
|
Username: "admin", Password: "password-123",
|
|
}, "127.0.0.1")
|
|
if err == nil {
|
|
t.Fatalf("expected login with old password to fail")
|
|
}
|
|
|
|
// New password should work
|
|
_, err = svc.Login(context.Background(), LoginInput{
|
|
Username: "admin", Password: "new-password-456",
|
|
}, "127.0.0.1")
|
|
if err != nil {
|
|
t.Fatalf("login with new password: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestChangePasswordWrongOld(t *testing.T) {
|
|
svc, _ := newTestAuthService()
|
|
_, err := svc.Setup(context.Background(), SetupInput{
|
|
Username: "admin", Password: "password-123", DisplayName: "Admin",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Setup: %v", err)
|
|
}
|
|
|
|
err = svc.ChangePassword(context.Background(), "1", ChangePasswordInput{
|
|
OldPassword: "wrong-password",
|
|
NewPassword: "new-password-456",
|
|
})
|
|
if err == nil {
|
|
t.Fatalf("expected ChangePassword with wrong old password to fail")
|
|
}
|
|
}
|
|
|
|
func TestAuthServiceLoginRequiresTwoFactorWhenEnabled(t *testing.T) {
|
|
svc, _ := newTestAuthService()
|
|
_, err := svc.Setup(context.Background(), SetupInput{
|
|
Username: "admin", Password: "password-123", DisplayName: "Admin",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Setup: %v", err)
|
|
}
|
|
|
|
setup, err := svc.PrepareTwoFactor(context.Background(), "1", TwoFactorSetupInput{
|
|
CurrentPassword: "password-123",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("PrepareTwoFactor: %v", err)
|
|
}
|
|
if setup.Secret == "" || setup.QRCodeDataURL == "" || setup.OTPAuthURL == "" {
|
|
t.Fatalf("expected populated 2FA enrollment, got %#v", setup)
|
|
}
|
|
|
|
code, err := totp.GenerateCode(setup.Secret, time.Now().UTC())
|
|
if err != nil {
|
|
t.Fatalf("GenerateCode: %v", err)
|
|
}
|
|
enabledUser, err := svc.EnableTwoFactor(context.Background(), "1", EnableTwoFactorInput{Code: code})
|
|
if err != nil {
|
|
t.Fatalf("EnableTwoFactor: %v", err)
|
|
}
|
|
if !enabledUser.User.TwoFactorEnabled {
|
|
t.Fatalf("expected 2FA enabled")
|
|
}
|
|
if len(enabledUser.RecoveryCodes) != security.RecoveryCodeCount {
|
|
t.Fatalf("expected %d recovery codes, got %d", security.RecoveryCodeCount, len(enabledUser.RecoveryCodes))
|
|
}
|
|
|
|
_, err = svc.Login(context.Background(), LoginInput{
|
|
Username: "admin", Password: "password-123",
|
|
}, "127.0.0.1")
|
|
if appErr, ok := err.(*apperror.AppError); !ok || appErr.Code != "AUTH_2FA_REQUIRED" {
|
|
t.Fatalf("expected AUTH_2FA_REQUIRED, got %v", err)
|
|
}
|
|
|
|
loginCode, err := totp.GenerateCode(setup.Secret, time.Now().UTC())
|
|
if err != nil {
|
|
t.Fatalf("GenerateCode login: %v", err)
|
|
}
|
|
loginResult, err := svc.Login(context.Background(), LoginInput{
|
|
Username: "admin", Password: "password-123", TwoFactorCode: loginCode,
|
|
}, "127.0.0.1")
|
|
if err != nil {
|
|
t.Fatalf("Login with 2FA: %v", err)
|
|
}
|
|
if loginResult.Token == "" {
|
|
t.Fatalf("expected non-empty token")
|
|
}
|
|
}
|
|
|
|
func TestAuthServiceDisableTwoFactor(t *testing.T) {
|
|
svc, _ := newTestAuthService()
|
|
_, err := svc.Setup(context.Background(), SetupInput{
|
|
Username: "admin", Password: "password-123", DisplayName: "Admin",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Setup: %v", err)
|
|
}
|
|
setup, err := svc.PrepareTwoFactor(context.Background(), "1", TwoFactorSetupInput{
|
|
CurrentPassword: "password-123",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("PrepareTwoFactor: %v", err)
|
|
}
|
|
code, err := totp.GenerateCode(setup.Secret, time.Now().UTC())
|
|
if err != nil {
|
|
t.Fatalf("GenerateCode: %v", err)
|
|
}
|
|
if _, err := svc.EnableTwoFactor(context.Background(), "1", EnableTwoFactorInput{Code: code}); err != nil {
|
|
t.Fatalf("EnableTwoFactor: %v", err)
|
|
}
|
|
|
|
disableCode, err := totp.GenerateCode(setup.Secret, time.Now().UTC())
|
|
if err != nil {
|
|
t.Fatalf("GenerateCode disable: %v", err)
|
|
}
|
|
user, err := svc.DisableTwoFactor(context.Background(), "1", DisableTwoFactorInput{
|
|
CurrentPassword: "password-123",
|
|
Code: disableCode,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("DisableTwoFactor: %v", err)
|
|
}
|
|
if user.TwoFactorEnabled {
|
|
t.Fatalf("expected 2FA disabled")
|
|
}
|
|
|
|
loginResult, err := svc.Login(context.Background(), LoginInput{
|
|
Username: "admin", Password: "password-123",
|
|
}, "127.0.0.1")
|
|
if err != nil {
|
|
t.Fatalf("Login after disable: %v", err)
|
|
}
|
|
if loginResult.Token == "" {
|
|
t.Fatalf("expected non-empty token")
|
|
}
|
|
}
|
|
|
|
func TestAuthServiceRecoveryCodeLoginConsumesCode(t *testing.T) {
|
|
svc, _ := newTestAuthService()
|
|
_, err := svc.Setup(context.Background(), SetupInput{
|
|
Username: "admin", Password: "password-123", DisplayName: "Admin",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Setup: %v", err)
|
|
}
|
|
setup, err := svc.PrepareTwoFactor(context.Background(), "1", TwoFactorSetupInput{
|
|
CurrentPassword: "password-123",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("PrepareTwoFactor: %v", err)
|
|
}
|
|
code, err := totp.GenerateCode(setup.Secret, time.Now().UTC())
|
|
if err != nil {
|
|
t.Fatalf("GenerateCode: %v", err)
|
|
}
|
|
enabled, err := svc.EnableTwoFactor(context.Background(), "1", EnableTwoFactorInput{Code: code})
|
|
if err != nil {
|
|
t.Fatalf("EnableTwoFactor: %v", err)
|
|
}
|
|
recoveryCode := enabled.RecoveryCodes[0]
|
|
|
|
loginResult, err := svc.Login(context.Background(), LoginInput{
|
|
Username: "admin", Password: "password-123", TwoFactorCode: recoveryCode,
|
|
}, "127.0.0.1")
|
|
if err != nil {
|
|
t.Fatalf("Login with recovery code: %v", err)
|
|
}
|
|
if loginResult.User.TwoFactorRecoveryCodesRemaining != security.RecoveryCodeCount-1 {
|
|
t.Fatalf("expected one recovery code consumed, got remaining=%d", loginResult.User.TwoFactorRecoveryCodesRemaining)
|
|
}
|
|
|
|
_, err = svc.Login(context.Background(), LoginInput{
|
|
Username: "admin", Password: "password-123", TwoFactorCode: recoveryCode,
|
|
}, "127.0.0.1")
|
|
if appErr, ok := err.(*apperror.AppError); !ok || appErr.Code != "AUTH_2FA_INVALID" {
|
|
t.Fatalf("expected consumed recovery code to fail, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAuthServiceRegenerateRecoveryCodesInvalidatesOldCodes(t *testing.T) {
|
|
svc, _ := newTestAuthService()
|
|
_, err := svc.Setup(context.Background(), SetupInput{
|
|
Username: "admin", Password: "password-123", DisplayName: "Admin",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Setup: %v", err)
|
|
}
|
|
setup, err := svc.PrepareTwoFactor(context.Background(), "1", TwoFactorSetupInput{
|
|
CurrentPassword: "password-123",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("PrepareTwoFactor: %v", err)
|
|
}
|
|
code, err := totp.GenerateCode(setup.Secret, time.Now().UTC())
|
|
if err != nil {
|
|
t.Fatalf("GenerateCode: %v", err)
|
|
}
|
|
enabled, err := svc.EnableTwoFactor(context.Background(), "1", EnableTwoFactorInput{Code: code})
|
|
if err != nil {
|
|
t.Fatalf("EnableTwoFactor: %v", err)
|
|
}
|
|
oldRecoveryCode := enabled.RecoveryCodes[0]
|
|
|
|
regenerateCode, err := totp.GenerateCode(setup.Secret, time.Now().UTC())
|
|
if err != nil {
|
|
t.Fatalf("GenerateCode regenerate: %v", err)
|
|
}
|
|
regenerated, err := svc.RegenerateRecoveryCodes(context.Background(), "1", RegenerateRecoveryCodesInput{
|
|
CurrentPassword: "password-123",
|
|
Code: regenerateCode,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RegenerateRecoveryCodes: %v", err)
|
|
}
|
|
if len(regenerated.RecoveryCodes) != security.RecoveryCodeCount {
|
|
t.Fatalf("expected %d recovery codes, got %d", security.RecoveryCodeCount, len(regenerated.RecoveryCodes))
|
|
}
|
|
|
|
_, err = svc.Login(context.Background(), LoginInput{
|
|
Username: "admin", Password: "password-123", TwoFactorCode: oldRecoveryCode,
|
|
}, "127.0.0.1")
|
|
if appErr, ok := err.(*apperror.AppError); !ok || appErr.Code != "AUTH_2FA_INVALID" {
|
|
t.Fatalf("expected old recovery code to fail, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAuthServiceTrustedDeviceSkipsMFA(t *testing.T) {
|
|
svc, repo := newTestAuthService()
|
|
_, err := svc.Setup(context.Background(), SetupInput{
|
|
Username: "admin", Password: "password-123", DisplayName: "Admin",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Setup: %v", err)
|
|
}
|
|
setup, err := svc.PrepareTwoFactor(context.Background(), "1", TwoFactorSetupInput{
|
|
CurrentPassword: "password-123",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("PrepareTwoFactor: %v", err)
|
|
}
|
|
code, err := totp.GenerateCode(setup.Secret, time.Now().UTC())
|
|
if err != nil {
|
|
t.Fatalf("GenerateCode: %v", err)
|
|
}
|
|
if _, err := svc.EnableTwoFactor(context.Background(), "1", EnableTwoFactorInput{Code: code}); err != nil {
|
|
t.Fatalf("EnableTwoFactor: %v", err)
|
|
}
|
|
loginCode, err := totp.GenerateCode(setup.Secret, time.Now().UTC())
|
|
if err != nil {
|
|
t.Fatalf("GenerateCode login: %v", err)
|
|
}
|
|
firstLogin, err := svc.Login(context.Background(), LoginInput{
|
|
Username: "admin", Password: "password-123", TwoFactorCode: loginCode,
|
|
RememberDevice: true, TrustedDeviceName: "test browser",
|
|
}, "127.0.0.1")
|
|
if err != nil {
|
|
t.Fatalf("Login with 2FA: %v", err)
|
|
}
|
|
if firstLogin.TrustedDeviceToken == "" || firstLogin.TrustedDevice == nil {
|
|
t.Fatalf("expected trusted device token")
|
|
}
|
|
secondLogin, err := svc.Login(context.Background(), LoginInput{
|
|
Username: "admin", Password: "password-123", TrustedDeviceToken: firstLogin.TrustedDeviceToken,
|
|
}, "127.0.0.1")
|
|
if err != nil {
|
|
t.Fatalf("Login with trusted device: %v", err)
|
|
}
|
|
if secondLogin.Token == "" {
|
|
t.Fatalf("expected token")
|
|
}
|
|
disableCode, err := totp.GenerateCode(setup.Secret, time.Now().UTC())
|
|
if err != nil {
|
|
t.Fatalf("GenerateCode disable: %v", err)
|
|
}
|
|
if _, err := svc.DisableTwoFactor(context.Background(), "1", DisableTwoFactorInput{
|
|
CurrentPassword: "password-123",
|
|
Code: disableCode,
|
|
}); err != nil {
|
|
t.Fatalf("DisableTwoFactor: %v", err)
|
|
}
|
|
if repo.users[0].TrustedDevices != "" {
|
|
t.Fatalf("expected trusted devices cleared after disabling last MFA method")
|
|
}
|
|
}
|
|
|
|
func TestAuthServiceOutOfBandOTPLoginConsumesCode(t *testing.T) {
|
|
svc, repo := newTestAuthService()
|
|
_, err := svc.Setup(context.Background(), SetupInput{
|
|
Username: "admin", Password: "password-123", DisplayName: "Admin",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Setup: %v", err)
|
|
}
|
|
user := repo.users[0]
|
|
user.Email = "admin@example.com"
|
|
user.EmailOTPEnabled = true
|
|
hash, err := security.HashPassword("123456")
|
|
if err != nil {
|
|
t.Fatalf("HashPassword: %v", err)
|
|
}
|
|
ciphertext, err := svc.twoFactorCipher.EncryptJSON(pendingOutOfBandOTP{
|
|
Channel: "email", CodeHash: hash, ExpiresAt: time.Now().UTC().Add(time.Minute),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("EncryptJSON: %v", err)
|
|
}
|
|
user.OutOfBandOTPCiphertext = ciphertext
|
|
if err := repo.Update(context.Background(), user); err != nil {
|
|
t.Fatalf("Update: %v", err)
|
|
}
|
|
|
|
loginResult, err := svc.Login(context.Background(), LoginInput{
|
|
Username: "admin", Password: "password-123", TwoFactorCode: "123456",
|
|
}, "127.0.0.1")
|
|
if err != nil {
|
|
t.Fatalf("Login with email OTP: %v", err)
|
|
}
|
|
if loginResult.Token == "" {
|
|
t.Fatalf("expected token")
|
|
}
|
|
if repo.users[0].OutOfBandOTPCiphertext != "" {
|
|
t.Fatalf("expected OTP to be consumed")
|
|
}
|
|
|
|
_, err = svc.Login(context.Background(), LoginInput{
|
|
Username: "admin", Password: "password-123", TwoFactorCode: "123456",
|
|
}, "127.0.0.1")
|
|
if appErr, ok := err.(*apperror.AppError); !ok || appErr.Code != "AUTH_2FA_INVALID" {
|
|
t.Fatalf("expected consumed OTP to fail, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAuthServiceMFAStartIsRateLimited(t *testing.T) {
|
|
svc, repo := newTestAuthService()
|
|
_, err := svc.Setup(context.Background(), SetupInput{
|
|
Username: "admin", Password: "password-123", DisplayName: "Admin",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Setup: %v", err)
|
|
}
|
|
repo.users[0].Email = "admin@example.com"
|
|
repo.users[0].EmailOTPEnabled = true
|
|
|
|
for i := 0; i < 5; i++ {
|
|
_ = svc.SendLoginOTP(context.Background(), LoginOTPInput{
|
|
Username: "admin", Password: "wrong-password", Channel: "email",
|
|
}, "127.0.0.1")
|
|
}
|
|
err = svc.SendLoginOTP(context.Background(), LoginOTPInput{
|
|
Username: "admin", Password: "wrong-password", Channel: "email",
|
|
}, "127.0.0.1")
|
|
if appErr, ok := err.(*apperror.AppError); !ok || appErr.Code != "AUTH_RATE_LIMITED" {
|
|
t.Fatalf("expected AUTH_RATE_LIMITED, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAuthServiceDisabledOTPChannelCannotConsumePendingCode(t *testing.T) {
|
|
svc, repo := newTestAuthService()
|
|
_, err := svc.Setup(context.Background(), SetupInput{
|
|
Username: "admin", Password: "password-123", DisplayName: "Admin",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Setup: %v", err)
|
|
}
|
|
user := repo.users[0]
|
|
user.Email = "admin@example.com"
|
|
user.EmailOTPEnabled = false
|
|
user.SMSOTPEnabled = true
|
|
hash, err := security.HashPassword("123456")
|
|
if err != nil {
|
|
t.Fatalf("HashPassword: %v", err)
|
|
}
|
|
ciphertext, err := svc.twoFactorCipher.EncryptJSON(pendingOutOfBandOTP{
|
|
Channel: "email", CodeHash: hash, ExpiresAt: time.Now().UTC().Add(time.Minute),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("EncryptJSON: %v", err)
|
|
}
|
|
user.OutOfBandOTPCiphertext = ciphertext
|
|
if err := repo.Update(context.Background(), user); err != nil {
|
|
t.Fatalf("Update: %v", err)
|
|
}
|
|
|
|
_, err = svc.Login(context.Background(), LoginInput{
|
|
Username: "admin", Password: "password-123", TwoFactorCode: "123456",
|
|
}, "127.0.0.1")
|
|
if appErr, ok := err.(*apperror.AppError); !ok || appErr.Code != "AUTH_2FA_INVALID" {
|
|
t.Fatalf("expected disabled OTP channel to fail, got %v", err)
|
|
}
|
|
if repo.users[0].OutOfBandOTPCiphertext != "" {
|
|
t.Fatalf("expected disabled channel OTP to be cleared")
|
|
}
|
|
}
|
|
|
|
func TestAuthServiceChangingOTPRecipientClearsPendingCode(t *testing.T) {
|
|
svc, repo := newTestAuthService()
|
|
_, err := svc.Setup(context.Background(), SetupInput{
|
|
Username: "admin", Password: "password-123", DisplayName: "Admin",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Setup: %v", err)
|
|
}
|
|
user := repo.users[0]
|
|
user.Email = "old@example.com"
|
|
user.EmailOTPEnabled = true
|
|
hash, err := security.HashPassword("123456")
|
|
if err != nil {
|
|
t.Fatalf("HashPassword: %v", err)
|
|
}
|
|
ciphertext, err := svc.twoFactorCipher.EncryptJSON(pendingOutOfBandOTP{
|
|
Channel: "email", CodeHash: hash, ExpiresAt: time.Now().UTC().Add(time.Minute),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("EncryptJSON: %v", err)
|
|
}
|
|
user.OutOfBandOTPCiphertext = ciphertext
|
|
if err := repo.Update(context.Background(), user); err != nil {
|
|
t.Fatalf("Update: %v", err)
|
|
}
|
|
|
|
updated, err := svc.ConfigureOutOfBandOTP(context.Background(), "1", OTPConfigInput{
|
|
CurrentPassword: "password-123",
|
|
Channel: "email",
|
|
Enabled: true,
|
|
Email: "new@example.com",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ConfigureOutOfBandOTP: %v", err)
|
|
}
|
|
if updated.Email != "new@example.com" {
|
|
t.Fatalf("expected email updated, got %q", updated.Email)
|
|
}
|
|
if repo.users[0].OutOfBandOTPCiphertext != "" {
|
|
t.Fatalf("expected pending email OTP to be cleared after recipient change")
|
|
}
|
|
}
|
|
|
|
func TestAuthServiceCorruptWebAuthnCredentialsStillRequireMFA(t *testing.T) {
|
|
svc, repo := newTestAuthService()
|
|
_, err := svc.Setup(context.Background(), SetupInput{
|
|
Username: "admin", Password: "password-123", DisplayName: "Admin",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Setup: %v", err)
|
|
}
|
|
repo.users[0].WebAuthnCredentials = "{invalid-json"
|
|
|
|
_, err = svc.Login(context.Background(), LoginInput{
|
|
Username: "admin", Password: "password-123",
|
|
}, "127.0.0.1")
|
|
if appErr, ok := err.(*apperror.AppError); !ok || appErr.Code != "AUTH_2FA_REQUIRED" {
|
|
t.Fatalf("expected corrupt WebAuthn credentials to require MFA, got %v", err)
|
|
}
|
|
}
|