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:
2026-04-30 01:07:16 +08:00
parent a62c0e4b5d
commit 19037a9e4b
5 changed files with 155 additions and 7 deletions
+66 -2
View File
@@ -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,