package public import ( "context" "crypto/subtle" "encoding/json" "errors" "net/http" "strings" clientauth "git.sendico.io/tech/sxchnge/product/auth/client" "git.sendico.io/tech/sxchnge/product/auth/ipguard" "git.sendico.io/tech/sxchnge/product/auth/request" "git.sendico.io/tech/sxchnge/product/auth/response" customermodel "git.sendico.io/tech/sxchnge/product/customers/model" sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model" "git.sendico.io/tech/sxchnge/shared/errors" "git.sendico.io/tech/sxchnge/shared/http/response" "git.sendico.io/tech/sxchnge/shared/logging" "git.sendico.io/tech/sxchnge/shared/logging/mask" "git.sendico.io/tech/sxchnge/shared/service" "go.uber.org/zap" ) const pendingLoginTTLMinutes = 10 const apiLoginGrantType = "password" const apiLoginClientAuthMethod = "client_secret_post" const dummyPasswordHash = "$2y$10$pmvNFN5zyooml6Oqh.cGFenBF/indHDE.dyLwjNJW1.Tk2Suvdvve" func (pr *PublicRouter) authenticateAccount(ctx context.Context, req *srequest.Login) (*customermodel.Account, bool, http.HandlerFunc) { // Get the account database entry trimmedLogin := strings.TrimSpace(req.Login) account, err := pr.db.GetByEmail(ctx, strings.ToLower(trimmedLogin)) if errors.Is(err, merrors.ErrNoData) || (err == nil && account == nil) { (&customermodel.Account{Password: dummyPasswordHash}).MatchPassword(req.Password) pr.logger.Debug("Login rejected: invalid credentials") return nil, true, response.Unauthorized(pr.logger, pr.service, "invalid credentials") } if err != nil { pr.logger.Warn("Failed to query user during login", zap.Error(err)) return nil, false, response.Internal(pr.logger, pr.service, err) } if !account.MatchPassword(req.Password) { pr.logger.Debug("Login rejected: invalid credentials") return nil, true, response.Unauthorized(pr.logger, pr.service, "invalid credentials") } if !account.IsActive() || account.IsArchived() { return nil, false, response.Forbidden(pr.logger, pr.service, "account_not_verified", "Account verification required") } return account, false, nil } func (pr *PublicRouter) respondPendingLogin(account *customermodel.Account) http.HandlerFunc { pendingToken, err := pr.imp.CreatePendingToken(account, pendingLoginTTLMinutes) if err != nil { pr.logger.Warn("Failed to generate pending token", zap.Error(err)) return response.Internal(pr.logger, pr.service, err) } return sresponse.LoginPending(pr.logger, account, &pendingToken, mask.Email(account.Login)) } func hasGrantType(grants []string, target string) bool { for _, grant := range grants { if strings.EqualFold(strings.TrimSpace(grant), target) { return true } } return false } func (pr *PublicRouter) validateClientIPPolicy(r *http.Request, clientID string, client *sessionmodel.Client) http.HandlerFunc { if client == nil { pr.logger.Debug("Client not found, rejecting authorization", zap.String("client_id", clientID)) return response.Unauthorized(pr.logger, pr.service, clientauth.ErrUnknownClientID.Error()) } clientIP := ipguard.ClientIP(r) allowed, err := ipguard.Allowed(clientIP, client.AllowedCIDRs) if err != nil { pr.logger.Warn("Client IP policy contains invalid CIDR", zap.Error(err), zap.String("client_id", clientID)) return response.Forbidden(pr.logger, pr.service, "client_ip_policy_invalid", "client ip policy is invalid") } if !allowed { rawIP := "" if clientIP != nil { rawIP = clientIP.String() } pr.logger.Info("Client IP policy denied request", zap.String("client_id", clientID), zap.String("remote_ip", rawIP)) return response.Forbidden(pr.logger, pr.service, "ip_not_allowed", "request ip is not allowed for this client") } return nil } func (pr *PublicRouter) loadClient(ctx context.Context, clientID string) (*sessionmodel.Client, http.HandlerFunc) { client, err := clientauth.Resolve(ctx, pr.rtdb, clientID) if errors.Is(err, clientauth.ErrUnknownClientID) { pr.logger.Debug("Login rejected: unknown client id", zap.String("client_id", clientID)) return nil, response.Unauthorized(pr.logger, pr.service, clientauth.ErrUnknownClientID.Error()) } if err != nil { pr.logger.Warn("Login rejected: failed to load client", zap.Error(err), zap.String("client_id", clientID)) return nil, response.Internal(pr.logger, pr.service, err) } return client, nil } func (pr *PublicRouter) validateClientAccountBinding(client *sessionmodel.Client, account *customermodel.Account) http.HandlerFunc { if client == nil || client.AccountRef == nil { return nil } accountRef := account.GetID() if accountRef == nil || *client.AccountRef != *accountRef { return response.Forbidden(pr.logger, pr.service, "client_account_mismatch", "client is bound to another account") } return nil } func (pr *PublicRouter) validateClientState(r *http.Request, clientID string, client *sessionmodel.Client) http.HandlerFunc { if client == nil { return response.Unauthorized(pr.logger, pr.service, clientauth.ErrUnknownClientID.Error()) } if client.IsRevoked { return response.Forbidden(pr.logger, pr.service, "client_revoked", "client has been revoked") } if h := pr.validateClientIPPolicy(r, clientID, client); h != nil { return h } return nil } func (pr *PublicRouter) validateAPIClient(ctx context.Context, r *http.Request, req *srequest.Login, account *customermodel.Account) http.HandlerFunc { client, h := pr.loadClient(ctx, req.ClientID) if h != nil { return h } if h := pr.validateClientState(r, req.ClientID, client); h != nil { return h } if !hasGrantType(client.GrantTypes, apiLoginGrantType) { return response.Forbidden(pr.logger, pr.service, "client_grant_not_allowed", "client does not allow password grant") } method := strings.ToLower(strings.TrimSpace(client.TokenEndpointAuthMethod)) if method == "" { method = apiLoginClientAuthMethod } if method != apiLoginClientAuthMethod { return response.Forbidden(pr.logger, pr.service, "client_auth_method_unsupported", "unsupported client auth method") } storedSecret := strings.TrimSpace(client.ClientSecret) if storedSecret == "" { return response.Forbidden(pr.logger, pr.service, "client_secret_missing", "client secret is not configured") } if subtle.ConstantTimeCompare([]byte(storedSecret), []byte(req.ClientSecret)) != 1 { pr.logger.Debug("API login rejected: invalid client secret", zap.String("client_id", req.ClientID)) return response.Unauthorized(pr.logger, pr.service, "invalid client secret") } if h := pr.validateClientAccountBinding(client, account); h != nil { return h } return nil } func (pr *PublicRouter) resolveLoginClient(ctx context.Context, r *http.Request, clientID string) (*sessionmodel.Client, http.HandlerFunc) { clientID = strings.TrimSpace(clientID) if clientID == "" { return nil, nil } client, h := pr.loadClient(ctx, clientID) if h != nil { return nil, h } if h := pr.validateClientState(r, clientID, client); h != nil { return nil, h } return client, nil } func (pr *PublicRouter) respondAPILogin(ctx context.Context, r *http.Request, req *srequest.Login, account *customermodel.Account) http.HandlerFunc { if req.ClientID == "" || req.DeviceID == "" { return response.BadRequest(pr.logger, pr.service, "missing_session", "session identifier is required") } return pr.refreshAndRespondLogin(ctx, r, &req.SessionIdentifier, account) } func decodeLogin(r *http.Request, logger mlogger.Logger) (*srequest.Login, http.HandlerFunc) { var req srequest.Login if err := json.NewDecoder(r.Body).Decode(&req); err != nil { logger.Info("Failed to decode login request", zap.Error(err)) return nil, response.BadPayload(logger, mservice.Accounts, err) } req.Login = strings.TrimSpace(req.Login) // Preserve passwords exactly as entered. req.ClientID = strings.TrimSpace(req.ClientID) req.DeviceID = strings.TrimSpace(req.DeviceID) req.ClientSecret = strings.TrimSpace(req.ClientSecret) if req.Login == "" { return nil, response.BadRequest(logger, mservice.Accounts, "email_missing", "login request has no user name") } if req.Password == "" { return nil, response.BadRequest(logger, mservice.Accounts, "password_missing", "login request has no password") } return &req, nil } func (a *PublicRouter) login(r *http.Request) http.HandlerFunc { req, h := decodeLogin(r, a.logger) if h != nil { return h } client, h := a.resolveLoginClient(r.Context(), r, req.ClientID) if h != nil { return h } account, h := a.authenticateRateLimited(r, req) if h != nil { return h } if h = a.validateClientAccountBinding(client, account); h != nil { return h } if a.flow != nil { return a.beginLogin(r, account, req.SessionIdentifier, randomKey()) } return a.respondPendingLogin(account) } func (a *PublicRouter) apiLogin(r *http.Request) http.HandlerFunc { req, h := decodeLogin(r, a.logger) if h != nil { return h } if req.ClientID == "" { return response.BadRequest(a.logger, mservice.Accounts, "client_id_missing", "clientId is required") } if req.ClientSecret == "" { return response.BadRequest(a.logger, mservice.Accounts, "client_secret_missing", "clientSecret is required") } account, h := a.authenticateRateLimited(r, req) if h != nil { return h } if h = a.validateAPIClient(r.Context(), r, req, account); h != nil { return h } return a.respondAPILogin(r.Context(), r, req, account) }