mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-06-02 00:09:45 +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:
@@ -1,12 +1,23 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net"
|
||||
stdhttp "net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/apperror"
|
||||
"backupx/server/internal/service"
|
||||
"backupx/server/pkg/response"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
trustedDeviceCookieName = "backupx_trusted_device"
|
||||
trustedDeviceCookiePath = "/api/auth"
|
||||
trustedDeviceCookieMaxAge = int((30 * 24 * time.Hour) / time.Second)
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
authService *service.AuthService
|
||||
}
|
||||
@@ -44,11 +55,18 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
response.Error(c, apperror.BadRequest("AUTH_LOGIN_INVALID", "登录参数不合法", err))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(input.TrustedDeviceToken) == "" {
|
||||
input.TrustedDeviceToken = trustedDeviceCookieValue(c)
|
||||
}
|
||||
payload, err := h.authService.Login(c.Request.Context(), input, ClientKey(c))
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
if payload.TrustedDeviceToken != "" {
|
||||
setTrustedDeviceCookie(c, payload.TrustedDeviceToken)
|
||||
payload.TrustedDeviceToken = ""
|
||||
}
|
||||
response.Success(c, payload)
|
||||
}
|
||||
|
||||
@@ -83,9 +101,315 @@ func (h *AuthHandler) ChangePassword(c *gin.Context) {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
clearTrustedDeviceCookie(c)
|
||||
response.Success(c, gin.H{"changed": true})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) PrepareTwoFactor(c *gin.Context) {
|
||||
subjectValue, _ := c.Get(contextUserSubjectKey)
|
||||
subject, err := service.SubjectFromContextValue(subjectValue)
|
||||
if err != nil {
|
||||
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
|
||||
return
|
||||
}
|
||||
var input service.TwoFactorSetupInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, apperror.BadRequest("AUTH_2FA_INVALID", "参数不合法", err))
|
||||
return
|
||||
}
|
||||
payload, err := h.authService.PrepareTwoFactor(c.Request.Context(), subject, input)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, payload)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) EnableTwoFactor(c *gin.Context) {
|
||||
subjectValue, _ := c.Get(contextUserSubjectKey)
|
||||
subject, err := service.SubjectFromContextValue(subjectValue)
|
||||
if err != nil {
|
||||
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
|
||||
return
|
||||
}
|
||||
var input service.EnableTwoFactorInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, apperror.BadRequest("AUTH_2FA_INVALID", "参数不合法", err))
|
||||
return
|
||||
}
|
||||
user, err := h.authService.EnableTwoFactor(c.Request.Context(), subject, input)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, user)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) DisableTwoFactor(c *gin.Context) {
|
||||
subjectValue, _ := c.Get(contextUserSubjectKey)
|
||||
subject, err := service.SubjectFromContextValue(subjectValue)
|
||||
if err != nil {
|
||||
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
|
||||
return
|
||||
}
|
||||
var input service.DisableTwoFactorInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, apperror.BadRequest("AUTH_2FA_INVALID", "参数不合法", err))
|
||||
return
|
||||
}
|
||||
user, err := h.authService.DisableTwoFactor(c.Request.Context(), subject, input)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
if !user.MFAEnabled {
|
||||
clearTrustedDeviceCookie(c)
|
||||
}
|
||||
response.Success(c, user)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) RegenerateRecoveryCodes(c *gin.Context) {
|
||||
subjectValue, _ := c.Get(contextUserSubjectKey)
|
||||
subject, err := service.SubjectFromContextValue(subjectValue)
|
||||
if err != nil {
|
||||
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
|
||||
return
|
||||
}
|
||||
var input service.RegenerateRecoveryCodesInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, apperror.BadRequest("AUTH_2FA_INVALID", "参数不合法", err))
|
||||
return
|
||||
}
|
||||
payload, err := h.authService.RegenerateRecoveryCodes(c.Request.Context(), subject, input)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, payload)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) ConfigureOTP(c *gin.Context) {
|
||||
subjectValue, _ := c.Get(contextUserSubjectKey)
|
||||
subject, err := service.SubjectFromContextValue(subjectValue)
|
||||
if err != nil {
|
||||
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
|
||||
return
|
||||
}
|
||||
var input service.OTPConfigInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, apperror.BadRequest("AUTH_OTP_INVALID", "参数不合法", err))
|
||||
return
|
||||
}
|
||||
user, err := h.authService.ConfigureOutOfBandOTP(c.Request.Context(), subject, input)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
if !user.MFAEnabled {
|
||||
clearTrustedDeviceCookie(c)
|
||||
}
|
||||
response.Success(c, user)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) SendLoginOTP(c *gin.Context) {
|
||||
var input service.LoginOTPInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, apperror.BadRequest("AUTH_OTP_INVALID", "参数不合法", err))
|
||||
return
|
||||
}
|
||||
if err := h.authService.SendLoginOTP(c.Request.Context(), input, ClientKey(c)); err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"sent": true})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) BeginWebAuthnRegistration(c *gin.Context) {
|
||||
subjectValue, _ := c.Get(contextUserSubjectKey)
|
||||
subject, err := service.SubjectFromContextValue(subjectValue)
|
||||
if err != nil {
|
||||
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
|
||||
return
|
||||
}
|
||||
var input service.WebAuthnRegistrationOptionsInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, apperror.BadRequest("AUTH_WEBAUTHN_INVALID", "参数不合法", err))
|
||||
return
|
||||
}
|
||||
options, err := h.authService.BeginWebAuthnRegistration(c.Request.Context(), subject, input, webAuthnRequestContext(c))
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, options)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) FinishWebAuthnRegistration(c *gin.Context) {
|
||||
subjectValue, _ := c.Get(contextUserSubjectKey)
|
||||
subject, err := service.SubjectFromContextValue(subjectValue)
|
||||
if err != nil {
|
||||
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
|
||||
return
|
||||
}
|
||||
var input service.WebAuthnRegistrationFinishInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, apperror.BadRequest("AUTH_WEBAUTHN_INVALID", "参数不合法", err))
|
||||
return
|
||||
}
|
||||
user, err := h.authService.FinishWebAuthnRegistration(c.Request.Context(), subject, input)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, user)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) BeginWebAuthnLogin(c *gin.Context) {
|
||||
var input service.WebAuthnLoginOptionsInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, apperror.BadRequest("AUTH_WEBAUTHN_INVALID", "参数不合法", err))
|
||||
return
|
||||
}
|
||||
options, err := h.authService.BeginWebAuthnLogin(c.Request.Context(), input, webAuthnRequestContext(c), ClientKey(c))
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, options)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) ListWebAuthnCredentials(c *gin.Context) {
|
||||
subjectValue, _ := c.Get(contextUserSubjectKey)
|
||||
subject, err := service.SubjectFromContextValue(subjectValue)
|
||||
if err != nil {
|
||||
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
|
||||
return
|
||||
}
|
||||
items, err := h.authService.ListWebAuthnCredentials(c.Request.Context(), subject)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, items)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) DeleteWebAuthnCredential(c *gin.Context) {
|
||||
subjectValue, _ := c.Get(contextUserSubjectKey)
|
||||
subject, err := service.SubjectFromContextValue(subjectValue)
|
||||
if err != nil {
|
||||
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
|
||||
return
|
||||
}
|
||||
var input service.WebAuthnCredentialDeleteInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, apperror.BadRequest("AUTH_WEBAUTHN_INVALID", "参数不合法", err))
|
||||
return
|
||||
}
|
||||
user, err := h.authService.DeleteWebAuthnCredential(c.Request.Context(), subject, c.Param("id"), input)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
if !user.MFAEnabled {
|
||||
clearTrustedDeviceCookie(c)
|
||||
}
|
||||
response.Success(c, user)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) ListTrustedDevices(c *gin.Context) {
|
||||
subjectValue, _ := c.Get(contextUserSubjectKey)
|
||||
subject, err := service.SubjectFromContextValue(subjectValue)
|
||||
if err != nil {
|
||||
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
|
||||
return
|
||||
}
|
||||
items, err := h.authService.ListTrustedDevices(c.Request.Context(), subject)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, items)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) RevokeTrustedDevice(c *gin.Context) {
|
||||
subjectValue, _ := c.Get(contextUserSubjectKey)
|
||||
subject, err := service.SubjectFromContextValue(subjectValue)
|
||||
if err != nil {
|
||||
response.Error(c, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效登录态", err))
|
||||
return
|
||||
}
|
||||
var input service.TrustedDeviceRevokeInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, apperror.BadRequest("AUTH_TRUSTED_DEVICE_INVALID", "参数不合法", err))
|
||||
return
|
||||
}
|
||||
if err := h.authService.RevokeTrustedDevice(c.Request.Context(), subject, c.Param("id"), input); err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
clearTrustedDeviceCookie(c)
|
||||
response.Success(c, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
response.Success(c, gin.H{"loggedOut": true})
|
||||
}
|
||||
|
||||
func webAuthnRequestContext(c *gin.Context) service.WebAuthnRequestContext {
|
||||
host := firstForwardedValue(c.Request.Host)
|
||||
if forwardedHost := firstForwardedValue(c.GetHeader("X-Forwarded-Host")); forwardedHost != "" {
|
||||
host = forwardedHost
|
||||
}
|
||||
rpID := host
|
||||
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
|
||||
rpID = parsedHost
|
||||
}
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
if forwardedProto := firstForwardedValue(c.GetHeader("X-Forwarded-Proto")); forwardedProto != "" {
|
||||
scheme = forwardedProto
|
||||
}
|
||||
origin := strings.TrimSpace(c.GetHeader("Origin"))
|
||||
if origin == "" {
|
||||
origin = scheme + "://" + host
|
||||
}
|
||||
return service.WebAuthnRequestContext{RPID: rpID, Origin: origin}
|
||||
}
|
||||
|
||||
func firstForwardedValue(value string) string {
|
||||
parts := strings.Split(value, ",")
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(parts[0])
|
||||
}
|
||||
|
||||
func trustedDeviceCookieValue(c *gin.Context) string {
|
||||
token, err := c.Cookie(trustedDeviceCookieName)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(token)
|
||||
}
|
||||
|
||||
func setTrustedDeviceCookie(c *gin.Context, token string) {
|
||||
writeTrustedDeviceCookie(c, strings.TrimSpace(token), trustedDeviceCookieMaxAge)
|
||||
}
|
||||
|
||||
func clearTrustedDeviceCookie(c *gin.Context) {
|
||||
writeTrustedDeviceCookie(c, "", -1)
|
||||
}
|
||||
|
||||
func writeTrustedDeviceCookie(c *gin.Context, value string, maxAge int) {
|
||||
c.SetSameSite(stdhttp.SameSiteLaxMode)
|
||||
c.SetCookie(trustedDeviceCookieName, value, maxAge, trustedDeviceCookiePath, "", requestIsSecure(c), true)
|
||||
}
|
||||
|
||||
func requestIsSecure(c *gin.Context) bool {
|
||||
if c.Request.TLS != nil {
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(firstForwardedValue(c.GetHeader("X-Forwarded-Proto")), "https")
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"backupx/server/internal/repository"
|
||||
"backupx/server/internal/security"
|
||||
"backupx/server/internal/service"
|
||||
"backupx/server/internal/storage/codec"
|
||||
)
|
||||
|
||||
// setupInstallFlowRouter 构造一个 Node + Agent + InstallToken 全量依赖的 router,
|
||||
@@ -40,6 +41,13 @@ func setupInstallFlowRouter(t *testing.T) (http.Handler, string) {
|
||||
if err != nil {
|
||||
t.Fatalf("db: %v", err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("sql db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = sqlDB.Close()
|
||||
})
|
||||
|
||||
userRepo := repository.NewUserRepository(db)
|
||||
systemConfigRepo := repository.NewSystemConfigRepository(db)
|
||||
@@ -48,7 +56,7 @@ func setupInstallFlowRouter(t *testing.T) (http.Handler, string) {
|
||||
t.Fatalf("security: %v", err)
|
||||
}
|
||||
jwtMgr := security.NewJWTManager(resolved.JWTSecret, time.Hour)
|
||||
authSvc := service.NewAuthService(userRepo, systemConfigRepo, jwtMgr, security.NewLoginRateLimiter(5, time.Minute))
|
||||
authSvc := service.NewAuthService(userRepo, systemConfigRepo, jwtMgr, security.NewLoginRateLimiter(5, time.Minute), codec.NewConfigCipher(resolved.EncryptionKey))
|
||||
systemSvc := service.NewSystemService(cfg, "test", time.Now().UTC())
|
||||
|
||||
nodeRepo := repository.NewNodeRepository(db)
|
||||
|
||||
@@ -94,9 +94,22 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
|
||||
auth.GET("/setup/status", authHandler.SetupStatus)
|
||||
auth.POST("/setup", authHandler.Setup)
|
||||
auth.POST("/login", authHandler.Login)
|
||||
auth.POST("/otp/send", authHandler.SendLoginOTP)
|
||||
auth.POST("/webauthn/login/options", authHandler.BeginWebAuthnLogin)
|
||||
auth.POST("/logout", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.Logout)
|
||||
auth.GET("/profile", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.Profile)
|
||||
auth.PUT("/password", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.ChangePassword)
|
||||
auth.POST("/2fa/setup", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.PrepareTwoFactor)
|
||||
auth.POST("/2fa/enable", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.EnableTwoFactor)
|
||||
auth.POST("/2fa/recovery-codes", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.RegenerateRecoveryCodes)
|
||||
auth.DELETE("/2fa", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.DisableTwoFactor)
|
||||
auth.PUT("/otp/config", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.ConfigureOTP)
|
||||
auth.POST("/webauthn/register/options", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.BeginWebAuthnRegistration)
|
||||
auth.POST("/webauthn/register/finish", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.FinishWebAuthnRegistration)
|
||||
auth.GET("/webauthn/credentials", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.ListWebAuthnCredentials)
|
||||
auth.DELETE("/webauthn/credentials/:id", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.DeleteWebAuthnCredential)
|
||||
auth.GET("/trusted-devices", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.ListTrustedDevices)
|
||||
auth.DELETE("/trusted-devices/:id", AuthMiddleware(deps.JWTManager, apiKeyAuth), authHandler.RevokeTrustedDevice)
|
||||
}
|
||||
|
||||
system := api.Group("/system")
|
||||
@@ -229,6 +242,7 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
|
||||
users.GET("", userHandler.List)
|
||||
users.POST("", userHandler.Create)
|
||||
users.PUT("/:id", userHandler.Update)
|
||||
users.POST("/:id/2fa/reset", userHandler.ResetTwoFactor)
|
||||
users.DELETE("/:id", userHandler.Delete)
|
||||
}
|
||||
|
||||
@@ -279,10 +293,10 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
|
||||
nodes.PUT("/:id", RequireRole("admin"), nodeHandler.Update)
|
||||
nodes.DELETE("/:id", RequireRole("admin"), nodeHandler.Delete)
|
||||
nodes.GET("/:id/fs/list", nodeHandler.ListDirectory)
|
||||
nodes.POST("/batch", RequireRole("admin"), nodeHandler.BatchCreate)
|
||||
nodes.POST("/:id/install-tokens", RequireRole("admin"), nodeHandler.CreateInstallToken)
|
||||
nodes.POST("/:id/rotate-token", RequireRole("admin"), nodeHandler.RotateToken)
|
||||
nodes.GET("/:id/install-script-preview", RequireRole("admin"), nodeHandler.PreviewScript)
|
||||
nodes.POST("/batch", RequireRole("admin"), nodeHandler.BatchCreate)
|
||||
nodes.POST("/:id/install-tokens", RequireRole("admin"), nodeHandler.CreateInstallToken)
|
||||
nodes.POST("/:id/rotate-token", RequireRole("admin"), nodeHandler.RotateToken)
|
||||
nodes.GET("/:id/install-script-preview", RequireRole("admin"), nodeHandler.PreviewScript)
|
||||
|
||||
// Agent API(token 认证,无需 JWT)
|
||||
if deps.AgentService != nil {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -78,3 +78,18 @@ func (h *UserHandler) Delete(c *gin.Context) {
|
||||
fmt.Sprintf("删除用户 (ID: %d)", id))
|
||||
response.Success(c, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
func (h *UserHandler) ResetTwoFactor(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
item, err := h.service.ResetTwoFactor(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
recordAudit(c, h.auditService, "user", "reset_two_factor", "user", fmt.Sprintf("%d", id), item.Username,
|
||||
fmt.Sprintf("重置用户 %s 的 MFA", item.Username))
|
||||
response.Success(c, item)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user