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:
@@ -17,6 +17,7 @@ import (
|
|||||||
opsconfig "github.com/geo-platform/tenant-api/internal/ops/config"
|
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/repository"
|
||||||
"github.com/geo-platform/tenant-api/internal/ops/transport"
|
"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/cache"
|
||||||
"github.com/geo-platform/tenant-api/internal/shared/observability"
|
"github.com/geo-platform/tenant-api/internal/shared/observability"
|
||||||
"github.com/geo-platform/tenant-api/internal/shared/repository/postgres"
|
"github.com/geo-platform/tenant-api/internal/shared/repository/postgres"
|
||||||
@@ -75,7 +76,8 @@ func main() {
|
|||||||
|
|
||||||
auditSvc := app.NewAuditService(auditsRepo, logger).WithIPRegionResolver(ipRegionResolver)
|
auditSvc := app.NewAuditService(auditsRepo, logger).WithIPRegionResolver(ipRegionResolver)
|
||||||
issuer := app.NewTokenIssuer(cfg.JWT.Secret, cfg.JWT.AccessTTL)
|
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)
|
accountSvc := app.NewAccountService(accountsRepo, auditSvc)
|
||||||
adminUserSvc := app.NewAdminUserService(adminUsersRepo, auditSvc, cfg.AdminUsers.DefaultPlanCode)
|
adminUserSvc := app.NewAdminUserService(adminUsersRepo, auditSvc, cfg.AdminUsers.DefaultPlanCode)
|
||||||
kolSubscriptionSvc := app.NewKolSubscriptionService(kolSubscriptionsRepo, auditSvc).WithCache(appCache)
|
kolSubscriptionSvc := app.NewKolSubscriptionService(kolSubscriptionsRepo, auditSvc).WithCache(appCache)
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ type App struct {
|
|||||||
Engine *gin.Engine
|
Engine *gin.Engine
|
||||||
JWT *auth.Manager
|
JWT *auth.Manager
|
||||||
Sessions *auth.SessionStore
|
Sessions *auth.SessionStore
|
||||||
|
LoginGuard *auth.LoginGuard
|
||||||
LLM llm.Client
|
LLM llm.Client
|
||||||
RetrievalProvider retrieval.Provider
|
RetrievalProvider retrieval.Provider
|
||||||
VectorStore retrieval.VectorStore
|
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)
|
jwtMgr := auth.NewManager(cfg.JWT.Secret, cfg.JWT.AccessTTL, cfg.JWT.RefreshTTL)
|
||||||
sessions := auth.NewSessionStore(rdb)
|
sessions := auth.NewSessionStore(rdb)
|
||||||
|
loginGuard := auth.NewLoginGuard(rdb, auth.DefaultLoginGuardConfig("tenant"))
|
||||||
llmClient := llm.New(cfg.LLM)
|
llmClient := llm.New(cfg.LLM)
|
||||||
retrievalProvider := retrieval.NewProvider(cfg.Retrieval, logger)
|
retrievalProvider := retrieval.NewProvider(cfg.Retrieval, logger)
|
||||||
vectorStore := retrieval.NewQdrantStore(cfg.Qdrant, logger)
|
vectorStore := retrieval.NewQdrantStore(cfg.Qdrant, logger)
|
||||||
@@ -171,6 +173,7 @@ func New(configPath string) (*App, error) {
|
|||||||
Engine: engine,
|
Engine: engine,
|
||||||
JWT: jwtMgr,
|
JWT: jwtMgr,
|
||||||
Sessions: sessions,
|
Sessions: sessions,
|
||||||
|
LoginGuard: loginGuard,
|
||||||
LLM: llmClient,
|
LLM: llmClient,
|
||||||
RetrievalProvider: retrievalProvider,
|
RetrievalProvider: retrievalProvider,
|
||||||
VectorStore: vectorStore,
|
VectorStore: vectorStore,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"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/domain"
|
||||||
"github.com/geo-platform/tenant-api/internal/ops/repository"
|
"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"
|
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -85,10 +87,20 @@ type AuthService struct {
|
|||||||
accounts *repository.AccountRepository
|
accounts *repository.AccountRepository
|
||||||
audits *AuditService
|
audits *AuditService
|
||||||
issuer *TokenIssuer
|
issuer *TokenIssuer
|
||||||
|
guard *sharedauth.LoginGuard
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuthService(accounts *repository.AccountRepository, audits *AuditService, issuer *TokenIssuer) *AuthService {
|
func NewAuthService(
|
||||||
return &AuthService{accounts: accounts, audits: audits, issuer: issuer}
|
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 {
|
type LoginInput struct {
|
||||||
@@ -131,21 +143,43 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (s *AuthService) Login(ctx context.Context, in LoginInput) (*LoginResult, error) {
|
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)
|
account, err := s.accounts.GetByUsername(ctx, in.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
permit.RecordFailure(ctx)
|
||||||
s.recordLoginFailure(ctx, in, "account_not_found")
|
s.recordLoginFailure(ctx, in, "account_not_found")
|
||||||
return nil, response.ErrUnauthorized(40110, "invalid_credentials", "账号或密码错误")
|
return nil, response.ErrUnauthorized(40110, "invalid_credentials", "账号或密码错误")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !account.IsActive() {
|
if !account.IsActive() {
|
||||||
|
permit.RecordFailure(ctx)
|
||||||
s.recordLoginFailure(ctx, in, "account_disabled")
|
s.recordLoginFailure(ctx, in, "account_disabled")
|
||||||
return nil, response.ErrForbidden(40310, "account_disabled", "账号已停用")
|
return nil, response.ErrForbidden(40310, "account_disabled", "账号已停用")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(account.PasswordHash), []byte(in.Password)); err != nil {
|
if err := bcrypt.CompareHashAndPassword([]byte(account.PasswordHash), []byte(in.Password)); err != nil {
|
||||||
|
permit.RecordFailure(ctx)
|
||||||
s.recordLoginFailure(ctx, in, "wrong_password")
|
s.recordLoginFailure(ctx, in, "wrong_password")
|
||||||
return nil, response.ErrUnauthorized(40110, "invalid_credentials", "账号或密码错误")
|
return nil, response.ErrUnauthorized(40110, "invalid_credentials", "账号或密码错误")
|
||||||
}
|
}
|
||||||
|
credentialsVerified = true
|
||||||
|
|
||||||
token, err := s.issuer.Issue(account)
|
token, err := s.issuer.Issue(account)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -174,6 +208,36 @@ func (s *AuthService) Login(ctx context.Context, in LoginInput) (*LoginResult, e
|
|||||||
}, nil
|
}, 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) {
|
func (s *AuthService) recordLoginFailure(ctx context.Context, in LoginInput, reason string) {
|
||||||
_ = s.audits.Append(ctx, domain.AuditEvent{
|
_ = s.audits.Append(ctx, domain.AuditEvent{
|
||||||
Action: ActionLoginFailed,
|
Action: ActionLoginFailed,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -19,6 +20,7 @@ type AuthService struct {
|
|||||||
kolProfiles repository.KolProfileRepository
|
kolProfiles repository.KolProfileRepository
|
||||||
jwt *auth.Manager
|
jwt *auth.Manager
|
||||||
sessions *auth.SessionStore
|
sessions *auth.SessionStore
|
||||||
|
loginGuard *auth.LoginGuard
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuthService(
|
func NewAuthService(
|
||||||
@@ -27,13 +29,19 @@ func NewAuthService(
|
|||||||
kolProfiles repository.KolProfileRepository,
|
kolProfiles repository.KolProfileRepository,
|
||||||
jwt *auth.Manager,
|
jwt *auth.Manager,
|
||||||
sessions *auth.SessionStore,
|
sessions *auth.SessionStore,
|
||||||
|
loginGuards ...*auth.LoginGuard,
|
||||||
) *AuthService {
|
) *AuthService {
|
||||||
|
var loginGuard *auth.LoginGuard
|
||||||
|
if len(loginGuards) > 0 {
|
||||||
|
loginGuard = loginGuards[0]
|
||||||
|
}
|
||||||
return &AuthService{
|
return &AuthService{
|
||||||
repo: repo,
|
repo: repo,
|
||||||
plans: plans,
|
plans: plans,
|
||||||
kolProfiles: kolProfiles,
|
kolProfiles: kolProfiles,
|
||||||
jwt: jwt,
|
jwt: jwt,
|
||||||
sessions: sessions,
|
sessions: sessions,
|
||||||
|
loginGuard: loginGuard,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +49,7 @@ type LoginRequest struct {
|
|||||||
Identifier string `json:"identifier"`
|
Identifier string `json:"identifier"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Password string `json:"password" binding:"required"`
|
Password string `json:"password" binding:"required"`
|
||||||
|
IP string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoginResponse struct {
|
type LoginResponse struct {
|
||||||
@@ -88,6 +97,8 @@ type KolProfileBrief struct {
|
|||||||
MarketEnabled bool `json:"market_enabled"`
|
MarketEnabled bool `json:"market_enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userBlockedReasonDisabled = "user_disabled"
|
||||||
|
|
||||||
func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
|
func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
|
||||||
identifier := strings.TrimSpace(req.Identifier)
|
identifier := strings.TrimSpace(req.Identifier)
|
||||||
if 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")
|
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)
|
user, err := s.repo.GetUserByLoginIdentifier(ctx, identifier)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
permit.RecordFailure(ctx)
|
||||||
return nil, response.ErrUnauthorized(40110, "invalid_credentials", "email or password is incorrect")
|
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 {
|
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")
|
return nil, response.ErrUnauthorized(40110, "invalid_credentials", "email or password is incorrect")
|
||||||
}
|
}
|
||||||
|
credentialsVerified = true
|
||||||
|
|
||||||
membership, err := s.repo.GetTenantMembership(ctx, user.ID)
|
membership, err := s.repo.GetTenantMembership(ctx, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -133,6 +156,7 @@ func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginRespon
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
memberInfo = applyUserDisabledMembership(memberInfo, user.Status)
|
||||||
|
|
||||||
return &LoginResponse{
|
return &LoginResponse{
|
||||||
AccessToken: pair.AccessToken,
|
AccessToken: pair.AccessToken,
|
||||||
@@ -154,6 +178,36 @@ func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginRespon
|
|||||||
}, nil
|
}, 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 {
|
type RefreshRequest struct {
|
||||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
memberInfo = applyUserDisabledMembership(memberInfo, user.Status)
|
||||||
|
|
||||||
return &UserInfo{
|
return &UserInfo{
|
||||||
ID: user.ID,
|
ID: user.ID,
|
||||||
@@ -235,6 +290,28 @@ func (s *AuthService) Me(ctx context.Context, actor auth.Actor) (*UserInfo, erro
|
|||||||
}, nil
|
}, 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) {
|
func (s *AuthService) loadKolProfileBrief(ctx context.Context, tenantID, userID int64) (*KolProfileBrief, error) {
|
||||||
if s.kolProfiles == nil {
|
if s.kolProfiles == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ func NewAuthHandler(a *bootstrap.App) *AuthHandler {
|
|||||||
a.KolProfiles,
|
a.KolProfiles,
|
||||||
a.JWT,
|
a.JWT,
|
||||||
a.Sessions,
|
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()))
|
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
req.IP = c.ClientIP()
|
||||||
resp, err := h.svc.Login(c.Request.Context(), req)
|
resp, err := h.svc.Login(c.Request.Context(), req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(c, err)
|
response.Error(c, err)
|
||||||
|
|||||||
Reference in New Issue
Block a user