package app import ( "context" "errors" "fmt" "strings" "time" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "golang.org/x/crypto/bcrypt" "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" ) type OperatorClaims struct { OperatorID int64 `json:"operator_id"` Username string `json:"username"` DisplayName string `json:"display_name"` Role string `json:"role"` jwt.RegisteredClaims } type TokenIssuer struct { secret []byte accessTTL time.Duration } func NewTokenIssuer(secret string, accessTTL time.Duration) *TokenIssuer { return &TokenIssuer{secret: []byte(secret), accessTTL: accessTTL} } func (t *TokenIssuer) AccessTTL() time.Duration { return t.accessTTL } type IssuedToken struct { AccessToken string ExpiresAt int64 } func (t *TokenIssuer) Issue(operator *domain.OperatorAccount) (*IssuedToken, error) { now := time.Now() exp := now.Add(t.accessTTL) claims := OperatorClaims{ OperatorID: operator.ID, Username: operator.Username, DisplayName: operator.DisplayName, Role: operator.Role, RegisteredClaims: jwt.RegisteredClaims{ ID: uuid.New().String(), Subject: "ops-access", IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(exp), }, } signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(t.secret) if err != nil { return nil, fmt.Errorf("sign ops token: %w", err) } return &IssuedToken{AccessToken: signed, ExpiresAt: exp.Unix()}, nil } func (t *TokenIssuer) Parse(raw string) (*OperatorClaims, error) { token, err := jwt.ParseWithClaims(raw, &OperatorClaims{}, func(tok *jwt.Token) (any, error) { if _, ok := tok.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", tok.Header["alg"]) } return t.secret, nil }) if err != nil { return nil, err } claims, ok := token.Claims.(*OperatorClaims) if !ok || !token.Valid { return nil, errors.New("invalid ops token") } if claims.Subject != "ops-access" { return nil, errors.New("wrong token subject") } return claims, nil } type AuthService struct { accounts *repository.AccountRepository audits *AuditService issuer *TokenIssuer guard *sharedauth.LoginGuard } 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 { Username string Password string IP string UserAgent string RequestID string } type LoginResult struct { AccessToken string ExpiresAt int64 Operator OperatorView } type OperatorView struct { ID int64 `json:"id"` Username string `json:"username"` DisplayName string `json:"display_name"` Email *string `json:"email"` Role string `json:"role"` Status string `json:"status"` } func ToOperatorView(a *domain.OperatorAccount) OperatorView { return OperatorView{ ID: a.ID, Username: a.Username, DisplayName: a.DisplayName, Email: a.Email, Role: a.Role, Status: a.Status, } } const ( ActionLoginSuccess = "auth.login.success" ActionLoginFailed = "auth.login.failure" ) 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 { return nil, fmt.Errorf("issue token: %w", err) } if err := s.accounts.MarkLogin(ctx, account.ID); err != nil { // non-fatal — login already succeeded _ = err } id := account.ID _ = s.audits.Append(ctx, domain.AuditEvent{ OperatorID: &id, OperatorName: account.DisplayName, Action: ActionLoginSuccess, IP: in.IP, UserAgent: in.UserAgent, RequestID: in.RequestID, }) return &LoginResult{ AccessToken: token.AccessToken, ExpiresAt: token.ExpiresAt, Operator: ToOperatorView(account), }, 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, IP: in.IP, UserAgent: in.UserAgent, RequestID: in.RequestID, Metadata: map[string]any{ "username": in.Username, "reason": reason, }, }) }