feat(security): gate admin role promotion behind step-up 2FA and harden admin TOTP verification

- 提升用户为管理员 / 创建管理员账号纳入敏感操作:handler 级 EnforceStepUp 门控
  (admin API key 拒绝、未启用 TOTP 拒绝、无 grant 返回 STEP_UP_REQUIRED),
  目标已是管理员的日常编辑不触发
- 管理员启用/停用 2FA 一律使用密码验证(默认通知邮箱常收不到验证码),
  verification-method 按用户角色返回;普通用户行为不变
- 用户编辑/创建弹窗接入 useStepUp:命中 STEP_UP_REQUIRED 弹 TOTP 验证并自动重试
- 审计日志清理入口与其他敏感操作对齐:未启用 2FA 时直接提示先启用 TOTP,
  不再弹出无法完成的验证码输入框(后端强制现场 TOTP 语义不变)
- 审计日志页重构:DataTable 布局、详情弹窗分区展示、时间范围改为 ops 同款
  下拉(预设窗口 + 自定义起止支持时分)
This commit is contained in:
shaw
2026-07-16 16:41:15 +08:00
parent 393a8fe56a
commit 35748d8c51
15 changed files with 953 additions and 269 deletions
+1 -1
View File
@@ -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)
@@ -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)
@@ -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,
@@ -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)
@@ -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
}
@@ -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()
@@ -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)
}
+11 -1
View File
@@ -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)
}
+48 -34
View File
@@ -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
}
@@ -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())
}
+39 -39
View File
@@ -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
@@ -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)
}
@@ -64,34 +64,57 @@
</div>
</template>
</BaseDialog>
<!-- 创建管理员账号时后端要求 step-up 2FA弹出 TOTP 验证后自动重试 -->
<TotpStepUpDialog :controller="stepUp" />
</template>
<script setup lang="ts">
import { reactive, watch } from 'vue'
import { reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'; import { adminAPI } from '@/api/admin'
import { useForm } from '@/composables/useForm'
import { useAppStore } from '@/stores/app'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Icon from '@/components/icons/Icon.vue'
import { useStepUp, isStepUpBlocked, isStepUpCancelled, stepUpBlockReason } from '@/composables/useStepUp'
import TotpStepUpDialog from '@/components/auth/TotpStepUpDialog.vue'
const props = defineProps<{ show: boolean }>()
const emit = defineEmits(['close', 'success']); const { t } = useI18n()
const appStore = useAppStore()
const form = reactive({ email: '', password: '', username: '', notes: '', role: 'user' as 'user' | 'admin', balance: '', concurrency: 1, rpm_limit: 0 })
const { loading, submit } = useForm({
form,
submitFn: async (data) => {
const { balance: rawBalance, ...rest } = data
const stepUp = useStepUp()
const loading = ref(false)
const submit = async () => {
if (loading.value) return
loading.value = true
try {
const { balance: rawBalance, ...rest } = { ...form }
const balance = String(rawBalance).trim()
const payload: typeof rest & { balance?: number } = { ...rest }
if (balance !== '') {
payload.balance = Number(balance)
}
await adminAPI.users.create(payload)
// 创建管理员属敏感操作:后端返回 STEP_UP_REQUIRED 时弹 TOTP 验证并重试
await stepUp.run(() => adminAPI.users.create(payload))
appStore.showSuccess(t('admin.users.userCreated'))
emit('success'); emit('close')
},
successMsg: t('admin.users.userCreated')
})
} catch (e: any) {
if (isStepUpCancelled(e)) {
// 用户主动取消二次验证:静默返回,表单保持打开。
} else if (isStepUpBlocked(e)) {
appStore.showError(
stepUpBlockReason(e) === 'STEP_UP_ADMIN_API_KEY_FORBIDDEN'
? t('stepUp.adminApiKeyForbidden')
: t('stepUp.notEnabled')
)
} else {
appStore.showError(e?.message || t('admin.users.failedToCreate'))
}
} finally { loading.value = false }
}
watch(() => props.show, (v) => { if(v) Object.assign(form, { email: '', password: '', username: '', notes: '', role: 'user', balance: '', concurrency: 1, rpm_limit: 0 }) })
@@ -67,6 +67,9 @@
</div>
</template>
</BaseDialog>
<!-- 角色提升为管理员时后端要求 step-up 2FA弹出 TOTP 验证后自动重试 -->
<TotpStepUpDialog :controller="stepUp" />
</template>
<script setup lang="ts">
@@ -79,6 +82,8 @@ import type { AdminUser, UserAttributeValuesMap } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import UserAttributeForm from '@/components/user/UserAttributeForm.vue'
import Icon from '@/components/icons/Icon.vue'
import { useStepUp, isStepUpBlocked, isStepUpCancelled, stepUpBlockReason } from '@/composables/useStepUp'
import TotpStepUpDialog from '@/components/auth/TotpStepUpDialog.vue'
const props = defineProps<{ show: boolean, user: AdminUser | null }>()
const emit = defineEmits(['close', 'success'])
@@ -104,6 +109,8 @@ const copyPassword = async () => {
passwordCopied.value = true; setTimeout(() => passwordCopied.value = false, 2000)
}
}
const stepUp = useStepUp()
const handleUpdateUser = async () => {
if (!props.user) return
if (!form.email.trim()) {
@@ -114,16 +121,28 @@ const handleUpdateUser = async () => {
appStore.showError(t('admin.users.concurrencyMin'))
return
}
const userId = props.user.id
submitting.value = true
try {
const data: any = { email: form.email, username: form.username, notes: form.notes, role: form.role, concurrency: form.concurrency, rpm_limit: form.rpm_limit }
if (form.password.trim()) data.password = form.password.trim()
await adminAPI.users.update(props.user.id, data)
if (Object.keys(form.customAttributes).length > 0) await adminAPI.userAttributes.updateUserAttributeValues(props.user.id, form.customAttributes)
// 提升为管理员属敏感操作:后端返回 STEP_UP_REQUIRED 时弹 TOTP 验证并重试
await stepUp.run(() => adminAPI.users.update(userId, data))
if (Object.keys(form.customAttributes).length > 0) await adminAPI.userAttributes.updateUserAttributeValues(userId, form.customAttributes)
appStore.showSuccess(t('admin.users.userUpdated'))
emit('success'); emit('close')
} catch (e: any) {
appStore.showError(e.response?.data?.detail || t('admin.users.failedToUpdate'))
if (isStepUpCancelled(e)) {
// 用户主动取消二次验证:静默返回,表单保持打开。
} else if (isStepUpBlocked(e)) {
appStore.showError(
stepUpBlockReason(e) === 'STEP_UP_ADMIN_API_KEY_FORBIDDEN'
? t('stepUp.adminApiKeyForbidden')
: t('stepUp.notEnabled')
)
} else {
appStore.showError(e?.message || t('admin.users.failedToUpdate'))
}
} finally { submitting.value = false }
}
</script>
+467 -176
View File
@@ -1,150 +1,304 @@
<template>
<div class="space-y-6">
<!-- Page header -->
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ t('admin.audit.title') }}</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ t('admin.audit.description') }}</p>
</div>
<button type="button" class="btn btn-danger btn-sm" @click="openClearDialog">
{{ t('admin.audit.clearAll') }}
</button>
</div>
<AppLayout>
<TablePageLayout>
<!-- Filters -->
<template #filters>
<div class="card p-4 sm:p-6">
<div class="flex flex-wrap items-end justify-between gap-4">
<!-- Left: filter fields -->
<div class="flex flex-1 flex-wrap items-end gap-4">
<div class="w-full sm:w-auto sm:min-w-[240px]">
<label class="input-label">{{ t('admin.audit.filters.q') }}</label>
<div class="relative">
<Icon
name="search"
size="md"
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
/>
<input
v-model.trim="filters.q"
type="text"
class="input pl-10"
:placeholder="t('admin.audit.filters.qPlaceholder')"
@keyup.enter="search"
/>
</div>
</div>
<!-- Filters -->
<div class="card p-4">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<label class="flex flex-col gap-1">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.audit.filters.q') }}</span>
<input v-model.trim="filters.q" class="input" :placeholder="t('admin.audit.filters.qPlaceholder')" @keyup.enter="search" />
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.audit.filters.actorEmail') }}</span>
<input v-model.trim="filters.actor_email" class="input" @keyup.enter="search" />
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.audit.filters.action') }}</span>
<input v-model.trim="filters.action" class="input" @keyup.enter="search" />
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.audit.filters.clientIp') }}</span>
<input v-model.trim="filters.client_ip" class="input" @keyup.enter="search" />
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.audit.filters.method') }}</span>
<Select v-model="filters.method" :options="methodOptions" />
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.audit.filters.authMethod') }}</span>
<Select v-model="filters.auth_method" :options="authMethodOptions" />
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.audit.filters.result') }}</span>
<Select v-model="filters.success" :options="resultOptions" />
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.audit.filters.startTime') }}</span>
<input v-model="filters.start_time" type="datetime-local" class="input" />
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.audit.filters.endTime') }}</span>
<input v-model="filters.end_time" type="datetime-local" class="input" />
</label>
</div>
<div class="mt-3 flex gap-2">
<button type="button" class="btn btn-primary btn-sm" :disabled="loading" @click="search">
{{ t('common.search') }}
</button>
<button type="button" class="btn btn-secondary btn-sm" :disabled="loading" @click="resetFilters">
{{ t('common.reset') }}
</button>
</div>
</div>
<div class="w-full sm:w-auto sm:min-w-[200px]">
<label class="input-label">{{ t('admin.audit.filters.actorEmail') }}</label>
<input v-model.trim="filters.actor_email" type="text" class="input" @keyup.enter="search" />
</div>
<!-- Table -->
<div class="card overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
<thead class="bg-gray-50 dark:bg-dark-900/40">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.audit.columns.time') }}</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.audit.columns.actor') }}</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.audit.columns.action') }}</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.audit.columns.method') }}</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.audit.columns.result') }}</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500">{{ t('admin.audit.columns.clientIp') }}</th>
<th class="px-4 py-3 text-right text-xs font-medium uppercase text-gray-500">{{ t('admin.audit.columns.detail') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-dark-700">
<tr v-if="loading">
<td colspan="7" class="px-4 py-8 text-center text-sm text-gray-500">{{ t('common.loading') }}</td>
</tr>
<tr v-else-if="logs.length === 0">
<td colspan="7" class="px-4 py-8 text-center text-sm text-gray-500">{{ t('admin.audit.empty') }}</td>
</tr>
<tr v-for="log in logs" :key="log.id" class="hover:bg-gray-50 dark:hover:bg-dark-800/60">
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-700 dark:text-gray-300">{{ formatTime(log.created_at) }}</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
<div class="font-medium">{{ log.actor_email || '—' }}</div>
<div class="text-xs text-gray-400">{{ log.actor_role }}<span v-if="log.auth_method"> · {{ log.auth_method }}</span></div>
</td>
<td class="px-4 py-3 text-sm font-mono text-gray-700 dark:text-gray-300">{{ log.action }}</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500">{{ log.method }}</td>
<td class="whitespace-nowrap px-4 py-3 text-sm">
<span :class="statusBadgeClass(log.status_code)">{{ log.status_code }}</span>
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500">{{ log.client_ip || '—' }}</td>
<td class="whitespace-nowrap px-4 py-3 text-right text-sm">
<button type="button" class="text-primary-600 hover:underline dark:text-primary-400" @click="openDetail(log.id)">
{{ t('admin.audit.columns.detail') }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="border-t border-gray-200 px-4 py-3 dark:border-dark-700">
<div class="w-full sm:w-auto sm:min-w-[180px]">
<label class="input-label">{{ t('admin.audit.filters.action') }}</label>
<input v-model.trim="filters.action" type="text" class="input" @keyup.enter="search" />
</div>
<div class="w-full sm:w-auto sm:min-w-[160px]">
<label class="input-label">{{ t('admin.audit.filters.clientIp') }}</label>
<input v-model.trim="filters.client_ip" type="text" class="input" @keyup.enter="search" />
</div>
<div class="w-full sm:w-auto sm:min-w-[140px]">
<label class="input-label">{{ t('admin.audit.filters.method') }}</label>
<Select v-model="filters.method" :options="methodOptions" @change="search" />
</div>
<div class="w-full sm:w-auto sm:min-w-[170px]">
<label class="input-label">{{ t('admin.audit.filters.authMethod') }}</label>
<Select v-model="filters.auth_method" :options="authMethodOptions" @change="search" />
</div>
<div class="w-full sm:w-auto sm:min-w-[140px]">
<label class="input-label">{{ t('admin.audit.filters.result') }}</label>
<Select v-model="filters.success" :options="resultOptions" @change="search" />
</div>
<div class="w-full sm:w-auto sm:min-w-[170px]">
<label class="input-label">{{ t('admin.dashboard.timeRange') }}</label>
<Select
:model-value="timeRange"
:options="timeRangeOptions"
@update:model-value="handleTimeRangeChange"
/>
</div>
</div>
<!-- Right: actions -->
<div class="flex w-full flex-wrap items-center justify-end gap-3 sm:w-auto">
<button type="button" class="btn btn-primary" :disabled="loading" @click="search">
{{ t('common.search') }}
</button>
<button type="button" class="btn btn-secondary" :disabled="loading" @click="resetFilters">
{{ t('common.reset') }}
</button>
<button type="button" class="btn btn-danger" @click="openClearDialog">
<Icon name="trash" size="sm" class="mr-1.5" />
{{ t('admin.audit.clearAll') }}
</button>
</div>
</div>
</div>
</template>
<!-- Table -->
<template #table>
<DataTable :columns="columns" :data="logs" :loading="loading" row-key="id">
<template #cell-created_at="{ value }">
<span class="whitespace-nowrap text-gray-600 dark:text-gray-300">{{ formatTime(value) }}</span>
</template>
<template #cell-actor="{ row }">
<div class="min-w-0 max-w-[220px]">
<div class="truncate font-medium text-gray-900 dark:text-white" :title="row.actor_email">
{{ row.actor_email || '—' }}
</div>
<div class="mt-0.5 truncate text-xs text-gray-400">
{{ row.actor_role }}<span v-if="row.auth_method"> · {{ authMethodLabel(row.auth_method) }}</span>
</div>
</div>
</template>
<template #cell-action="{ row }">
<div class="min-w-0 max-w-xs">
<div class="truncate font-mono text-sm text-gray-800 dark:text-gray-200" :title="row.action">
{{ row.action }}
</div>
<div class="mt-0.5 truncate font-mono text-xs text-gray-400" :title="`${row.method} ${row.path}`">
{{ row.method }} {{ row.path }}
</div>
</div>
</template>
<template #cell-status_code="{ row }">
<span :class="statusBadgeClass(row.status_code)">
<span class="h-1.5 w-1.5 rounded-full" :class="statusDotClass(row.status_code)"></span>
{{ row.status_code }}
</span>
</template>
<template #cell-latency_ms="{ value }">
<span class="whitespace-nowrap text-gray-500 dark:text-gray-400">{{ value }} ms</span>
</template>
<template #cell-client_ip="{ value }">
<span class="whitespace-nowrap font-mono text-gray-600 dark:text-gray-300">{{ value || '—' }}</span>
</template>
<template #cell-actions="{ row }">
<button
type="button"
class="inline-flex items-center gap-1 font-medium text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
@click="openDetail(row.id)"
>
<Icon name="eye" size="sm" />
{{ t('admin.audit.columns.detail') }}
</button>
</template>
<template #empty>
<div class="flex flex-col items-center py-8">
<Icon name="shield" size="xl" class="mb-4 h-12 w-12 text-gray-300 dark:text-dark-600" />
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t('admin.audit.empty') }}</p>
</div>
</template>
</DataTable>
</template>
<!-- Pagination -->
<template #pagination>
<Pagination
v-if="total > 0"
:total="total"
:page="page"
:page-size="pageSize"
@update:page="onPageChange"
@update:pageSize="onPageSizeChange"
/>
</div>
</div>
</template>
</TablePageLayout>
<!-- Detail dialog -->
<BaseDialog :show="detailVisible" :title="t('admin.audit.detail.title')" width="wide" @close="detailVisible = false">
<div v-if="detailLoading" class="py-8 text-center text-sm text-gray-500">{{ t('common.loading') }}</div>
<div v-else-if="detail" class="space-y-3 text-sm">
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<DetailRow :label="t('admin.audit.columns.time')" :value="formatTime(detail.created_at)" />
<DetailRow :label="t('admin.audit.columns.actor')" :value="detail.actor_email || '—'" />
<DetailRow :label="t('admin.audit.detail.actorRole')" :value="detail.actor_role" />
<DetailRow :label="t('admin.audit.filters.authMethod')" :value="detail.auth_method" />
<DetailRow :label="t('admin.audit.columns.action')" :value="detail.action" mono />
<DetailRow :label="t('admin.audit.detail.methodPath')" :value="`${detail.method} ${detail.path}`" mono />
<DetailRow :label="t('admin.audit.columns.result')" :value="String(detail.status_code)" />
<DetailRow :label="t('admin.audit.detail.latency')" :value="`${detail.latency_ms} ms`" />
<DetailRow :label="t('admin.audit.columns.clientIp')" :value="detail.client_ip || '—'" />
<DetailRow :label="t('admin.audit.detail.requestId')" :value="detail.request_id || '—'" mono />
<DetailRow :label="t('admin.audit.detail.credential')" :value="detail.credential_masked || '—'" mono />
</div>
<div>
<div class="mb-1 text-xs font-medium text-gray-500">{{ t('admin.audit.detail.userAgent') }}</div>
<div class="break-all rounded bg-gray-50 p-2 font-mono text-xs text-gray-600 dark:bg-dark-900/40 dark:text-gray-400">{{ detail.user_agent || '—' }}</div>
</div>
<div v-if="detail.request_body">
<div class="mb-1 text-xs font-medium text-gray-500">{{ t('admin.audit.detail.requestBody') }}</div>
<pre class="max-h-72 overflow-auto rounded bg-gray-50 p-3 font-mono text-xs text-gray-600 dark:bg-dark-900/40 dark:text-gray-400">{{ prettyBody(detail.request_body) }}</pre>
</div>
<div v-if="detail.extra && Object.keys(detail.extra).length">
<div class="mb-1 text-xs font-medium text-gray-500">{{ t('admin.audit.detail.extra') }}</div>
<pre class="max-h-48 overflow-auto rounded bg-gray-50 p-3 font-mono text-xs text-gray-600 dark:bg-dark-900/40 dark:text-gray-400">{{ JSON.stringify(detail.extra, null, 2) }}</pre>
<BaseDialog
:show="detailVisible"
:title="t('admin.audit.detail.title')"
width="wide"
:close-on-click-outside="true"
@close="detailVisible = false"
>
<div v-if="detailLoading" class="flex items-center justify-center py-16">
<div class="flex flex-col items-center gap-3">
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-primary-600"></div>
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t('common.loading') }}</div>
</div>
</div>
<div v-else-if="detail" class="space-y-5 py-2">
<!-- Hero: action + result at a glance -->
<div class="rounded-2xl border border-gray-200 bg-gray-50/60 p-5 dark:border-dark-700 dark:bg-dark-900/60">
<div class="flex flex-wrap items-center gap-3">
<span :class="statusBadgeClass(detail.status_code)">
<span class="h-1.5 w-1.5 rounded-full" :class="statusDotClass(detail.status_code)"></span>
{{ detail.status_code }} {{ statusText(detail.status_code) }}
</span>
<span class="break-all font-mono text-base font-semibold text-gray-900 dark:text-white">
{{ detail.action }}
</span>
</div>
<div class="mt-3 flex items-center gap-2 rounded-lg bg-white px-3 py-2 ring-1 ring-gray-200 dark:bg-dark-800 dark:ring-dark-600">
<span class="rounded bg-gray-100 px-1.5 py-0.5 font-mono text-[11px] font-bold text-gray-700 dark:bg-dark-700 dark:text-gray-200">
{{ detail.method }}
</span>
<span class="break-all font-mono text-xs text-gray-600 dark:text-gray-300">{{ detail.path }}</span>
</div>
<div class="mt-3 flex flex-wrap items-center gap-x-5 gap-y-1.5 text-xs text-gray-500 dark:text-gray-400">
<span class="inline-flex items-center gap-1.5">
<Icon name="clock" size="xs" />
{{ formatTime(detail.created_at) }}
</span>
<span>{{ t('admin.audit.detail.latency') }} {{ detail.latency_ms }} ms</span>
<span v-if="detail.request_id" class="inline-flex items-center gap-1">
{{ t('admin.audit.detail.requestId') }}
<span class="break-all font-mono">{{ detail.request_id }}</span>
</span>
</div>
</div>
<!-- Actor / auth / source -->
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">
{{ t('admin.audit.columns.actor') }}
</div>
<div class="mt-1 break-all text-sm font-medium text-gray-900 dark:text-white">
{{ detail.actor_email || '—' }}
</div>
<div class="mt-0.5 text-xs text-gray-400">{{ detail.actor_role }}</div>
</div>
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">
{{ t('admin.audit.filters.authMethod') }}
</div>
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
{{ authMethodLabel(detail.auth_method) || '—' }}
</div>
<div v-if="detail.credential_masked" class="mt-0.5 break-all font-mono text-xs text-gray-400">
{{ detail.credential_masked }}
</div>
</div>
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">
{{ t('admin.audit.columns.clientIp') }}
</div>
<div class="mt-1 break-all font-mono text-sm font-medium text-gray-900 dark:text-white">
{{ detail.client_ip || '—' }}
</div>
</div>
</div>
<!-- User-Agent -->
<section>
<h4 class="mb-1.5 text-xs font-bold uppercase tracking-wider text-gray-400">
{{ t('admin.audit.detail.userAgent') }}
</h4>
<div class="break-all rounded-xl bg-gray-50 p-3 font-mono text-xs leading-relaxed text-gray-600 dark:bg-dark-900 dark:text-gray-400">
{{ detail.user_agent || '—' }}
</div>
</section>
<!-- Request body (redacted) -->
<section v-if="detail.request_body">
<h4 class="mb-1.5 text-xs font-bold uppercase tracking-wider text-gray-400">
{{ t('admin.audit.detail.requestBody') }}
</h4>
<pre class="max-h-72 overflow-auto rounded-xl bg-gray-50 p-4 font-mono text-xs leading-relaxed text-gray-600 dark:bg-dark-900 dark:text-gray-400">{{ prettyBody(detail.request_body) }}</pre>
</section>
<!-- Extra -->
<section v-if="detail.extra && Object.keys(detail.extra).length">
<h4 class="mb-1.5 text-xs font-bold uppercase tracking-wider text-gray-400">
{{ t('admin.audit.detail.extra') }}
</h4>
<pre class="max-h-48 overflow-auto rounded-xl bg-gray-50 p-4 font-mono text-xs leading-relaxed text-gray-600 dark:bg-dark-900 dark:text-gray-400">{{ JSON.stringify(detail.extra, null, 2) }}</pre>
</section>
</div>
</BaseDialog>
<!-- Custom time range dialog ( /admin/ops 时间下拉一致的自定义范围支持时分) -->
<BaseDialog
:show="showCustomTimeRangeDialog"
:title="t('admin.ops.timeRange.custom')"
width="narrow"
@close="handleCustomTimeRangeCancel"
>
<div class="space-y-4 py-2">
<div>
<label class="input-label">{{ t('admin.ops.customTimeRange.startTime') }}</label>
<input v-model="customStartTimeInput" type="datetime-local" class="input" />
</div>
<div>
<label class="input-label">{{ t('admin.ops.customTimeRange.endTime') }}</label>
<input v-model="customEndTimeInput" type="datetime-local" class="input" />
</div>
</div>
<template #footer>
<button type="button" class="btn btn-secondary" @click="handleCustomTimeRangeCancel">
{{ t('common.cancel') }}
</button>
<button
type="button"
class="btn btn-primary"
:disabled="!customStartTimeInput || !customEndTimeInput"
@click="handleCustomTimeRangeConfirm"
>
{{ t('common.confirm') }}
</button>
</template>
</BaseDialog>
<!-- Clear confirmation step-up TOTP -->
@@ -159,55 +313,63 @@
@cancel="clearConfirmVisible = false"
/>
<!-- Reused: TOTP prompt for the clear operation -->
<div v-if="clearTotpVisible" class="fixed inset-0 z-[60] overflow-y-auto">
<div class="flex min-h-full items-center justify-center p-4">
<div class="fixed inset-0 bg-black/50" @click="cancelClearTotp"></div>
<div class="relative w-full max-w-md rounded-xl bg-white p-6 shadow-xl dark:bg-dark-800">
<h3 class="mb-2 text-center text-lg font-semibold text-gray-900 dark:text-white">{{ t('admin.audit.clearConfirm.totpTitle') }}</h3>
<p class="mb-4 text-center text-sm text-gray-500 dark:text-gray-400">{{ t('admin.audit.clearConfirm.totpHint') }}</p>
<input
v-model.trim="clearTotpCode"
type="text"
inputmode="numeric"
maxlength="6"
autocomplete="one-time-code"
class="input mb-4 text-center text-lg tracking-[0.5em]"
placeholder="••••••"
@keyup.enter="submitClear"
/>
<div class="flex gap-2">
<button type="button" class="btn btn-secondary flex-1" :disabled="clearing" @click="cancelClearTotp">{{ t('common.cancel') }}</button>
<button type="button" class="btn btn-danger flex-1" :disabled="clearing || clearTotpCode.length !== 6" @click="submitClear">
{{ clearing ? t('common.loading') : t('admin.audit.clearAll') }}
</button>
</div>
</div>
<!-- TOTP prompt for the clear operation -->
<BaseDialog
:show="clearTotpVisible"
:title="t('admin.audit.clearConfirm.totpTitle')"
width="narrow"
:z-index="60"
@close="cancelClearTotp"
>
<div class="py-2">
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('admin.audit.clearConfirm.totpHint') }}</p>
<input
v-model.trim="clearTotpCode"
type="text"
inputmode="numeric"
maxlength="6"
autocomplete="one-time-code"
class="input mt-4 text-center text-lg tracking-[0.5em]"
placeholder="••••••"
@keyup.enter="submitClear"
/>
</div>
</div>
</div>
<template #footer>
<button type="button" class="btn btn-secondary" :disabled="clearing" @click="cancelClearTotp">
{{ t('common.cancel') }}
</button>
<button
type="button"
class="btn btn-danger"
:disabled="clearing || clearTotpCode.length !== 6"
@click="submitClear"
>
{{ clearing ? t('common.loading') : t('admin.audit.clearAll') }}
</button>
</template>
</BaseDialog>
</AppLayout>
</template>
<script setup lang="ts">
import { computed, h, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminAPI, type AuditLog } from '@/api/admin'
import { totpAPI } from '@/api'
import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import DataTable from '@/components/common/DataTable.vue'
import type { Column } from '@/components/common/types'
import Pagination from '@/components/common/Pagination.vue'
import Select from '@/components/common/Select.vue'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Icon from '@/components/icons/Icon.vue'
import { useAppStore } from '@/stores'
const { t } = useI18n()
const appStore = useAppStore()
// Tiny inline label/value row for the detail dialog.
const DetailRow = (props: { label: string; value: string; mono?: boolean }) =>
h('div', { class: 'flex flex-col gap-0.5' }, [
h('span', { class: 'text-xs text-gray-400' }, props.label),
h('span', { class: ['text-gray-700 dark:text-gray-200 break-all', props.mono ? 'font-mono text-xs' : ''] }, props.value)
])
const loading = ref(false)
const logs = ref<AuditLog[]>([])
const total = ref(0)
@@ -221,11 +383,96 @@ const filters = reactive({
client_ip: '',
method: '',
auth_method: '',
success: '',
start_time: '',
end_time: ''
success: ''
})
// 时间范围:预设窗口(同 /admin/ops 时间下拉)+ 自定义起止(datetime-local,支持时分)
const timeRange = ref('')
const customStartTime = ref('')
const customEndTime = ref('')
const showCustomTimeRangeDialog = ref(false)
const customStartTimeInput = ref('')
const customEndTimeInput = ref('')
const TIME_RANGE_MINUTES: Record<string, number> = {
'30m': 30,
'1h': 60,
'6h': 6 * 60,
'24h': 24 * 60,
'7d': 7 * 24 * 60,
'30d': 30 * 24 * 60
}
const timeRangeOptions = computed(() => [
{ value: '', label: t('admin.audit.filters.all') },
{ value: '30m', label: t('admin.ops.timeRange.30m') },
{ value: '1h', label: t('admin.ops.timeRange.1h') },
{ value: '6h', label: t('admin.ops.timeRange.6h') },
{ value: '24h', label: t('admin.ops.timeRange.24h') },
{ value: '7d', label: t('admin.ops.timeRange.7d') },
{ value: '30d', label: t('admin.ops.timeRange.30d') },
{
value: 'custom',
label:
timeRange.value === 'custom' && customStartTime.value && customEndTime.value
? `${t('admin.ops.timeRange.custom')} (${formatCustomTimeRangeLabel(customStartTime.value, customEndTime.value)})`
: t('admin.ops.timeRange.custom')
}
])
function formatCustomTimeRangeLabel(startTime: string, endTime: string): string {
const fmt = (raw: string) => {
const d = new Date(raw)
if (Number.isNaN(d.getTime())) return raw
const pad = (n: number) => String(n).padStart(2, '0')
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
return `${fmt(startTime)} ~ ${fmt(endTime)}`
}
function toDatetimeLocal(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function handleTimeRangeChange(val: string | number | boolean | null) {
const value = String(val ?? '')
if (value === 'custom') {
// 预填:已有自定义值沿用,否则默认最近1小时(本地时区)
const now = new Date()
customStartTimeInput.value = customStartTime.value || toDatetimeLocal(new Date(now.getTime() - 60 * 60 * 1000))
customEndTimeInput.value = customEndTime.value || toDatetimeLocal(now)
showCustomTimeRangeDialog.value = true
return
}
timeRange.value = value
search()
}
function handleCustomTimeRangeConfirm() {
if (!customStartTimeInput.value || !customEndTimeInput.value) return
customStartTime.value = customStartTimeInput.value
customEndTime.value = customEndTimeInput.value
timeRange.value = 'custom'
showCustomTimeRangeDialog.value = false
search()
}
function handleCustomTimeRangeCancel() {
// 未确认不改变当前时间范围;Select 是受控组件,展示值保持不变。
showCustomTimeRangeDialog.value = false
}
const columns = computed<Column[]>(() => [
{ key: 'created_at', label: t('admin.audit.columns.time') },
{ key: 'actor', label: t('admin.audit.columns.actor') },
{ key: 'action', label: t('admin.audit.columns.action') },
{ key: 'status_code', label: t('admin.audit.columns.result') },
{ key: 'latency_ms', label: t('admin.audit.detail.latency') },
{ key: 'client_ip', label: t('admin.audit.columns.clientIp') },
{ key: 'actions', label: t('common.actions') }
])
const methodOptions = computed(() => [
{ value: '', label: t('admin.audit.filters.all') },
{ value: 'POST', label: 'POST' },
@@ -247,6 +494,11 @@ const resultOptions = computed(() => [
{ value: 'false', label: t('admin.audit.filters.resultFailure') }
])
function authMethodLabel(method: string): string {
const found = authMethodOptions.value.find((o) => o.value === method)
return found && found.value ? found.label : method
}
function toRFC3339(local: string): string | undefined {
if (!local) return undefined
const d = new Date(local)
@@ -254,6 +506,18 @@ function toRFC3339(local: string): string | undefined {
return d.toISOString()
}
function buildTimeRangeQuery(): { start_time?: string; end_time?: string } {
if (timeRange.value === 'custom') {
return {
start_time: toRFC3339(customStartTime.value),
end_time: toRFC3339(customEndTime.value)
}
}
const minutes = TIME_RANGE_MINUTES[timeRange.value]
if (!minutes) return {}
return { start_time: new Date(Date.now() - minutes * 60 * 1000).toISOString() }
}
function buildQuery() {
return {
page: page.value,
@@ -265,8 +529,7 @@ function buildQuery() {
method: filters.method || undefined,
auth_method: filters.auth_method || undefined,
success: filters.success || undefined,
start_time: toRFC3339(filters.start_time),
end_time: toRFC3339(filters.end_time)
...buildTimeRangeQuery()
}
}
@@ -296,8 +559,9 @@ function resetFilters() {
filters.method = ''
filters.auth_method = ''
filters.success = ''
filters.start_time = ''
filters.end_time = ''
timeRange.value = ''
customStartTime.value = ''
customEndTime.value = ''
search()
}
@@ -344,8 +608,25 @@ const clearConfirmVisible = ref(false)
const clearTotpVisible = ref(false)
const clearTotpCode = ref('')
const clearing = ref(false)
const checkingTotpStatus = ref(false)
function openClearDialog() {
// 与其他敏感操作一致:未启用 2FA 时直接提示去个人资料启用 TOTP,
// 而不是弹出一个无法完成的验证码输入框(后端会以 TOTP_NOT_SETUP 拒绝)。
async function openClearDialog() {
if (checkingTotpStatus.value) return
checkingTotpStatus.value = true
try {
const status = await totpAPI.getStatus()
if (!status.enabled) {
appStore.showError(t('stepUp.notEnabled'))
return
}
} catch (err: any) {
appStore.showError(err?.message || t('admin.audit.loadFailed'))
return
} finally {
checkingTotpStatus.value = false
}
clearConfirmVisible.value = true
}
@@ -383,12 +664,22 @@ function formatTime(iso: string): string {
return d.toLocaleString()
}
function statusText(status: number): string {
return status < 400 ? t('admin.audit.filters.resultSuccess') : t('admin.audit.filters.resultFailure')
}
function statusBadgeClass(status: number): string {
const base = 'inline-flex rounded-full px-2 py-0.5 text-xs font-medium '
const base = 'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-semibold '
if (status >= 500) return base + 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
if (status >= 400) return base + 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'
return base + 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
}
function statusDotClass(status: number): string {
if (status >= 500) return 'bg-red-500'
if (status >= 400) return 'bg-amber-500'
return 'bg-green-500'
}
onMounted(fetchLogs)
</script>