From 35748d8c51bf80eef86df0e874b8c695a9c2d925 Mon Sep 17 00:00:00 2001 From: shaw Date: Thu, 16 Jul 2026 16:41:15 +0800 Subject: [PATCH] feat(security): gate admin role promotion behind step-up 2FA and harden admin TOTP verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 提升用户为管理员 / 创建管理员账号纳入敏感操作:handler 级 EnforceStepUp 门控 (admin API key 拒绝、未启用 TOTP 拒绝、无 grant 返回 STEP_UP_REQUIRED), 目标已是管理员的日常编辑不触发 - 管理员启用/停用 2FA 一律使用密码验证(默认通知邮箱常收不到验证码), verification-method 按用户角色返回;普通用户行为不变 - 用户编辑/创建弹窗接入 useStepUp:命中 STEP_UP_REQUIRED 弹 TOTP 验证并自动重试 - 审计日志清理入口与其他敏感操作对齐:未启用 2FA 时直接提示先启用 TOTP, 不再弹出无法完成的验证码输入框(后端强制现场 TOTP 语义不变) - 审计日志页重构:DataTable 布局、详情弹窗分区展示、时间范围改为 ops 同款 下拉(预设窗口 + 自定义起止支持时分) --- backend/cmd/server/wire_gen.go | 2 +- .../admin/admin_basic_handlers_test.go | 2 +- .../internal/handler/admin/user_handler.go | 29 + .../admin/user_handler_activity_test.go | 4 +- .../admin/user_handler_get_deleted_test.go | 2 +- .../user_handler_list_apikey_group_test.go | 2 +- .../admin/user_handler_role_stepup_test.go | 86 +++ backend/internal/handler/totp_handler.go | 12 +- backend/internal/server/middleware/step_up.go | 82 ++- .../server/middleware/step_up_test.go | 105 +++ backend/internal/service/totp_service.go | 78 +-- .../service/totp_verification_method_test.go | 107 +++ .../components/admin/user/UserCreateModal.vue | 43 +- .../components/admin/user/UserEditModal.vue | 25 +- frontend/src/views/admin/AuditLogView.vue | 643 +++++++++++++----- 15 files changed, 953 insertions(+), 269 deletions(-) create mode 100644 backend/internal/handler/admin/user_handler_role_stepup_test.go create mode 100644 backend/internal/server/middleware/step_up_test.go create mode 100644 backend/internal/service/totp_verification_method_test.go diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index bb4205231e..6f9b9c6d24 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -177,7 +177,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig) proxyLatencyCache := repository.NewProxyLatencyCache(redisClient) adminService := service.NewAdminService(userRepository, groupRepository, adminAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService, affiliateService) - adminUserHandler := admin.NewUserHandler(adminService, concurrencyService, serviceUserPlatformQuotaRepository, billingCache) + adminUserHandler := admin.NewUserHandler(adminService, concurrencyService, serviceUserPlatformQuotaRepository, billingCache, totpService, userService) groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache) groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService) claudeUsageFetcher := repository.NewClaudeUsageFetcher(httpUpstream) diff --git a/backend/internal/handler/admin/admin_basic_handlers_test.go b/backend/internal/handler/admin/admin_basic_handlers_test.go index bffddc8a73..23b20f4e91 100644 --- a/backend/internal/handler/admin/admin_basic_handlers_test.go +++ b/backend/internal/handler/admin/admin_basic_handlers_test.go @@ -16,7 +16,7 @@ func setupAdminRouter() (*gin.Engine, *stubAdminService) { router := gin.New() adminSvc := newStubAdminService() - userHandler := NewUserHandler(adminSvc, nil, nil, nil) + userHandler := NewUserHandler(adminSvc, nil, nil, nil, nil, nil) groupHandler := NewGroupHandler(adminSvc, nil, nil) proxyHandler := NewProxyHandler(adminSvc) redeemHandler := NewRedeemHandler(adminSvc, nil) diff --git a/backend/internal/handler/admin/user_handler.go b/backend/internal/handler/admin/user_handler.go index 534f774e88..b1816d4554 100644 --- a/backend/internal/handler/admin/user_handler.go +++ b/backend/internal/handler/admin/user_handler.go @@ -13,6 +13,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/handler/quotaview" "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -30,6 +31,8 @@ type UserHandler struct { concurrencyService *service.ConcurrencyService userPlatformQuotaRepo service.UserPlatformQuotaRepository // T13 admin quota view billingCache service.BillingCache // T17/T18 缓存失效(PUT/POST 路径) + totpService *service.TotpService // 角色提升为管理员的 step-up 门控 + userService *service.UserService } // NewUserHandler creates a new admin user handler @@ -38,12 +41,16 @@ func NewUserHandler( concurrencyService *service.ConcurrencyService, userPlatformQuotaRepo service.UserPlatformQuotaRepository, billingCache service.BillingCache, + totpService *service.TotpService, + userService *service.UserService, ) *UserHandler { return &UserHandler{ adminService: adminService, concurrencyService: concurrencyService, userPlatformQuotaRepo: userPlatformQuotaRepo, billingCache: billingCache, + totpService: totpService, + userService: userService, } } @@ -266,6 +273,13 @@ func (h *UserHandler) Create(c *gin.Context) { return } + // 创建管理员账号属权限敏感操作:需最近完成 step-up 2FA 验证。 + if req.Role == service.RoleAdmin { + if !middleware.EnforceStepUp(c, h.totpService, h.userService) { + return + } + } + user, err := h.adminService.CreateUser(c.Request.Context(), &service.CreateUserInput{ Email: req.Email, Password: req.Password, @@ -308,6 +322,21 @@ func (h *UserHandler) Update(c *gin.Context) { return } + // 把普通用户提升为管理员属权限敏感操作:需最近完成 step-up 2FA 验证。 + // 目标已是管理员时(前端编辑表单总是携带 role)不触发,避免日常编辑被打断。 + if req.Role == service.RoleAdmin { + target, err := h.adminService.GetUser(c.Request.Context(), userID) + if err != nil { + response.ErrorFrom(c, err) + return + } + if target.Role != service.RoleAdmin { + if !middleware.EnforceStepUp(c, h.totpService, h.userService) { + return + } + } + } + // 使用指针类型直接传递,nil 表示未提供该字段 user, err := h.adminService.UpdateUser(c.Request.Context(), userID, &service.UpdateUserInput{ Email: req.Email, diff --git a/backend/internal/handler/admin/user_handler_activity_test.go b/backend/internal/handler/admin/user_handler_activity_test.go index a9fdc48aa0..74a34de7c2 100644 --- a/backend/internal/handler/admin/user_handler_activity_test.go +++ b/backend/internal/handler/admin/user_handler_activity_test.go @@ -35,7 +35,7 @@ func TestUserHandlerListIncludesActivityFieldsAndSortParams(t *testing.T) { UpdatedAt: lastLoginAt, }, } - handler := NewUserHandler(adminSvc, nil, nil, nil) + handler := NewUserHandler(adminSvc, nil, nil, nil, nil, nil) recorder := httptest.NewRecorder() c, _ := gin.CreateTestContext(recorder) @@ -89,7 +89,7 @@ func TestUserHandlerGetByIDIncludesActivityFields(t *testing.T) { UpdatedAt: lastLoginAt, }, } - handler := NewUserHandler(adminSvc, nil, nil, nil) + handler := NewUserHandler(adminSvc, nil, nil, nil, nil, nil) recorder := httptest.NewRecorder() c, _ := gin.CreateTestContext(recorder) diff --git a/backend/internal/handler/admin/user_handler_get_deleted_test.go b/backend/internal/handler/admin/user_handler_get_deleted_test.go index 1b3070cde9..c915a88f8f 100644 --- a/backend/internal/handler/admin/user_handler_get_deleted_test.go +++ b/backend/internal/handler/admin/user_handler_get_deleted_test.go @@ -26,7 +26,7 @@ func (s *getByIDAdminStub) GetUserIncludeDeleted(_ context.Context, id int64) (* func setupGetByIDRouter(svc service.AdminService) *gin.Engine { gin.SetMode(gin.TestMode) r := gin.New() - h := NewUserHandler(svc, nil, nil, nil) + h := NewUserHandler(svc, nil, nil, nil, nil, nil) r.GET("/admin/users/:id", h.GetByID) return r } diff --git a/backend/internal/handler/admin/user_handler_list_apikey_group_test.go b/backend/internal/handler/admin/user_handler_list_apikey_group_test.go index b5cdd9a18f..0d14b62834 100644 --- a/backend/internal/handler/admin/user_handler_list_apikey_group_test.go +++ b/backend/internal/handler/admin/user_handler_list_apikey_group_test.go @@ -39,7 +39,7 @@ func TestAdminUserList_ParsesAPIKeyGroupID(t *testing.T) { t.Run(tc.name, func(t *testing.T) { stub := &listUsersFilterStub{AdminService: newStubAdminService()} r := gin.New() - h := NewUserHandler(stub, nil, nil, nil) + h := NewUserHandler(stub, nil, nil, nil, nil, nil) r.GET("/admin/users", h.List) w := httptest.NewRecorder() diff --git a/backend/internal/handler/admin/user_handler_role_stepup_test.go b/backend/internal/handler/admin/user_handler_role_stepup_test.go new file mode 100644 index 0000000000..f4ffa3f65f --- /dev/null +++ b/backend/internal/handler/admin/user_handler_role_stepup_test.go @@ -0,0 +1,86 @@ +package admin + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// 角色提升为管理员的 step-up 门控条件测试。 +// 测试环境不注入认证上下文,因此门控一旦触发会以 401 中止; +// 借此区分「触发了 step-up 校验」与「直接放行到业务层(200)」。 +func setupRoleStepUpRouter(t *testing.T) (*gin.Engine, *stubAdminService) { + t.Helper() + gin.SetMode(gin.TestMode) + router := gin.New() + adminSvc := newStubAdminService() + // 追加一个已是管理员的目标用户,验证「目标已是 admin 不触发门控」。 + adminSvc.users = append(adminSvc.users, service.User{ + ID: 2, + Email: "admin@example.com", + Role: service.RoleAdmin, + Status: service.StatusActive, + }) + + h := NewUserHandler(adminSvc, nil, nil, nil, nil, nil) + router.POST("/api/v1/admin/users", h.Create) + router.PUT("/api/v1/admin/users/:id", h.Update) + return router, adminSvc +} + +func doJSON(t *testing.T, router *gin.Engine, method, path string, payload map[string]any) *httptest.ResponseRecorder { + t.Helper() + body, err := json.Marshal(payload) + require.NoError(t, err) + rec := httptest.NewRecorder() + req := httptest.NewRequest(method, path, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(rec, req) + return rec +} + +func TestUpdateUserPromoteToAdminRequiresStepUp(t *testing.T) { + router, _ := setupRoleStepUpRouter(t) + + rec := doJSON(t, router, http.MethodPut, "/api/v1/admin/users/1", map[string]any{"role": "admin"}) + require.Equal(t, http.StatusUnauthorized, rec.Code) +} + +func TestUpdateUserKeepAdminRoleSkipsStepUp(t *testing.T) { + router, _ := setupRoleStepUpRouter(t) + + rec := doJSON(t, router, http.MethodPut, "/api/v1/admin/users/2", map[string]any{"role": "admin"}) + require.Equal(t, http.StatusOK, rec.Code) +} + +func TestUpdateUserRegularRoleSkipsStepUp(t *testing.T) { + router, _ := setupRoleStepUpRouter(t) + + rec := doJSON(t, router, http.MethodPut, "/api/v1/admin/users/1", map[string]any{"role": "user", "email": "u@example.com"}) + require.Equal(t, http.StatusOK, rec.Code) +} + +func TestCreateAdminUserRequiresStepUp(t *testing.T) { + router, _ := setupRoleStepUpRouter(t) + + rec := doJSON(t, router, http.MethodPost, "/api/v1/admin/users", map[string]any{ + "email": "new-admin@example.com", "password": "pass123", "role": "admin", + }) + require.Equal(t, http.StatusUnauthorized, rec.Code) +} + +func TestCreateRegularUserSkipsStepUp(t *testing.T) { + router, _ := setupRoleStepUpRouter(t) + + rec := doJSON(t, router, http.MethodPost, "/api/v1/admin/users", map[string]any{ + "email": "new-user@example.com", "password": "pass123", "role": "user", + }) + require.Equal(t, http.StatusOK, rec.Code) +} diff --git a/backend/internal/handler/totp_handler.go b/backend/internal/handler/totp_handler.go index 9d5527ca7e..edd738ac00 100644 --- a/backend/internal/handler/totp_handler.go +++ b/backend/internal/handler/totp_handler.go @@ -159,7 +159,17 @@ func (h *TotpHandler) Disable(c *gin.Context) { // GetVerificationMethod returns the verification method for TOTP operations // GET /api/v1/user/totp/verification-method func (h *TotpHandler) GetVerificationMethod(c *gin.Context) { - method := h.totpService.GetVerificationMethod(c.Request.Context()) + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + response.Unauthorized(c, "User not authenticated") + return + } + + method, err := h.totpService.GetVerificationMethod(c.Request.Context(), subject.UserID) + if err != nil { + response.ErrorFrom(c, err) + return + } response.Success(c, method) } diff --git a/backend/internal/server/middleware/step_up.go b/backend/internal/server/middleware/step_up.go index 46fd82e8be..b8ffc3d079 100644 --- a/backend/internal/server/middleware/step_up.go +++ b/backend/internal/server/middleware/step_up.go @@ -45,42 +45,56 @@ func NewStepUpAuthMiddleware(totpService *service.TotpService, userService *serv func stepUpAuth(grantChecker stepUpGrantChecker, userReader stepUpUserReader) gin.HandlerFunc { return func(c *gin.Context) { - if c.GetString("auth_method") == service.AuditAuthMethodAdminAPIKey { - AbortWithError(c, 403, "STEP_UP_ADMIN_API_KEY_FORBIDDEN", - "Admin API key cannot access this endpoint; a two-factor verified admin session is required") + if !enforceStepUp(c, grantChecker, userReader) { return } - - subject, ok := GetAuthSubjectFromContext(c) - if !ok || subject.UserID <= 0 { - AbortWithError(c, 401, "UNAUTHORIZED", "Authorization required") - return - } - - user, err := userReader.GetByID(c.Request.Context(), subject.UserID) - if err != nil { - AbortWithError(c, 500, "INTERNAL_ERROR", "Failed to load user") - return - } - if !user.TotpEnabled { - AbortWithError(c, 403, "STEP_UP_TOTP_NOT_ENABLED", - "This operation requires two-factor authentication; please enable TOTP first") - return - } - - sessionKey := StepUpSessionKey(c, subject.UserID) - granted, err := grantChecker.HasStepUpGrant(c.Request.Context(), subject.UserID, sessionKey) - if err != nil { - // 安全门控故障时选择 fail-closed。 - AbortWithError(c, 503, "STEP_UP_UNAVAILABLE", "Step-up verification service unavailable") - return - } - if !granted { - AbortWithError(c, 403, "STEP_UP_REQUIRED", - "This operation requires recent two-factor verification") - return - } - c.Next() } } + +// EnforceStepUp 对当前请求执行与 StepUpAuthMiddleware 相同语义的 step-up 门控, +// 供 handler 在需要按请求内容条件触发时调用(如仅当把用户角色提升为管理员时)。 +// 校验失败时写入错误响应并中止请求,返回 false;通过返回 true。 +func EnforceStepUp(c *gin.Context, totpService *service.TotpService, userService *service.UserService) bool { + return enforceStepUp(c, totpService, userService) +} + +func enforceStepUp(c *gin.Context, grantChecker stepUpGrantChecker, userReader stepUpUserReader) bool { + if c.GetString("auth_method") == service.AuditAuthMethodAdminAPIKey { + AbortWithError(c, 403, "STEP_UP_ADMIN_API_KEY_FORBIDDEN", + "Admin API key cannot access this endpoint; a two-factor verified admin session is required") + return false + } + + subject, ok := GetAuthSubjectFromContext(c) + if !ok || subject.UserID <= 0 { + AbortWithError(c, 401, "UNAUTHORIZED", "Authorization required") + return false + } + + user, err := userReader.GetByID(c.Request.Context(), subject.UserID) + if err != nil { + AbortWithError(c, 500, "INTERNAL_ERROR", "Failed to load user") + return false + } + if !user.TotpEnabled { + AbortWithError(c, 403, "STEP_UP_TOTP_NOT_ENABLED", + "This operation requires two-factor authentication; please enable TOTP first") + return false + } + + sessionKey := StepUpSessionKey(c, subject.UserID) + granted, err := grantChecker.HasStepUpGrant(c.Request.Context(), subject.UserID, sessionKey) + if err != nil { + // 安全门控故障时选择 fail-closed。 + AbortWithError(c, 503, "STEP_UP_UNAVAILABLE", "Step-up verification service unavailable") + return false + } + if !granted { + AbortWithError(c, 403, "STEP_UP_REQUIRED", + "This operation requires recent two-factor verification") + return false + } + + return true +} diff --git a/backend/internal/server/middleware/step_up_test.go b/backend/internal/server/middleware/step_up_test.go new file mode 100644 index 0000000000..557d94d04c --- /dev/null +++ b/backend/internal/server/middleware/step_up_test.go @@ -0,0 +1,105 @@ +package middleware + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type stubStepUpGrantChecker struct { + granted bool + err error +} + +func (s stubStepUpGrantChecker) HasStepUpGrant(ctx context.Context, userID int64, sessionKey string) (bool, error) { + return s.granted, s.err +} + +type stubStepUpUserReader struct { + user *service.User + err error +} + +func (s stubStepUpUserReader) GetByID(ctx context.Context, id int64) (*service.User, error) { + return s.user, s.err +} + +func newStepUpTestContext(t *testing.T) (*gin.Context, *httptest.ResponseRecorder) { + t.Helper() + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/sensitive", nil) + return c, rec +} + +func TestEnforceStepUpRejectsAdminAPIKey(t *testing.T) { + c, rec := newStepUpTestContext(t) + c.Set("auth_method", service.AuditAuthMethodAdminAPIKey) + + ok := enforceStepUp(c, stubStepUpGrantChecker{granted: true}, stubStepUpUserReader{user: &service.User{TotpEnabled: true}}) + + require.False(t, ok) + require.True(t, c.IsAborted()) + require.Equal(t, http.StatusForbidden, rec.Code) + require.Contains(t, rec.Body.String(), "STEP_UP_ADMIN_API_KEY_FORBIDDEN") +} + +func TestEnforceStepUpRequiresAuthSubject(t *testing.T) { + c, rec := newStepUpTestContext(t) + + ok := enforceStepUp(c, stubStepUpGrantChecker{granted: true}, stubStepUpUserReader{user: &service.User{TotpEnabled: true}}) + + require.False(t, ok) + require.Equal(t, http.StatusUnauthorized, rec.Code) +} + +func TestEnforceStepUpRequiresTotpEnabled(t *testing.T) { + c, rec := newStepUpTestContext(t) + c.Set(string(ContextKeyUser), AuthSubject{UserID: 1}) + + ok := enforceStepUp(c, stubStepUpGrantChecker{granted: true}, stubStepUpUserReader{user: &service.User{ID: 1, TotpEnabled: false}}) + + require.False(t, ok) + require.Equal(t, http.StatusForbidden, rec.Code) + require.Contains(t, rec.Body.String(), "STEP_UP_TOTP_NOT_ENABLED") +} + +func TestEnforceStepUpFailsClosedOnGrantError(t *testing.T) { + c, rec := newStepUpTestContext(t) + c.Set(string(ContextKeyUser), AuthSubject{UserID: 1}) + + ok := enforceStepUp(c, stubStepUpGrantChecker{err: errors.New("redis down")}, stubStepUpUserReader{user: &service.User{ID: 1, TotpEnabled: true}}) + + require.False(t, ok) + require.Equal(t, http.StatusServiceUnavailable, rec.Code) + require.Contains(t, rec.Body.String(), "STEP_UP_UNAVAILABLE") +} + +func TestEnforceStepUpRequiresGrant(t *testing.T) { + c, rec := newStepUpTestContext(t) + c.Set(string(ContextKeyUser), AuthSubject{UserID: 1}) + + ok := enforceStepUp(c, stubStepUpGrantChecker{granted: false}, stubStepUpUserReader{user: &service.User{ID: 1, TotpEnabled: true}}) + + require.False(t, ok) + require.Equal(t, http.StatusForbidden, rec.Code) + require.Contains(t, rec.Body.String(), "STEP_UP_REQUIRED") +} + +func TestEnforceStepUpPassesWithGrant(t *testing.T) { + c, _ := newStepUpTestContext(t) + c.Set(string(ContextKeyUser), AuthSubject{UserID: 1}) + + ok := enforceStepUp(c, stubStepUpGrantChecker{granted: true}, stubStepUpUserReader{user: &service.User{ID: 1, TotpEnabled: true}}) + + require.True(t, ok) + require.False(t, c.IsAborted()) +} diff --git a/backend/internal/service/totp_service.go b/backend/internal/service/totp_service.go index 0f4fa185fc..aac42dac71 100644 --- a/backend/internal/service/totp_service.go +++ b/backend/internal/service/totp_service.go @@ -141,6 +141,31 @@ func (s *TotpService) GetStatus(ctx context.Context, userID int64) (*TotpStatus, }, nil } +// usesEmailVerification 判断 TOTP 启用/停用时的身份校验方式。 +// 管理员一律使用密码校验:管理员账号的邮箱常为占位地址收不到验证码, +// 且管理员凭证失守时攻击者往往同时控制通知邮箱,邮箱验证码不构成有效防线。 +// 普通用户维持原有行为:开启邮箱验证时用邮箱验证码,否则用密码。 +func (s *TotpService) usesEmailVerification(ctx context.Context, user *User) bool { + return user.Role != RoleAdmin && s.settingService.IsEmailVerifyEnabled(ctx) +} + +// verifyIdentity 按 usesEmailVerification 的结果校验邮箱验证码或密码。 +func (s *TotpService) verifyIdentity(ctx context.Context, user *User, emailCode, password string) error { + if s.usesEmailVerification(ctx, user) { + if emailCode == "" { + return ErrVerifyCodeRequired + } + return s.emailService.VerifyCode(ctx, user.Email, emailCode) + } + if password == "" { + return ErrPasswordRequired + } + if !user.CheckPassword(password) { + return ErrPasswordIncorrect + } + return nil +} + // InitiateSetup starts the TOTP setup process // If email verification is enabled, emailCode is required; otherwise password is required func (s *TotpService) InitiateSetup(ctx context.Context, userID int64, emailCode, password string) (*TotpSetupResponse, error) { @@ -159,23 +184,8 @@ func (s *TotpService) InitiateSetup(ctx context.Context, userID int64, emailCode return nil, ErrTotpAlreadyEnabled } - // Verify identity based on email verification setting - if s.settingService.IsEmailVerifyEnabled(ctx) { - // Email verification enabled - verify email code - if emailCode == "" { - return nil, ErrVerifyCodeRequired - } - if err := s.emailService.VerifyCode(ctx, user.Email, emailCode); err != nil { - return nil, err - } - } else { - // Email verification disabled - verify password - if password == "" { - return nil, ErrPasswordRequired - } - if !user.CheckPassword(password) { - return nil, ErrPasswordIncorrect - } + if err := s.verifyIdentity(ctx, user, emailCode, password); err != nil { + return nil, err } // Generate a new TOTP key @@ -306,23 +316,8 @@ func (s *TotpService) Disable(ctx context.Context, userID int64, emailCode, pass return ErrTotpNotSetup } - // Verify identity based on email verification setting - if s.settingService.IsEmailVerifyEnabled(ctx) { - // Email verification enabled - verify email code - if emailCode == "" { - return ErrVerifyCodeRequired - } - if err := s.emailService.VerifyCode(ctx, user.Email, emailCode); err != nil { - return err - } - } else { - // Email verification disabled - verify password - if password == "" { - return ErrPasswordRequired - } - if !user.CheckPassword(password) { - return ErrPasswordIncorrect - } + if err := s.verifyIdentity(ctx, user, emailCode, password); err != nil { + return err } // Disable TOTP @@ -532,12 +527,17 @@ type VerificationMethod struct { Method string `json:"method"` // "email" or "password" } -// GetVerificationMethod returns the verification method for TOTP operations -func (s *TotpService) GetVerificationMethod(ctx context.Context) *VerificationMethod { - if s.settingService.IsEmailVerifyEnabled(ctx) { - return &VerificationMethod{Method: "email"} +// GetVerificationMethod returns the verification method for TOTP operations. +// 与 verifyIdentity 保持同一判定:管理员一律返回 password。 +func (s *TotpService) GetVerificationMethod(ctx context.Context, userID int64) (*VerificationMethod, error) { + user, err := s.userRepo.GetByID(ctx, userID) + if err != nil { + return nil, fmt.Errorf("get user: %w", err) } - return &VerificationMethod{Method: "password"} + if s.usesEmailVerification(ctx, user) { + return &VerificationMethod{Method: "email"}, nil + } + return &VerificationMethod{Method: "password"}, nil } // SendVerifyCode sends an email verification code for TOTP operations diff --git a/backend/internal/service/totp_verification_method_test.go b/backend/internal/service/totp_verification_method_test.go new file mode 100644 index 0000000000..baae2d6749 --- /dev/null +++ b/backend/internal/service/totp_verification_method_test.go @@ -0,0 +1,107 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +// totpVMUserRepoStub 仅实现 TOTP 验证方式测试所需方法;未桩方法调用即 panic(嵌入 nil 接口)。 +type totpVMUserRepoStub struct { + UserRepository + user *User + totpDisabled bool + disableCalled bool +} + +func (s *totpVMUserRepoStub) GetByID(ctx context.Context, id int64) (*User, error) { + if s.user == nil { + return nil, errors.New("user not found") + } + return s.user, nil +} + +func (s *totpVMUserRepoStub) DisableTotp(ctx context.Context, userID int64) error { + s.disableCalled = true + s.totpDisabled = true + return nil +} + +type totpVMSettingRepoStub struct { + SettingRepository + values map[string]string +} + +func (s *totpVMSettingRepoStub) GetValue(ctx context.Context, key string) (string, error) { + v, ok := s.values[key] + if !ok { + return "", errors.New("setting not found") + } + return v, nil +} + +func newTotpVMService(t *testing.T, user *User, emailVerifyEnabled bool) (*TotpService, *totpVMUserRepoStub) { + t.Helper() + userRepo := &totpVMUserRepoStub{user: user} + values := map[string]string{} + if emailVerifyEnabled { + values[SettingKeyEmailVerifyEnabled] = "true" + } + settingSvc := NewSettingService(&totpVMSettingRepoStub{values: values}, nil) + return NewTotpService(userRepo, nil, nil, settingSvc, nil, nil), userRepo +} + +func TestGetVerificationMethodAdminAlwaysPassword(t *testing.T) { + admin := &User{ID: 1, Email: "admin@example.com", Role: RoleAdmin} + svc, _ := newTotpVMService(t, admin, true) + + method, err := svc.GetVerificationMethod(context.Background(), admin.ID) + require.NoError(t, err) + require.Equal(t, "password", method.Method) +} + +func TestGetVerificationMethodRegularUserFollowsEmailVerifySetting(t *testing.T) { + user := &User{ID: 2, Email: "user@example.com", Role: RoleUser} + + svcEmailOn, _ := newTotpVMService(t, user, true) + method, err := svcEmailOn.GetVerificationMethod(context.Background(), user.ID) + require.NoError(t, err) + require.Equal(t, "email", method.Method) + + svcEmailOff, _ := newTotpVMService(t, user, false) + method, err = svcEmailOff.GetVerificationMethod(context.Background(), user.ID) + require.NoError(t, err) + require.Equal(t, "password", method.Method) +} + +func TestTotpDisableAdminUsesPasswordEvenWithEmailVerifyEnabled(t *testing.T) { + admin := &User{ID: 1, Email: "admin@example.com", Role: RoleAdmin, TotpEnabled: true} + require.NoError(t, admin.SetPassword("correct-password")) + svc, userRepo := newTotpVMService(t, admin, true) + + // 缺密码 → 要求密码(而非邮箱验证码)。 + err := svc.Disable(context.Background(), admin.ID, "", "") + require.ErrorIs(t, err, ErrPasswordRequired) + + // 密码错误 → 拒绝。 + err = svc.Disable(context.Background(), admin.ID, "", "wrong-password") + require.ErrorIs(t, err, ErrPasswordIncorrect) + + // 密码正确 → 成功停用;全程不需要邮箱验证码(emailService 为 nil,走到邮箱分支会 panic)。 + err = svc.Disable(context.Background(), admin.ID, "", "correct-password") + require.NoError(t, err) + require.True(t, userRepo.disableCalled) +} + +func TestTotpDisableRegularUserStillRequiresEmailCode(t *testing.T) { + user := &User{ID: 2, Email: "user@example.com", Role: RoleUser, TotpEnabled: true} + require.NoError(t, user.SetPassword("whatever")) + svc, _ := newTotpVMService(t, user, true) + + err := svc.Disable(context.Background(), user.ID, "", "whatever") + require.ErrorIs(t, err, ErrVerifyCodeRequired) +} diff --git a/frontend/src/components/admin/user/UserCreateModal.vue b/frontend/src/components/admin/user/UserCreateModal.vue index 609f7cd520..1397a4c855 100644 --- a/frontend/src/components/admin/user/UserCreateModal.vue +++ b/frontend/src/components/admin/user/UserCreateModal.vue @@ -64,34 +64,57 @@ + + + diff --git a/frontend/src/views/admin/AuditLogView.vue b/frontend/src/views/admin/AuditLogView.vue index 7419c170c4..b2bb2d099a 100644 --- a/frontend/src/views/admin/AuditLogView.vue +++ b/frontend/src/views/admin/AuditLogView.vue @@ -1,150 +1,304 @@