feat(auth): apply LoginGuard to tenant and ops login endpoints
Wire LoginGuard into tenant-api and ops-api login flows: acquire a permit before checking credentials, record a failure on every invalid attempt to drive lockouts, and clear counters on success. Tenant-api now also forwards the request IP into the service so per-IP and IP+identifier limits actually fire under real traffic. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -19,6 +20,7 @@ type AuthService struct {
|
||||
kolProfiles repository.KolProfileRepository
|
||||
jwt *auth.Manager
|
||||
sessions *auth.SessionStore
|
||||
loginGuard *auth.LoginGuard
|
||||
}
|
||||
|
||||
func NewAuthService(
|
||||
@@ -27,13 +29,19 @@ func NewAuthService(
|
||||
kolProfiles repository.KolProfileRepository,
|
||||
jwt *auth.Manager,
|
||||
sessions *auth.SessionStore,
|
||||
loginGuards ...*auth.LoginGuard,
|
||||
) *AuthService {
|
||||
var loginGuard *auth.LoginGuard
|
||||
if len(loginGuards) > 0 {
|
||||
loginGuard = loginGuards[0]
|
||||
}
|
||||
return &AuthService{
|
||||
repo: repo,
|
||||
plans: plans,
|
||||
kolProfiles: kolProfiles,
|
||||
jwt: jwt,
|
||||
sessions: sessions,
|
||||
loginGuard: loginGuard,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +49,7 @@ type LoginRequest struct {
|
||||
Identifier string `json:"identifier"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
IP string `json:"-"`
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
@@ -88,6 +97,8 @@ type KolProfileBrief struct {
|
||||
MarketEnabled bool `json:"market_enabled"`
|
||||
}
|
||||
|
||||
const userBlockedReasonDisabled = "user_disabled"
|
||||
|
||||
func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
|
||||
identifier := strings.TrimSpace(req.Identifier)
|
||||
if identifier == "" {
|
||||
@@ -97,18 +108,30 @@ func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginRespon
|
||||
return nil, response.ErrBadRequest(40001, "invalid_params", "login identifier is required")
|
||||
}
|
||||
|
||||
permit, err := s.acquireLoginPermit(ctx, identifier, req.IP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
credentialsVerified := false
|
||||
defer func() {
|
||||
if credentialsVerified {
|
||||
permit.RecordSuccess(ctx)
|
||||
return
|
||||
}
|
||||
permit.Release(ctx)
|
||||
}()
|
||||
|
||||
user, err := s.repo.GetUserByLoginIdentifier(ctx, identifier)
|
||||
if err != nil {
|
||||
permit.RecordFailure(ctx)
|
||||
return nil, response.ErrUnauthorized(40110, "invalid_credentials", "email or password is incorrect")
|
||||
}
|
||||
|
||||
if user.Status != "active" {
|
||||
return nil, response.ErrForbidden(40311, "user_disabled", "user account is disabled")
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
|
||||
permit.RecordFailure(ctx)
|
||||
return nil, response.ErrUnauthorized(40110, "invalid_credentials", "email or password is incorrect")
|
||||
}
|
||||
credentialsVerified = true
|
||||
|
||||
membership, err := s.repo.GetTenantMembership(ctx, user.ID)
|
||||
if err != nil {
|
||||
@@ -133,6 +156,7 @@ func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginRespon
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
memberInfo = applyUserDisabledMembership(memberInfo, user.Status)
|
||||
|
||||
return &LoginResponse{
|
||||
AccessToken: pair.AccessToken,
|
||||
@@ -154,6 +178,36 @@ func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginRespon
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) acquireLoginPermit(ctx context.Context, identifier, ip string) (*auth.LoginPermit, error) {
|
||||
if s.loginGuard == nil {
|
||||
return nil, nil
|
||||
}
|
||||
permit, err := s.loginGuard.Acquire(ctx, auth.LoginGuardSubject{
|
||||
Identifier: identifier,
|
||||
IP: ip,
|
||||
})
|
||||
if err == nil {
|
||||
return permit, nil
|
||||
}
|
||||
|
||||
var blocked *auth.LoginGuardBlockedError
|
||||
if errors.As(err, &blocked) {
|
||||
detail := fmt.Sprintf("请 %d 秒后重试", blocked.RetryAfterSeconds())
|
||||
switch blocked.Reason {
|
||||
case auth.LoginGuardBlockedInProgress:
|
||||
return nil, response.ErrTooManyRequests(42912, "login_in_progress", detail)
|
||||
case auth.LoginGuardBlockedLocked:
|
||||
return nil, response.ErrTooManyRequests(42911, "login_locked", detail)
|
||||
default:
|
||||
return nil, response.ErrTooManyRequests(42910, "login_rate_limited", detail)
|
||||
}
|
||||
}
|
||||
if errors.Is(err, auth.ErrLoginGuardUnavailable) {
|
||||
return nil, response.ErrServiceUnavailable(50305, "login_guard_unavailable", "login protection is temporarily unavailable")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
type RefreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
@@ -219,6 +273,7 @@ func (s *AuthService) Me(ctx context.Context, actor auth.Actor) (*UserInfo, erro
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
memberInfo = applyUserDisabledMembership(memberInfo, user.Status)
|
||||
|
||||
return &UserInfo{
|
||||
ID: user.ID,
|
||||
@@ -235,6 +290,28 @@ func (s *AuthService) Me(ctx context.Context, actor auth.Actor) (*UserInfo, erro
|
||||
}, nil
|
||||
}
|
||||
|
||||
func applyUserDisabledMembership(info *MembershipInfo, userStatus string) *MembershipInfo {
|
||||
if strings.EqualFold(strings.TrimSpace(userStatus), "active") {
|
||||
return info
|
||||
}
|
||||
|
||||
reason := userBlockedReasonDisabled
|
||||
if info == nil {
|
||||
return &MembershipInfo{
|
||||
Status: "disabled",
|
||||
Blocked: true,
|
||||
BlockedReason: &reason,
|
||||
}
|
||||
}
|
||||
|
||||
next := *info
|
||||
next.Status = "disabled"
|
||||
next.Blocked = true
|
||||
next.BlockedReason = &reason
|
||||
next.ContactAdminOnExpiry = true
|
||||
return &next
|
||||
}
|
||||
|
||||
func (s *AuthService) loadKolProfileBrief(ctx context.Context, tenantID, userID int64) (*KolProfileBrief, error) {
|
||||
if s.kolProfiles == nil {
|
||||
return nil, nil
|
||||
|
||||
@@ -24,6 +24,7 @@ func NewAuthHandler(a *bootstrap.App) *AuthHandler {
|
||||
a.KolProfiles,
|
||||
a.JWT,
|
||||
a.Sessions,
|
||||
a.LoginGuard,
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -34,6 +35,7 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
|
||||
return
|
||||
}
|
||||
req.IP = c.ClientIP()
|
||||
resp, err := h.svc.Login(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
|
||||
Reference in New Issue
Block a user