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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user