mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-09-04 23:16:41 +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:
@@ -16,50 +16,17 @@ import (
|
||||
"backupx/server/internal/repository"
|
||||
"backupx/server/internal/security"
|
||||
"backupx/server/internal/service"
|
||||
"backupx/server/internal/storage/codec"
|
||||
|
||||
"github.com/pquerna/otp/totp"
|
||||
)
|
||||
|
||||
func TestSetupLoginAndProfileFlow(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
cfg := config.Config{
|
||||
Server: config.ServerConfig{Host: "127.0.0.1", Port: 8340, Mode: "test"},
|
||||
Database: config.DatabaseConfig{Path: filepath.Join(tempDir, "backupx.db")},
|
||||
Security: config.SecurityConfig{JWTExpire: "24h"},
|
||||
Log: config.LogConfig{Level: "error"},
|
||||
}
|
||||
|
||||
log, err := logger.New(cfg.Log)
|
||||
if err != nil {
|
||||
t.Fatalf("logger.New error: %v", err)
|
||||
}
|
||||
db, err := database.Open(cfg.Database, log)
|
||||
if err != nil {
|
||||
t.Fatalf("database.Open error: %v", err)
|
||||
}
|
||||
|
||||
userRepo := repository.NewUserRepository(db)
|
||||
systemConfigRepo := repository.NewSystemConfigRepository(db)
|
||||
resolved, err := service.ResolveSecurity(context.Background(), cfg.Security, systemConfigRepo)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveSecurity error: %v", err)
|
||||
}
|
||||
jwtManager := security.NewJWTManager(resolved.JWTSecret, time.Hour)
|
||||
authService := service.NewAuthService(userRepo, systemConfigRepo, jwtManager, security.NewLoginRateLimiter(5, time.Minute))
|
||||
systemService := service.NewSystemService(cfg, "test", time.Now().UTC())
|
||||
|
||||
router := NewRouter(RouterDependencies{
|
||||
Config: cfg,
|
||||
Version: "test",
|
||||
Logger: log,
|
||||
AuthService: authService,
|
||||
SystemService: systemService,
|
||||
JWTManager: jwtManager,
|
||||
UserRepository: userRepo,
|
||||
SystemConfigRepo: systemConfigRepo,
|
||||
})
|
||||
router, _ := newTestHTTPRouter(t)
|
||||
|
||||
setupBody, _ := json.Marshal(map[string]string{
|
||||
"username": "admin",
|
||||
"password": "password-123",
|
||||
"username": "admin",
|
||||
"password": "password-123",
|
||||
"displayName": "Admin",
|
||||
})
|
||||
setupRequest := httptest.NewRequest(http.MethodPost, "/api/auth/setup", bytes.NewBuffer(setupBody))
|
||||
@@ -92,3 +59,143 @@ func TestSetupLoginAndProfileFlow(t *testing.T) {
|
||||
t.Fatalf("expected profile 200, got %d", profileRecorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustedDeviceCookieSkipsMFA(t *testing.T) {
|
||||
router, authService := newTestHTTPRouter(t)
|
||||
if _, err := authService.Setup(context.Background(), service.SetupInput{
|
||||
Username: "admin", Password: "password-123", DisplayName: "Admin",
|
||||
}); err != nil {
|
||||
t.Fatalf("Setup error: %v", err)
|
||||
}
|
||||
totpSetup, err := authService.PrepareTwoFactor(context.Background(), "1", service.TwoFactorSetupInput{
|
||||
CurrentPassword: "password-123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareTwoFactor error: %v", err)
|
||||
}
|
||||
enableCode, err := totp.GenerateCode(totpSetup.Secret, time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCode error: %v", err)
|
||||
}
|
||||
if _, err := authService.EnableTwoFactor(context.Background(), "1", service.EnableTwoFactorInput{Code: enableCode}); err != nil {
|
||||
t.Fatalf("EnableTwoFactor error: %v", err)
|
||||
}
|
||||
|
||||
loginCode, err := totp.GenerateCode(totpSetup.Secret, time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCode login error: %v", err)
|
||||
}
|
||||
loginBody, _ := json.Marshal(map[string]any{
|
||||
"username": "admin",
|
||||
"password": "password-123",
|
||||
"twoFactorCode": loginCode,
|
||||
"rememberDevice": true,
|
||||
"trustedDeviceName": "test browser",
|
||||
})
|
||||
loginRequest := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewBuffer(loginBody))
|
||||
loginRequest.Header.Set("Content-Type", "application/json")
|
||||
loginRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(loginRecorder, loginRequest)
|
||||
|
||||
if loginRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected login 200, got %d: %s", loginRecorder.Code, loginRecorder.Body.String())
|
||||
}
|
||||
trustedCookie := findCookie(loginRecorder.Result().Cookies(), trustedDeviceCookieName)
|
||||
if trustedCookie == nil {
|
||||
t.Fatalf("expected trusted device cookie")
|
||||
}
|
||||
if !trustedCookie.HttpOnly {
|
||||
t.Fatalf("expected trusted device cookie to be HttpOnly")
|
||||
}
|
||||
if trustedCookie.Path != trustedDeviceCookiePath {
|
||||
t.Fatalf("expected trusted device cookie path %q, got %q", trustedDeviceCookiePath, trustedCookie.Path)
|
||||
}
|
||||
var loginResponse struct {
|
||||
Data struct {
|
||||
Token string `json:"token"`
|
||||
TrustedDeviceToken string `json:"trustedDeviceToken"`
|
||||
TrustedDevice *service.TrustedDeviceOutput `json:"trustedDevice"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(loginRecorder.Body.Bytes(), &loginResponse); err != nil {
|
||||
t.Fatalf("unmarshal login response: %v", err)
|
||||
}
|
||||
if loginResponse.Data.Token == "" || loginResponse.Data.TrustedDevice == nil {
|
||||
t.Fatalf("expected login token and trusted device metadata")
|
||||
}
|
||||
if loginResponse.Data.TrustedDeviceToken != "" {
|
||||
t.Fatalf("trusted device token should not be exposed in response body")
|
||||
}
|
||||
|
||||
secondBody, _ := json.Marshal(map[string]string{
|
||||
"username": "admin",
|
||||
"password": "password-123",
|
||||
})
|
||||
secondRequest := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewBuffer(secondBody))
|
||||
secondRequest.Header.Set("Content-Type", "application/json")
|
||||
secondRequest.AddCookie(trustedCookie)
|
||||
secondRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(secondRecorder, secondRequest)
|
||||
|
||||
if secondRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected trusted device login 200, got %d: %s", secondRecorder.Code, secondRecorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func newTestHTTPRouter(t *testing.T) (http.Handler, *service.AuthService) {
|
||||
t.Helper()
|
||||
tempDir := t.TempDir()
|
||||
cfg := config.Config{
|
||||
Server: config.ServerConfig{Host: "127.0.0.1", Port: 8340, Mode: "test"},
|
||||
Database: config.DatabaseConfig{Path: filepath.Join(tempDir, "backupx.db")},
|
||||
Security: config.SecurityConfig{JWTExpire: "24h"},
|
||||
Log: config.LogConfig{Level: "error"},
|
||||
}
|
||||
|
||||
log, err := logger.New(cfg.Log)
|
||||
if err != nil {
|
||||
t.Fatalf("logger.New error: %v", err)
|
||||
}
|
||||
db, err := database.Open(cfg.Database, log)
|
||||
if err != nil {
|
||||
t.Fatalf("database.Open error: %v", err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("db.DB error: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = sqlDB.Close()
|
||||
})
|
||||
|
||||
userRepo := repository.NewUserRepository(db)
|
||||
systemConfigRepo := repository.NewSystemConfigRepository(db)
|
||||
resolved, err := service.ResolveSecurity(context.Background(), cfg.Security, systemConfigRepo)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveSecurity error: %v", err)
|
||||
}
|
||||
jwtManager := security.NewJWTManager(resolved.JWTSecret, time.Hour)
|
||||
authService := service.NewAuthService(userRepo, systemConfigRepo, jwtManager, security.NewLoginRateLimiter(5, time.Minute), codec.NewConfigCipher(resolved.EncryptionKey))
|
||||
systemService := service.NewSystemService(cfg, "test", time.Now().UTC())
|
||||
|
||||
router := NewRouter(RouterDependencies{
|
||||
Config: cfg,
|
||||
Version: "test",
|
||||
Logger: log,
|
||||
AuthService: authService,
|
||||
SystemService: systemService,
|
||||
JWTManager: jwtManager,
|
||||
UserRepository: userRepo,
|
||||
SystemConfigRepo: systemConfigRepo,
|
||||
})
|
||||
return router, authService
|
||||
}
|
||||
|
||||
func findCookie(cookies []*http.Cookie, name string) *http.Cookie {
|
||||
for _, cookie := range cookies {
|
||||
if cookie.Name == name {
|
||||
return cookie
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user