Make product API versions independently installable

This commit is contained in:
Stephan D committed 2026-09-13 10:26:06 +02:00
1 parent ce6583f454
commit d04c8b12ab
69 files changed
+755 -404

No files matched your search

@@ -0,0 +1,253 @@
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/http/v1/request"
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
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.factors != 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)
}