diff --git a/server/cmd/ops-api/main.go b/server/cmd/ops-api/main.go index 64c6975..290dcff 100644 --- a/server/cmd/ops-api/main.go +++ b/server/cmd/ops-api/main.go @@ -17,6 +17,7 @@ import ( opsconfig "github.com/geo-platform/tenant-api/internal/ops/config" "github.com/geo-platform/tenant-api/internal/ops/repository" "github.com/geo-platform/tenant-api/internal/ops/transport" + sharedauth "github.com/geo-platform/tenant-api/internal/shared/auth" "github.com/geo-platform/tenant-api/internal/shared/cache" "github.com/geo-platform/tenant-api/internal/shared/observability" "github.com/geo-platform/tenant-api/internal/shared/repository/postgres" @@ -75,7 +76,8 @@ func main() { auditSvc := app.NewAuditService(auditsRepo, logger).WithIPRegionResolver(ipRegionResolver) issuer := app.NewTokenIssuer(cfg.JWT.Secret, cfg.JWT.AccessTTL) - authSvc := app.NewAuthService(accountsRepo, auditSvc, issuer) + 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) kolSubscriptionSvc := app.NewKolSubscriptionService(kolSubscriptionsRepo, auditSvc).WithCache(appCache) diff --git a/server/internal/bootstrap/bootstrap.go b/server/internal/bootstrap/bootstrap.go index 9dc627b..8a6ab31 100644 --- a/server/internal/bootstrap/bootstrap.go +++ b/server/internal/bootstrap/bootstrap.go @@ -40,6 +40,7 @@ type App struct { Engine *gin.Engine JWT *auth.Manager Sessions *auth.SessionStore + LoginGuard *auth.LoginGuard LLM llm.Client RetrievalProvider retrieval.Provider VectorStore retrieval.VectorStore @@ -104,6 +105,7 @@ func New(configPath string) (*App, error) { jwtMgr := auth.NewManager(cfg.JWT.Secret, cfg.JWT.AccessTTL, cfg.JWT.RefreshTTL) sessions := auth.NewSessionStore(rdb) + loginGuard := auth.NewLoginGuard(rdb, auth.DefaultLoginGuardConfig("tenant")) llmClient := llm.New(cfg.LLM) retrievalProvider := retrieval.NewProvider(cfg.Retrieval, logger) vectorStore := retrieval.NewQdrantStore(cfg.Qdrant, logger) @@ -171,6 +173,7 @@ func New(configPath string) (*App, error) { Engine: engine, JWT: jwtMgr, Sessions: sessions, + LoginGuard: loginGuard, LLM: llmClient, RetrievalProvider: retrievalProvider, VectorStore: vectorStore, diff --git a/server/internal/ops/app/auth.go b/server/internal/ops/app/auth.go index 3b1fe64..452d5d8 100644 --- a/server/internal/ops/app/auth.go +++ b/server/internal/ops/app/auth.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "time" "github.com/golang-jwt/jwt/v5" @@ -12,6 +13,7 @@ import ( "github.com/geo-platform/tenant-api/internal/ops/domain" "github.com/geo-platform/tenant-api/internal/ops/repository" + sharedauth "github.com/geo-platform/tenant-api/internal/shared/auth" "github.com/geo-platform/tenant-api/internal/shared/response" ) @@ -85,10 +87,20 @@ type AuthService struct { accounts *repository.AccountRepository audits *AuditService issuer *TokenIssuer + guard *sharedauth.LoginGuard } -func NewAuthService(accounts *repository.AccountRepository, audits *AuditService, issuer *TokenIssuer) *AuthService { - return &AuthService{accounts: accounts, audits: audits, issuer: issuer} +func NewAuthService( + accounts *repository.AccountRepository, + audits *AuditService, + issuer *TokenIssuer, + guards ...*sharedauth.LoginGuard, +) *AuthService { + var guard *sharedauth.LoginGuard + if len(guards) > 0 { + guard = guards[0] + } + return &AuthService{accounts: accounts, audits: audits, issuer: issuer, guard: guard} } type LoginInput struct { @@ -131,21 +143,43 @@ const ( ) func (s *AuthService) Login(ctx context.Context, in LoginInput) (*LoginResult, error) { + in.Username = strings.TrimSpace(in.Username) + if in.Username == "" { + return nil, response.ErrBadRequest(40000, "invalid_payload", "username is required") + } + + permit, err := s.acquireLoginPermit(ctx, in) + if err != nil { + return nil, err + } + credentialsVerified := false + defer func() { + if credentialsVerified { + permit.RecordSuccess(ctx) + return + } + permit.Release(ctx) + }() + account, err := s.accounts.GetByUsername(ctx, in.Username) if err != nil { + permit.RecordFailure(ctx) s.recordLoginFailure(ctx, in, "account_not_found") return nil, response.ErrUnauthorized(40110, "invalid_credentials", "账号或密码错误") } if !account.IsActive() { + permit.RecordFailure(ctx) s.recordLoginFailure(ctx, in, "account_disabled") return nil, response.ErrForbidden(40310, "account_disabled", "账号已停用") } if err := bcrypt.CompareHashAndPassword([]byte(account.PasswordHash), []byte(in.Password)); err != nil { + permit.RecordFailure(ctx) s.recordLoginFailure(ctx, in, "wrong_password") return nil, response.ErrUnauthorized(40110, "invalid_credentials", "账号或密码错误") } + credentialsVerified = true token, err := s.issuer.Issue(account) if err != nil { @@ -174,6 +208,36 @@ func (s *AuthService) Login(ctx context.Context, in LoginInput) (*LoginResult, e }, nil } +func (s *AuthService) acquireLoginPermit(ctx context.Context, in LoginInput) (*sharedauth.LoginPermit, error) { + if s.guard == nil { + return nil, nil + } + permit, err := s.guard.Acquire(ctx, sharedauth.LoginGuardSubject{ + Identifier: in.Username, + IP: in.IP, + }) + if err == nil { + return permit, nil + } + + var blocked *sharedauth.LoginGuardBlockedError + if errors.As(err, &blocked) { + detail := fmt.Sprintf("请 %d 秒后重试", blocked.RetryAfterSeconds()) + switch blocked.Reason { + case sharedauth.LoginGuardBlockedInProgress: + return nil, response.ErrTooManyRequests(42912, "login_in_progress", detail) + case sharedauth.LoginGuardBlockedLocked: + return nil, response.ErrTooManyRequests(42911, "login_locked", detail) + default: + return nil, response.ErrTooManyRequests(42910, "login_rate_limited", detail) + } + } + if errors.Is(err, sharedauth.ErrLoginGuardUnavailable) { + return nil, response.ErrServiceUnavailable(50305, "login_guard_unavailable", "login protection is temporarily unavailable") + } + return nil, err +} + func (s *AuthService) recordLoginFailure(ctx context.Context, in LoginInput, reason string) { _ = s.audits.Append(ctx, domain.AuditEvent{ Action: ActionLoginFailed, diff --git a/server/internal/tenant/app/auth_service.go b/server/internal/tenant/app/auth_service.go index 66bd04f..b37f40f 100644 --- a/server/internal/tenant/app/auth_service.go +++ b/server/internal/tenant/app/auth_service.go @@ -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 diff --git a/server/internal/tenant/transport/auth_handler.go b/server/internal/tenant/transport/auth_handler.go index 3b85dd4..c450304 100644 --- a/server/internal/tenant/transport/auth_handler.go +++ b/server/internal/tenant/transport/auth_handler.go @@ -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)