From 800f8c12f8695d876a50e555187cd70becd89744 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Sat, 28 Feb 2026 10:07:52 +0100 Subject: [PATCH] api login method --- api/edge/bff/interface/api/srequest/login.go | 1 + api/edge/bff/interface/model/token.go | 19 ++- .../api/routers/authorized/handler.go | 47 +++++- .../internal/api/routers/authorized/router.go | 5 +- .../bff/internal/api/routers/dispatcher.go | 2 +- .../internal/api/routers/endpoint/token.go | 6 +- .../internal/api/routers/ipguard/ipguard.go | 64 +++++++ .../api/routers/ipguard/ipguard_test.go | 86 ++++++++++ .../bff/internal/api/routers/public/login.go | 158 ++++++++++++++++-- .../internal/api/routers/public/refresh.go | 4 + .../bff/internal/api/routers/public/rotate.go | 4 + .../bff/internal/api/routers/public/router.go | 1 + .../internal/api/routers/public/validate.go | 22 ++- .../internal/server/verificationimp/token.go | 4 +- .../internal/server/verificationimp/verify.go | 2 +- interface/api.yaml | 2 + interface/api/accounts/auth_login_api.yaml | 30 ++++ interface/api/accounts/bodies/auth.yaml | 6 + interface/api/accounts/request/auth.yaml | 16 ++ 19 files changed, 455 insertions(+), 24 deletions(-) create mode 100644 api/edge/bff/internal/api/routers/ipguard/ipguard.go create mode 100644 api/edge/bff/internal/api/routers/ipguard/ipguard_test.go create mode 100644 interface/api/accounts/auth_login_api.yaml diff --git a/api/edge/bff/interface/api/srequest/login.go b/api/edge/bff/interface/api/srequest/login.go index 4b91f508..c1f016d2 100644 --- a/api/edge/bff/interface/api/srequest/login.go +++ b/api/edge/bff/interface/api/srequest/login.go @@ -5,4 +5,5 @@ import "github.com/tech/sendico/pkg/model" type Login struct { model.SessionIdentifier `json:",inline"` model.LoginData `json:"login"` + ClientSecret string `json:"clientSecret,omitempty"` } diff --git a/api/edge/bff/interface/model/token.go b/api/edge/bff/interface/model/token.go index 57032ec6..79154ba9 100644 --- a/api/edge/bff/interface/model/token.go +++ b/api/edge/bff/interface/model/token.go @@ -16,16 +16,18 @@ type AccountToken struct { Login string Name string Locale string + ClientID string Expiration time.Time Pending bool } -func createAccountToken(a *model.Account, expiration int) AccountToken { +func createAccountToken(a *model.Account, expiration int, clientID string) AccountToken { return AccountToken{ AccountRef: *a.GetID(), Login: a.Login, Name: a.Name, Locale: a.Locale, + ClientID: clientID, Expiration: time.Now().Add(mduration.Param2Duration(expiration, time.Hour)), Pending: false, } @@ -45,6 +47,7 @@ const ( paramNameName = "name" paramNameLocale = "locale" paramNameLogin = "login" + paramNameClientID = "clientId" paramNameExpiration = "exp" paramNamePending = "pending" ) @@ -68,6 +71,11 @@ func Claims2Token(claims middleware.MapClaims) (*AccountToken, error) { if at.Locale, err = getTokenParam(claims, paramNameLocale); err != nil { return nil, err } + if clientID, ok := claims[paramNameClientID]; ok { + if clientIDText, ok := clientID.(string); ok { + at.ClientID = clientIDText + } + } if pending, ok := claims[paramNamePending]; ok { if pbool, ok := pending.(bool); ok { at.Pending = pbool @@ -91,19 +99,24 @@ func Claims2Token(claims middleware.MapClaims) (*AccountToken, error) { } func Account2Claims(a *model.Account, expiration int) middleware.MapClaims { - t := createAccountToken(a, expiration) + return Account2ClaimsForClient(a, expiration, "") +} + +func Account2ClaimsForClient(a *model.Account, expiration int, clientID string) middleware.MapClaims { + t := createAccountToken(a, expiration, clientID) return middleware.MapClaims{ paramNameID: t.AccountRef.Hex(), paramNameLogin: t.Login, paramNameName: t.Name, paramNameLocale: t.Locale, + paramNameClientID: t.ClientID, paramNameExpiration: int64(t.Expiration.Unix()), paramNamePending: t.Pending, } } func PendingAccount2Claims(a *model.Account, expirationMinutes int) middleware.MapClaims { - t := createAccountToken(a, expirationMinutes/60) + t := createAccountToken(a, expirationMinutes/60, "") t.Expiration = time.Now().Add(time.Duration(expirationMinutes) * time.Minute) t.Pending = true return middleware.MapClaims{ diff --git a/api/edge/bff/internal/api/routers/authorized/handler.go b/api/edge/bff/internal/api/routers/authorized/handler.go index 755859d7..8ccccd3e 100644 --- a/api/edge/bff/internal/api/routers/authorized/handler.go +++ b/api/edge/bff/internal/api/routers/authorized/handler.go @@ -3,6 +3,7 @@ package routers import ( "errors" "net/http" + "strings" "github.com/go-chi/jwtauth/v5" api "github.com/tech/sendico/pkg/api/http" @@ -13,11 +14,52 @@ import ( "github.com/tech/sendico/pkg/mutil/mzap" "github.com/tech/sendico/server/interface/api/sresponse" emodel "github.com/tech/sendico/server/interface/model" + "github.com/tech/sendico/server/internal/api/routers/ipguard" "go.uber.org/zap" ) type tokenHandlerFunc = func(r *http.Request, t *emodel.AccountToken) http.HandlerFunc +func (ar *AuthorizedRouter) validateClientPolicy(r *http.Request, t *emodel.AccountToken) http.HandlerFunc { + clientID := strings.TrimSpace(t.ClientID) + if clientID == "" { + // Legacy tokens without client_id remain valid until expiration. + return nil + } + client, err := ar.rtdb.GetClient(r.Context(), clientID) + if errors.Is(err, merrors.ErrNoData) || client == nil { + ar.logger.Debug("Client not found for access token", zap.String("client_id", clientID)) + return response.Unauthorized(ar.logger, ar.service, "client not found") + } + if err != nil { + ar.logger.Warn("Failed to resolve client for access token", zap.Error(err), zap.String("client_id", clientID)) + return response.Internal(ar.logger, ar.service, err) + } + if client.IsRevoked { + return response.Unauthorized(ar.logger, ar.service, "client has been revoked") + } + if client.AccountRef != nil && *client.AccountRef != t.AccountRef { + return response.Unauthorized(ar.logger, ar.service, "client account mismatch") + } + + clientIP := ipguard.ClientIP(r) + allowed, err := ipguard.Allowed(clientIP, client.AllowedCIDRs) + if err != nil { + ar.logger.Warn("Client IP policy contains invalid CIDR", zap.Error(err), zap.String("client_id", clientID)) + return response.Forbidden(ar.logger, ar.service, "client_ip_policy_invalid", "client ip policy is invalid") + } + if !allowed { + rawIP := "" + if clientIP != nil { + rawIP = clientIP.String() + } + ar.logger.Warn("Client IP policy denied authorized request", zap.String("client_id", clientID), zap.String("remote_ip", rawIP)) + return response.Forbidden(ar.logger, ar.service, "ip_not_allowed", "request ip is not allowed for this client") + } + + return nil +} + func (ar *AuthorizedRouter) tokenHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler tokenHandlerFunc) { hndlr := func(r *http.Request) http.HandlerFunc { _, claims, err := jwtauth.FromContext(r.Context()) @@ -30,6 +72,9 @@ func (ar *AuthorizedRouter) tokenHandler(service mservice.Type, endpoint string, ar.logger.Debug("Failed to decode account token", zap.Error(err)) return response.BadRequest(ar.logger, ar.service, "credentials_unreadable", "faild to parse credentials") } + if h := ar.validateClientPolicy(r, t); h != nil { + return h + } return handler(r, t) } ar.imp.InstallHandler(service, endpoint, method, hndlr) @@ -48,7 +93,7 @@ func (ar *AuthorizedRouter) AccountHandler(service mservice.Type, endpoint strin } return response.Internal(ar.logger, ar.service, err) } - accessToken, err := ar.imp.CreateAccessToken(&a) + accessToken, err := ar.imp.CreateAccessTokenForClient(&a, t.ClientID) if err != nil { ar.logger.Warn("Failed to generate access token", zap.Error(err)) return response.Internal(ar.logger, ar.service, err) diff --git a/api/edge/bff/internal/api/routers/authorized/router.go b/api/edge/bff/internal/api/routers/authorized/router.go index 7bd95968..22db9df1 100644 --- a/api/edge/bff/internal/api/routers/authorized/router.go +++ b/api/edge/bff/internal/api/routers/authorized/router.go @@ -5,6 +5,7 @@ import ( "github.com/go-chi/jwtauth/v5" "github.com/tech/sendico/pkg/auth" "github.com/tech/sendico/pkg/db/account" + "github.com/tech/sendico/pkg/db/refreshtokens" "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mservice" "github.com/tech/sendico/server/interface/middleware" @@ -14,11 +15,12 @@ import ( type AuthorizedRouter struct { logger mlogger.Logger db account.DB + rtdb refreshtokens.DB imp *re.HttpEndpointRouter service mservice.Type } -func NewRouter(logger mlogger.Logger, apiEndpoint string, router chi.Router, db account.DB, enforcer auth.Enforcer, config *middleware.TokenConfig, signature *middleware.Signature) *AuthorizedRouter { +func NewRouter(logger mlogger.Logger, apiEndpoint string, router chi.Router, db account.DB, rtdb refreshtokens.DB, enforcer auth.Enforcer, config *middleware.TokenConfig, signature *middleware.Signature) *AuthorizedRouter { ja := jwtauth.New(signature.Algorithm, signature.PrivateKey, signature.PublicKey) router.Use(jwtauth.Verifier(ja)) router.Use(jwtauth.Authenticator(ja)) @@ -26,6 +28,7 @@ func NewRouter(logger mlogger.Logger, apiEndpoint string, router chi.Router, db ar := AuthorizedRouter{ logger: l, db: db, + rtdb: rtdb, imp: re.NewHttpEndpointRouter(l, apiEndpoint, router, config, signature), service: mservice.Accounts, } diff --git a/api/edge/bff/internal/api/routers/dispatcher.go b/api/edge/bff/internal/api/routers/dispatcher.go index ca7c6dfc..b17a0df1 100644 --- a/api/edge/bff/internal/api/routers/dispatcher.go +++ b/api/edge/bff/internal/api/routers/dispatcher.go @@ -48,7 +48,7 @@ func NewDispatcher(logger mlogger.Logger, router chi.Router, db account.DB, vdb d.public = rpublic.NewRouter(d.logger, endpoint, db, vdb, rtdb, r, &config.Token, &signature) }) router.Group(func(r chi.Router) { - d.protected = rauthorized.NewRouter(d.logger, endpoint, r, db, enforcer, &config.Token, &signature) + d.protected = rauthorized.NewRouter(d.logger, endpoint, r, db, rtdb, enforcer, &config.Token, &signature) }) return d diff --git a/api/edge/bff/internal/api/routers/endpoint/token.go b/api/edge/bff/internal/api/routers/endpoint/token.go index be85a2a4..92a0466b 100644 --- a/api/edge/bff/internal/api/routers/endpoint/token.go +++ b/api/edge/bff/internal/api/routers/endpoint/token.go @@ -10,8 +10,12 @@ import ( ) func (er *HttpEndpointRouter) CreateAccessToken(user *model.Account) (sresponse.TokenData, error) { + return er.CreateAccessTokenForClient(user, "") +} + +func (er *HttpEndpointRouter) CreateAccessTokenForClient(user *model.Account, clientID string) (sresponse.TokenData, error) { ja := jwtauth.New(er.signature.Algorithm, er.signature.PrivateKey, er.signature.PublicKey) - _, res, err := ja.Encode(emodel.Account2Claims(user, er.config.Expiration.Account)) + _, res, err := ja.Encode(emodel.Account2ClaimsForClient(user, er.config.Expiration.Account, clientID)) token := sresponse.TokenData{ Token: res, Expiration: time.Now().Add(time.Duration(er.config.Expiration.Account) * time.Hour), diff --git a/api/edge/bff/internal/api/routers/ipguard/ipguard.go b/api/edge/bff/internal/api/routers/ipguard/ipguard.go new file mode 100644 index 00000000..849fb1bc --- /dev/null +++ b/api/edge/bff/internal/api/routers/ipguard/ipguard.go @@ -0,0 +1,64 @@ +package ipguard + +import ( + "net" + "net/http" + "strings" +) + +// ClientIP resolves caller IP from request remote address. +// The service relies on trusted proxy middleware to normalize RemoteAddr. +func ClientIP(r *http.Request) net.IP { + if r == nil { + return nil + } + raw := strings.TrimSpace(r.RemoteAddr) + if raw == "" { + return nil + } + if ip := net.ParseIP(raw); ip != nil { + return ip + } + host, _, err := net.SplitHostPort(raw) + if err != nil { + return nil + } + return net.ParseIP(host) +} + +func parseCIDRs(raw []string) ([]*net.IPNet, error) { + blocks := make([]*net.IPNet, 0, len(raw)) + for _, item := range raw { + clean := strings.TrimSpace(item) + if clean == "" { + continue + } + _, block, err := net.ParseCIDR(clean) + if err != nil { + return nil, err + } + blocks = append(blocks, block) + } + return blocks, nil +} + +// Allowed reports whether clientIP is allowed by configured CIDRs. +// Empty CIDR list means unrestricted access. +func Allowed(clientIP net.IP, cidrs []string) (bool, error) { + blocks, err := parseCIDRs(cidrs) + if err != nil { + return false, err + } + if len(blocks) == 0 { + return true, nil + } + if clientIP == nil { + return false, nil + } + for _, block := range blocks { + if block.Contains(clientIP) { + return true, nil + } + } + return false, nil +} diff --git a/api/edge/bff/internal/api/routers/ipguard/ipguard_test.go b/api/edge/bff/internal/api/routers/ipguard/ipguard_test.go new file mode 100644 index 00000000..af2b28ca --- /dev/null +++ b/api/edge/bff/internal/api/routers/ipguard/ipguard_test.go @@ -0,0 +1,86 @@ +package ipguard + +import ( + "net" + "net/http" + "testing" +) + +func TestClientIP(t *testing.T) { + t.Run("extracts host from remote addr", func(t *testing.T) { + req := &http.Request{RemoteAddr: "10.1.2.3:1234"} + ip := ClientIP(req) + if ip == nil || ip.String() != "10.1.2.3" { + t.Fatalf("unexpected ip: %v", ip) + } + }) + + t.Run("supports plain ip", func(t *testing.T) { + req := &http.Request{RemoteAddr: "8.8.8.8"} + ip := ClientIP(req) + if ip == nil || ip.String() != "8.8.8.8" { + t.Fatalf("unexpected ip: %v", ip) + } + }) + + t.Run("invalid remote addr", func(t *testing.T) { + req := &http.Request{RemoteAddr: "invalid"} + if ip := ClientIP(req); ip != nil { + t.Fatalf("expected nil ip, got %v", ip) + } + }) +} + +func TestAllowed(t *testing.T) { + clientIP := net.ParseIP("10.1.2.3") + if clientIP == nil { + t.Fatal("failed to parse test ip") + } + + t.Run("allows when cidr matches", func(t *testing.T) { + allowed, err := Allowed(clientIP, []string{"10.0.0.0/8"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !allowed { + t.Fatal("expected allowed") + } + }) + + t.Run("denies when cidr does not match", func(t *testing.T) { + allowed, err := Allowed(clientIP, []string{"192.168.0.0/16"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if allowed { + t.Fatal("expected denied") + } + }) + + t.Run("allows when cidr list is empty", func(t *testing.T) { + allowed, err := Allowed(clientIP, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !allowed { + t.Fatal("expected allowed") + } + }) + + t.Run("invalid cidr fails", func(t *testing.T) { + _, err := Allowed(clientIP, []string{"not-a-cidr"}) + if err == nil { + t.Fatal("expected error") + } + }) + + t.Run("nil client ip denied when cidrs configured", func(t *testing.T) { + allowed, err := Allowed(nil, []string{"10.0.0.0/8"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if allowed { + t.Fatal("expected denied") + } + }) +} diff --git a/api/edge/bff/internal/api/routers/public/login.go b/api/edge/bff/internal/api/routers/public/login.go index 3e94ef37..522c5074 100644 --- a/api/edge/bff/internal/api/routers/public/login.go +++ b/api/edge/bff/internal/api/routers/public/login.go @@ -2,6 +2,7 @@ package routers import ( "context" + "crypto/subtle" "encoding/json" "errors" "net/http" @@ -9,36 +10,45 @@ import ( "github.com/tech/sendico/pkg/api/http/response" "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/mservice" "github.com/tech/sendico/pkg/mutil/mask" "github.com/tech/sendico/server/interface/api/srequest" "github.com/tech/sendico/server/interface/api/sresponse" + "github.com/tech/sendico/server/internal/api/routers/ipguard" "go.uber.org/zap" ) const pendingLoginTTLMinutes = 10 +const apiLoginGrantType = "password" +const apiLoginClientAuthMethod = "client_secret_post" -func (pr *PublicRouter) logUserIn(ctx context.Context, _ *http.Request, req *srequest.Login) http.HandlerFunc { +func (pr *PublicRouter) authenticateAccount(ctx context.Context, req *srequest.Login) (*model.Account, 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) || (account == nil) { pr.logger.Debug("User not found while logging in", zap.Error(err), zap.String("login", req.Login)) - return response.Unauthorized(pr.logger, pr.service, "user not found") + return nil, response.Unauthorized(pr.logger, pr.service, "user not found") } if err != nil { pr.logger.Warn("Failed to query user with email", zap.Error(err), zap.String("login", req.Login)) - return response.Internal(pr.logger, pr.service, err) + return nil, response.Internal(pr.logger, pr.service, err) } if !account.IsActive() { - return response.Forbidden(pr.logger, pr.service, "account_not_verified", "Account verification required") + return nil, response.Forbidden(pr.logger, pr.service, "account_not_verified", "Account verification required") } if !account.MatchPassword(req.Password) { - return response.Unauthorized(pr.logger, pr.service, "password does not match") + return nil, response.Unauthorized(pr.logger, pr.service, "password does not match") } + return account, nil +} + +func (pr *PublicRouter) respondPendingLogin(account *model.Account) http.HandlerFunc { pendingToken, err := pr.imp.CreatePendingToken(account, pendingLoginTTLMinutes) if err != nil { pr.logger.Warn("Failed to generate pending token", zap.Error(err)) @@ -48,20 +58,144 @@ func (pr *PublicRouter) logUserIn(ctx context.Context, _ *http.Request, req *sre return sresponse.LoginPending(pr.logger, account, &pendingToken, mask.Email(account.Login)) } -func (a *PublicRouter) login(r *http.Request) http.HandlerFunc { - // TODO: add rate check +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 *model.Client) http.HandlerFunc { + if client == nil { + return response.Unauthorized(pr.logger, pr.service, "client not found") + } + 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.Warn("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) validateAPIClient(ctx context.Context, r *http.Request, req *srequest.Login, account *model.Account) http.HandlerFunc { + client, err := pr.rtdb.GetClient(ctx, req.ClientID) + if errors.Is(err, merrors.ErrNoData) || client == nil { + pr.logger.Debug("API login rejected: client not found", zap.String("client_id", req.ClientID)) + return response.Unauthorized(pr.logger, pr.service, "client not found") + } + if err != nil { + pr.logger.Warn("API login rejected: failed to load client", zap.Error(err), zap.String("client_id", req.ClientID)) + return response.Internal(pr.logger, pr.service, err) + } + if client.IsRevoked { + return response.Forbidden(pr.logger, pr.service, "client_revoked", "client has been revoked") + } + 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 client.AccountRef != 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") + } + } + if h := pr.validateClientIPPolicy(r, req.ClientID, client); h != nil { + return h + } + + return nil +} + +func (pr *PublicRouter) respondAPILogin(ctx context.Context, r *http.Request, req *srequest.Login, account *model.Account) http.HandlerFunc { + if req.ClientID == "" || req.DeviceID == "" { + return response.BadRequest(pr.logger, pr.service, "missing_session", "session identifier is required") + } + accessToken, err := pr.imp.CreateAccessTokenForClient(account, req.ClientID) + if err != nil { + pr.logger.Warn("Failed to generate access token for API login", zap.Error(err)) + return response.Internal(pr.logger, pr.service, err) + } + return pr.refreshAndRespondLogin(ctx, r, &req.SessionIdentifier, account, &accessToken) +} + +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 { - a.logger.Info("Failed to decode login request", zap.Error(err)) - return response.BadPayload(a.logger, mservice.Accounts, err) + logger.Info("Failed to decode login request", zap.Error(err)) + return nil, response.BadPayload(logger, mservice.Accounts, err) } req.Login = strings.TrimSpace(req.Login) req.Password = strings.TrimSpace(req.Password) + req.ClientID = strings.TrimSpace(req.ClientID) + req.DeviceID = strings.TrimSpace(req.DeviceID) + req.ClientSecret = strings.TrimSpace(req.ClientSecret) + if req.Login == "" { - return response.BadRequest(a.logger, mservice.Accounts, "email_missing", "login request has no user name") + return nil, response.BadRequest(logger, mservice.Accounts, "email_missing", "login request has no user name") } if req.Password == "" { - return response.BadRequest(a.logger, mservice.Accounts, "password_missing", "login request has no password") + return nil, response.BadRequest(logger, mservice.Accounts, "password_missing", "login request has no password") } - return a.logUserIn(r.Context(), r, &req) + return &req, nil +} + +func (a *PublicRouter) login(r *http.Request) http.HandlerFunc { + // TODO: add rate check + req, h := decodeLogin(r, a.logger) + if h != nil { + return h + } + account, h := a.authenticateAccount(r.Context(), req) + if h != nil { + return h + } + 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.authenticateAccount(r.Context(), 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) } diff --git a/api/edge/bff/internal/api/routers/public/refresh.go b/api/edge/bff/internal/api/routers/public/refresh.go index 7c7be600..e47a39c5 100644 --- a/api/edge/bff/internal/api/routers/public/refresh.go +++ b/api/edge/bff/internal/api/routers/public/refresh.go @@ -2,6 +2,7 @@ package routers import ( "encoding/json" + "errors" "net/http" "github.com/tech/sendico/pkg/api/http/response" @@ -21,6 +22,9 @@ func (pr *PublicRouter) refreshAccessToken(r *http.Request) http.HandlerFunc { account, token, err := pr.validateRefreshToken(r.Context(), r, &req) if err != nil { + if errors.Is(err, errClientIPNotAllowed) { + return response.Forbidden(pr.logger, pr.service, "ip_not_allowed", "request ip is not allowed for this client") + } pr.logger.Warn("Failed to process access token refreshment request", zap.Error(err)) return response.Auto(pr.logger, pr.service, err) } diff --git a/api/edge/bff/internal/api/routers/public/rotate.go b/api/edge/bff/internal/api/routers/public/rotate.go index cf933944..61cb5f97 100644 --- a/api/edge/bff/internal/api/routers/public/rotate.go +++ b/api/edge/bff/internal/api/routers/public/rotate.go @@ -2,6 +2,7 @@ package routers import ( "encoding/json" + "errors" "net/http" "github.com/tech/sendico/pkg/api/http/response" @@ -20,6 +21,9 @@ func (pr *PublicRouter) rotateRefreshToken(r *http.Request) http.HandlerFunc { account, token, err := pr.validateRefreshToken(r.Context(), r, &req) if err != nil { + if errors.Is(err, errClientIPNotAllowed) { + return response.Forbidden(pr.logger, pr.service, "ip_not_allowed", "request ip is not allowed for this client") + } pr.logger.Warn("Failed to validate refresh token", zap.Error(err)) return response.Auto(pr.logger, pr.service, err) } diff --git a/api/edge/bff/internal/api/routers/public/router.go b/api/edge/bff/internal/api/routers/public/router.go index ddef7122..b52cf4fc 100644 --- a/api/edge/bff/internal/api/routers/public/router.go +++ b/api/edge/bff/internal/api/routers/public/router.go @@ -40,6 +40,7 @@ func NewRouter(logger mlogger.Logger, apiEndpoint string, db account.DB, vdb ver } hr.InstallHandler(hr.service, "/login", api.Post, hr.login) + hr.InstallHandler(hr.service, "/login/api", api.Post, hr.apiLogin) hr.InstallHandler(hr.service, "/rotate", api.Post, hr.rotateRefreshToken) hr.InstallHandler(hr.service, "/refresh", api.Post, hr.refreshAccessToken) diff --git a/api/edge/bff/internal/api/routers/public/validate.go b/api/edge/bff/internal/api/routers/public/validate.go index 41f7d431..940d3aaa 100644 --- a/api/edge/bff/internal/api/routers/public/validate.go +++ b/api/edge/bff/internal/api/routers/public/validate.go @@ -14,6 +14,8 @@ import ( "go.uber.org/zap" ) +var errClientIPNotAllowed = errors.New("client_ip_not_allowed") + func validateToken(token string, rt *model.RefreshToken) string { if rt.AccountRef == nil { return "missing account reference" @@ -31,7 +33,23 @@ func validateToken(token string, rt *model.RefreshToken) string { return "" } -func (pr *PublicRouter) validateRefreshToken(ctx context.Context, _ *http.Request, req *srequest.TokenRefreshRotate) (*model.Account, *sresponse.TokenData, error) { +func (pr *PublicRouter) validateRefreshToken(ctx context.Context, r *http.Request, req *srequest.TokenRefreshRotate) (*model.Account, *sresponse.TokenData, error) { + client, err := pr.rtdb.GetClient(ctx, req.ClientID) + if errors.Is(err, merrors.ErrNoData) || client == nil { + pr.logger.Info("Refresh token rejected: client not found", zap.String("client_id", req.ClientID)) + return nil, nil, merrors.Unauthorized("client not found") + } + if err != nil { + pr.logger.Warn("Failed to fetch client for refresh token validation", zap.Error(err), zap.String("client_id", req.ClientID)) + return nil, nil, err + } + if client.IsRevoked { + return nil, nil, merrors.Unauthorized("client has been revoked") + } + if h := pr.validateClientIPPolicy(r, req.ClientID, client); h != nil { + return nil, nil, errClientIPNotAllowed + } + rt, err := pr.rtdb.GetByCRT(ctx, req) if errors.Is(err, merrors.ErrNoData) { pr.logger.Info("Refresh token not found", zap.String("client_id", req.ClientID), zap.String("device_id", req.DeviceID)) @@ -49,7 +67,7 @@ func (pr *PublicRouter) validateRefreshToken(ctx context.Context, _ *http.Reques return nil, nil, merrors.Unauthorized("user not found") } - accessToken, err := pr.imp.CreateAccessToken(&account) + accessToken, err := pr.imp.CreateAccessTokenForClient(&account, req.ClientID) if err != nil { pr.logger.Warn("Failed to generate access token", zap.Error(err)) return nil, nil, err diff --git a/api/edge/bff/internal/server/verificationimp/token.go b/api/edge/bff/internal/server/verificationimp/token.go index 6134c9a0..ab192839 100644 --- a/api/edge/bff/internal/server/verificationimp/token.go +++ b/api/edge/bff/internal/server/verificationimp/token.go @@ -9,9 +9,9 @@ import ( emodel "github.com/tech/sendico/server/interface/model" ) -func (a *VerificationAPI) createAccessToken(account *model.Account) (sresponse.TokenData, error) { +func (a *VerificationAPI) createAccessToken(account *model.Account, clientID string) (sresponse.TokenData, error) { ja := jwtauth.New(a.signature.Algorithm, a.signature.PrivateKey, a.signature.PublicKey) - _, res, err := ja.Encode(emodel.Account2Claims(account, a.tokenConfig.Expiration.Account)) + _, res, err := ja.Encode(emodel.Account2ClaimsForClient(account, a.tokenConfig.Expiration.Account, clientID)) token := sresponse.TokenData{ Token: res, Expiration: time.Now().Add(time.Duration(a.tokenConfig.Expiration.Account) * time.Hour), diff --git a/api/edge/bff/internal/server/verificationimp/verify.go b/api/edge/bff/internal/server/verificationimp/verify.go index a8b90ff5..d7625f7e 100644 --- a/api/edge/bff/internal/server/verificationimp/verify.go +++ b/api/edge/bff/internal/server/verificationimp/verify.go @@ -53,7 +53,7 @@ func (a *VerificationAPI) verifyCode(r *http.Request, account *model.Account, to if req.SessionIdentifier.ClientID == "" || req.SessionIdentifier.DeviceID == "" { return response.BadRequest(a.logger, a.Name(), "missing_session", "session identifier is required") } - accessToken, err := a.createAccessToken(account) + accessToken, err := a.createAccessToken(account, req.SessionIdentifier.ClientID) if err != nil { a.logger.Warn("Failed to generate access token", zap.Error(err)) return response.Internal(a.logger, a.Name(), err) diff --git a/interface/api.yaml b/interface/api.yaml index 3ff5ffe7..e4d325c7 100644 --- a/interface/api.yaml +++ b/interface/api.yaml @@ -29,6 +29,8 @@ tags: paths: /accounts/login: $ref: ./api/accounts/auth_login.yaml + /accounts/login/api: + $ref: ./api/accounts/auth_login_api.yaml /accounts/rotate: $ref: ./api/accounts/auth_rotate.yaml /accounts/refresh: diff --git a/interface/api/accounts/auth_login_api.yaml b/interface/api/accounts/auth_login_api.yaml new file mode 100644 index 00000000..3bb72b2e --- /dev/null +++ b/interface/api/accounts/auth_login_api.yaml @@ -0,0 +1,30 @@ +post: + tags: [Accounts, Auth] + summary: API login using email/password and client credentials + description: | + Validates account credentials and eligible API client credentials, then returns final auth payload. + This endpoint bypasses login OTP/2FA and is intended only for approved API clients. + If the client definition includes `allowedCIDRs`, request source IP must match one of those CIDRs. + operationId: accountsApiLogin + requestBody: + $ref: ./bodies/auth.yaml#/components/requestBodies/ApiLoginBody + responses: + '200': + description: Login successful + content: + application/json: + schema: + allOf: + - $ref: ../response/response.yaml#/components/schemas/BaseResponse + - type: object + properties: + data: + $ref: ./response/auth.yaml#/components/schemas/LoginData + '400': + $ref: ../response/operation.yaml#/components/responses/BadRequest + '401': + $ref: ../response/operation.yaml#/components/responses/Unauthorized + '403': + $ref: ../response/operation.yaml#/components/responses/Forbidden + '500': + $ref: ../response/operation.yaml#/components/responses/InternalServerError diff --git a/interface/api/accounts/bodies/auth.yaml b/interface/api/accounts/bodies/auth.yaml index 44b94437..1b738514 100644 --- a/interface/api/accounts/bodies/auth.yaml +++ b/interface/api/accounts/bodies/auth.yaml @@ -6,6 +6,12 @@ components: application/json: schema: $ref: ../request/auth.yaml#/components/schemas/LoginRequest + ApiLoginBody: + required: true + content: + application/json: + schema: + $ref: ../request/auth.yaml#/components/schemas/ApiLoginRequest RefreshTokenBody: required: true diff --git a/interface/api/accounts/request/auth.yaml b/interface/api/accounts/request/auth.yaml index 09c50395..7471deda 100644 --- a/interface/api/accounts/request/auth.yaml +++ b/interface/api/accounts/request/auth.yaml @@ -8,10 +8,26 @@ components: properties: clientId: type: string + description: Client identifier bound to refresh token lifecycle and client policy checks. deviceId: type: string login: $ref: ../../../models/auth/login_data.yaml#/components/schemas/LoginData + ApiLoginRequest: + allOf: + - $ref: ./auth.yaml#/components/schemas/LoginRequest + - type: object + additionalProperties: false + required: + - clientId + - deviceId + - clientSecret + properties: + clientSecret: + type: string + format: password + description: Client secret for `client_secret_post` authentication. + RefreshTokenRequest: $ref: ../../../models/auth/client_refresh_token.yaml#/components/schemas/ClientRefreshToken