Make product API versions independently installable
This commit is contained in:
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)
|
||||
}
|
||||
Reference in new issue
Block a user