feat: improve login UX and add admin login lock reset
- Translate all login error messages to Chinese (network errors, HTTP status codes, URL validation errors) - Add password visibility toggle (eye icon) to login form - Add live countdown timer when login is locked, disable login button during cooldown, auto-clear error when countdown ends - Add X button red hover feedback in server settings dialog - Add LoginGuard.Unlock() method to clear Redis lock/failure keys - Expose POST /admin-users/:id/reset-login-lock endpoint in ops API - Add "重置登录保护" button in ops-web user management table Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -81,7 +81,8 @@ func main() {
|
||||
loginGuard := sharedauth.NewLoginGuard(rdb, sharedauth.DefaultLoginGuardConfig("ops"))
|
||||
authSvc := app.NewAuthService(accountsRepo, auditSvc, issuer, loginGuard)
|
||||
accountSvc := app.NewAccountService(accountsRepo, auditSvc)
|
||||
adminUserSvc := app.NewAdminUserService(adminUsersRepo, auditSvc, cfg.AdminUsers.DefaultPlanCode)
|
||||
tenantLoginGuard := sharedauth.NewLoginGuard(rdb, sharedauth.DefaultLoginGuardConfig("tenant"))
|
||||
adminUserSvc := app.NewAdminUserService(adminUsersRepo, auditSvc, cfg.AdminUsers.DefaultPlanCode).WithLoginGuard(tenantLoginGuard)
|
||||
kolSubscriptionSvc := app.NewKolSubscriptionService(kolSubscriptionsRepo, auditSvc).WithCache(appCache)
|
||||
siteDomainMappingSvc := app.NewSiteDomainMappingService(siteDomainMappingsRepo, auditSvc)
|
||||
|
||||
|
||||
@@ -28,7 +28,8 @@ const (
|
||||
ActionAdminUserSetKOL = "admin_user.set_kol"
|
||||
ActionAdminUserDisable = "admin_user.disable"
|
||||
ActionAdminUserEnable = "admin_user.enable"
|
||||
ActionAdminUserResetPassword = "admin_user.reset_password"
|
||||
ActionAdminUserResetPassword = "admin_user.reset_password"
|
||||
ActionAdminUserResetLoginLock = "admin_user.reset_login_lock"
|
||||
)
|
||||
|
||||
var phonePattern = regexp.MustCompile(`^\+?[0-9]{6,20}$`)
|
||||
@@ -36,6 +37,7 @@ var phonePattern = regexp.MustCompile(`^\+?[0-9]{6,20}$`)
|
||||
type AdminUserService struct {
|
||||
users *repository.AdminUserRepository
|
||||
audits *AuditService
|
||||
loginGuard *sharedauth.LoginGuard
|
||||
configMu sync.RWMutex
|
||||
defaultPlanCode string
|
||||
}
|
||||
@@ -52,6 +54,11 @@ func NewAdminUserService(users *repository.AdminUserRepository, audits *AuditSer
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AdminUserService) WithLoginGuard(g *sharedauth.LoginGuard) *AdminUserService {
|
||||
s.loginGuard = g
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *AdminUserService) UpdateDefaultPlanCode(defaultPlanCode string) {
|
||||
if s == nil {
|
||||
return
|
||||
@@ -596,6 +603,26 @@ func (s *AdminUserService) ResetPassword(ctx context.Context, actor *Actor, id i
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AdminUserService) ResetLoginLock(ctx context.Context, actor *Actor, id int64) error {
|
||||
user, err := s.users.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrAdminUserNotFound) {
|
||||
return response.ErrNotFound(40430, "user_not_found", "用户不存在")
|
||||
}
|
||||
return response.ErrInternal(50034, "query_failed", "failed to get user")
|
||||
}
|
||||
if s.loginGuard != nil {
|
||||
_ = s.loginGuard.Unlock(ctx, user.Phone)
|
||||
if user.Email != nil && *user.Email != "" {
|
||||
_ = s.loginGuard.Unlock(ctx, *user.Email)
|
||||
}
|
||||
}
|
||||
if actor != nil {
|
||||
_ = s.audits.Append(ctx, actor.audit(ActionAdminUserResetLoginLock, "admin_user", id, nil))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AdminUserService) mapWriteError(err error) error {
|
||||
if errors.Is(err, repository.ErrDefaultPlanNotFound) {
|
||||
return response.ErrInternal(50034, "default_plan_missing", "默认套餐未配置")
|
||||
|
||||
@@ -284,3 +284,18 @@ func resetAdminUserPasswordHandler(svc *app.AdminUserService) gin.HandlerFunc {
|
||||
response.Success(c, gin.H{"id": id})
|
||||
}
|
||||
}
|
||||
|
||||
func resetAdminUserLoginLockHandler(svc *app.AdminUserService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, err := parseIDParam(c)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
if err := svc.ResetLoginLock(c.Request.Context(), actorFromGin(c), id); err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"id": id})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ func RegisterRoutes(d Deps) {
|
||||
authed.POST("/admin-users/:id/kol", setAdminUserKOLHandler(d.AdminUsers))
|
||||
authed.POST("/admin-users/:id/status", setAdminUserStatusHandler(d.AdminUsers))
|
||||
authed.POST("/admin-users/:id/password", resetAdminUserPasswordHandler(d.AdminUsers))
|
||||
authed.POST("/admin-users/:id/reset-login-lock", resetAdminUserLoginLockHandler(d.AdminUsers))
|
||||
|
||||
authed.GET("/kol/packages", listKolSubscriptionPackagesHandler(d.KolSubs))
|
||||
authed.GET("/kol/subscriptions", listKolSubscriptionsHandler(d.KolSubs))
|
||||
|
||||
@@ -74,8 +74,8 @@ func DefaultLoginGuardConfig(scope string) LoginGuardConfig {
|
||||
RequestPairWindow: time.Minute,
|
||||
FailureIdentifierLimit: 8,
|
||||
FailurePairLimit: 5,
|
||||
FailureWindow: 15 * time.Minute,
|
||||
FailureLockBaseTTL: 5 * time.Minute,
|
||||
FailureWindow: 5 * time.Minute,
|
||||
FailureLockBaseTTL: 2 * time.Minute,
|
||||
FailureLockMaxTTL: time.Hour,
|
||||
ConcurrentAttemptLockTTL: 15 * time.Second,
|
||||
}
|
||||
@@ -83,11 +83,11 @@ func DefaultLoginGuardConfig(scope string) LoginGuardConfig {
|
||||
if strings.EqualFold(strings.TrimSpace(scope), "ops") {
|
||||
cfg.RequestIPLimit = 30
|
||||
cfg.RequestIdentifierLimit = 8
|
||||
cfg.RequestPairLimit = 5
|
||||
cfg.FailureIdentifierLimit = 5
|
||||
cfg.FailurePairLimit = 3
|
||||
cfg.FailureLockBaseTTL = 10 * time.Minute
|
||||
cfg.FailureLockMaxTTL = 2 * time.Hour
|
||||
cfg.RequestPairLimit = 6
|
||||
cfg.FailureIdentifierLimit = 10
|
||||
cfg.FailurePairLimit = 5
|
||||
cfg.FailureLockBaseTTL = 2 * time.Minute
|
||||
cfg.FailureLockMaxTTL = 30 * time.Minute
|
||||
}
|
||||
|
||||
return cfg
|
||||
@@ -256,6 +256,18 @@ func (p *LoginPermit) Release(ctx context.Context) {
|
||||
_, _ = loginGuardReleaseScript.Run(ctx, p.guard.rdb, []string{p.keys.inflightIdentifier}, p.token).Result()
|
||||
}
|
||||
|
||||
func (g *LoginGuard) Unlock(ctx context.Context, identifier string) error {
|
||||
if g == nil || !g.cfg.Enabled {
|
||||
return nil
|
||||
}
|
||||
keys := g.keysFor(LoginGuardSubject{Identifier: identifier})
|
||||
if g.rdb == nil {
|
||||
g.fallback.unlock(keys)
|
||||
return nil
|
||||
}
|
||||
return g.rdb.Del(ctx, keys.lockIdentifier, keys.failureIdentifier).Err()
|
||||
}
|
||||
|
||||
type loginGuardKeys struct {
|
||||
requestIP string
|
||||
requestIdentifier string
|
||||
@@ -582,6 +594,13 @@ func (g *memoryLoginGuard) bumpFailureLocked(failureKey, lockKey string, thresho
|
||||
g.locks[lockKey] = now.Add(lockTTL)
|
||||
}
|
||||
|
||||
func (g *memoryLoginGuard) unlock(keys loginGuardKeys) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
delete(g.locks, keys.lockIdentifier)
|
||||
delete(g.failures, keys.failureIdentifier)
|
||||
}
|
||||
|
||||
func (g *memoryLoginGuard) releaseLocked(key, token string) {
|
||||
current, ok := g.inflight[key]
|
||||
if !ok || current.token != token {
|
||||
|
||||
Reference in New Issue
Block a user