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,75 @@
|
||||
package authorized
|
||||
|
||||
import (
|
||||
"context"
|
||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
emodel "git.sendico.io/tech/sxchnge/product/auth/model"
|
||||
"git.sendico.io/tech/sxchnge/product/customers/store"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type customerDB struct {
|
||||
account.DB
|
||||
customer customermodel.Account
|
||||
requested bson.ObjectID
|
||||
}
|
||||
|
||||
func (d *customerDB) Get(_ context.Context, id bson.ObjectID, result *customermodel.Account) error {
|
||||
d.requested = id
|
||||
if id != d.customer.ID {
|
||||
return merrors.NoData("customer not found")
|
||||
}
|
||||
*result = d.customer
|
||||
return nil
|
||||
}
|
||||
func (d *sessionDB) GetClient(context.Context, string) (*sessionmodel.Client, error) {
|
||||
return &sessionmodel.Client{ClientID: "web"}, nil
|
||||
}
|
||||
|
||||
func TestCustomerHandlerResolvesIdentityWithoutRoles(t *testing.T) {
|
||||
for _, status := range []customermodel.AccountStatus{customermodel.AccountActive, customermodel.AccountBlocked, customermodel.AccountPendingVerification} {
|
||||
t.Run(string(status), func(t *testing.T) {
|
||||
accounts := &customerDB{}
|
||||
accounts.customer.ID = bson.NewObjectID()
|
||||
accounts.customer.Status = status
|
||||
routes := chi.NewRouter()
|
||||
signature := &middleware.Signature{Algorithm: "HS256", PrivateKey: []byte("test-only-signing-key-at-least-32-bytes")}
|
||||
router := Install(zap.NewNop(), "/api/v1", routes, accounts, &sessionDB{token: &sessionmodel.RefreshToken{}}, &middleware.TokenConfig{Expiration: middleware.TokenExpiration{Account: 1}}, signature)
|
||||
called := false
|
||||
router.AccountHandler(mservice.Accounts, "/me", api.Get, func(_ *http.Request, c *customermodel.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
called = true
|
||||
require.Equal(t, accounts.customer.ID, c.ID)
|
||||
return func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }
|
||||
})
|
||||
ja := jwtauth.New(signature.Algorithm, signature.PrivateKey, nil)
|
||||
_, token, err := ja.Encode(emodel.Account2ClaimsForSession(&accounts.customer, 1, sessionmodel.SessionIdentifier{ClientID: "web", DeviceID: "device", SessionVersion: "v1"}))
|
||||
require.NoError(t, err)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/accounts/me?accountRef="+bson.NewObjectID().Hex(), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
recorder := httptest.NewRecorder()
|
||||
routes.ServeHTTP(recorder, req)
|
||||
require.Equal(t, accounts.customer.ID, accounts.requested)
|
||||
if status == customermodel.AccountActive {
|
||||
require.Equal(t, http.StatusNoContent, recorder.Code)
|
||||
require.True(t, called)
|
||||
} else {
|
||||
require.Equal(t, http.StatusUnauthorized, recorder.Code)
|
||||
require.False(t, called)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package authorized
|
||||
|
||||
import (
|
||||
"errors"
|
||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
|
||||
emodel "git.sendico.io/tech/sxchnge/product/auth/model"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/metrics"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging/fields"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"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.Info("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) validateSession(r *http.Request, t *emodel.AccountToken) http.HandlerFunc {
|
||||
if t.Pending {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(t.ClientID) == "" || strings.TrimSpace(t.DeviceID) == "" || strings.TrimSpace(t.SessionVersion) == "" {
|
||||
sessionmetrics.Observe(sessionmetrics.Validation, sessionmetrics.MissingIdentity)
|
||||
ar.logger.Debug("Access token has no session identifier", mzap.AccRef(t.AccountRef))
|
||||
return response.Unauthorized(ar.logger, ar.service, "session identifier is required")
|
||||
}
|
||||
if _, err := ar.rtdb.GetSession(r.Context(), t.AccountRef, &t.SessionIdentifier); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) || errors.Is(err, merrors.ErrAccessDenied) {
|
||||
sessionmetrics.Observe(sessionmetrics.Validation, sessionmetrics.AlreadyInactive)
|
||||
ar.logger.Debug("Access token session is not active", mzap.AccRef(t.AccountRef),
|
||||
zap.String("client_id", t.ClientID), zap.String("device_id", t.DeviceID))
|
||||
return response.Unauthorized(ar.logger, ar.service, "session is not active")
|
||||
}
|
||||
sessionmetrics.Observe(sessionmetrics.Validation, sessionmetrics.StorageError)
|
||||
ar.logger.Warn("Failed to validate access token session", zap.Error(err), mzap.AccRef(t.AccountRef),
|
||||
zap.String("client_id", t.ClientID), zap.String("device_id", t.DeviceID))
|
||||
return response.Internal(ar.logger, ar.service, err)
|
||||
}
|
||||
sessionmetrics.Observe(sessionmetrics.Validation, sessionmetrics.Active)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ar *AuthorizedRouter) tokenHandlerWithSessionCheck(service mservice.Type, endpoint string, method api.HTTPMethod, checkSession bool, handler tokenHandlerFunc) {
|
||||
hndlr := func(r *http.Request) http.HandlerFunc {
|
||||
_, claims, err := jwtauth.FromContext(r.Context())
|
||||
if err != nil {
|
||||
ar.logger.Debug("Authorization failed", zap.Error(err), zap.String("request", r.URL.Path))
|
||||
return response.Unauthorized(ar.logger, ar.service, "credentials required")
|
||||
}
|
||||
t, err := emodel.Claims2Token(claims)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
if checkSession {
|
||||
if h := ar.validateSession(r, t); h != nil {
|
||||
return h
|
||||
}
|
||||
}
|
||||
return handler(r, t)
|
||||
}
|
||||
ar.imp.InstallHandler(service, endpoint, method, hndlr)
|
||||
}
|
||||
|
||||
func (ar *AuthorizedRouter) tokenHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler tokenHandlerFunc) {
|
||||
ar.tokenHandlerWithSessionCheck(service, endpoint, method, true, handler)
|
||||
}
|
||||
|
||||
func (ar *AuthorizedRouter) AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc) {
|
||||
hndlr := func(r *http.Request, t *emodel.AccountToken) http.HandlerFunc {
|
||||
if t.Pending {
|
||||
return response.Unauthorized(ar.logger, ar.service, "additional verification required")
|
||||
}
|
||||
var a customermodel.Account
|
||||
if err := ar.db.Get(r.Context(), t.AccountRef, &a); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
ar.logger.Debug("Failed to find related user", zap.Error(err), mzap.AccRef(t.AccountRef))
|
||||
return response.NotFound(ar.logger, ar.service, err.Error())
|
||||
}
|
||||
return response.Internal(ar.logger, ar.service, err)
|
||||
}
|
||||
if !a.IsActive() || a.IsArchived() {
|
||||
return response.Unauthorized(ar.logger, ar.service, "customer is inactive")
|
||||
}
|
||||
|
||||
accessToken, err := ar.imp.CreateAccessTokenForSession(&a, t.SessionIdentifier)
|
||||
if err != nil {
|
||||
ar.logger.Warn("Failed to generate access token", zap.Error(err))
|
||||
return response.Internal(ar.logger, ar.service, err)
|
||||
}
|
||||
return handler(r, &a, &accessToken)
|
||||
}
|
||||
ar.tokenHandler(service, endpoint, method, hndlr)
|
||||
}
|
||||
|
||||
func (ar *AuthorizedRouter) PendingAccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.PendingAccountHandlerFunc) {
|
||||
hndlr := func(r *http.Request, t *emodel.AccountToken) http.HandlerFunc {
|
||||
var a customermodel.Account
|
||||
if err := ar.db.Get(r.Context(), t.AccountRef, &a); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
ar.logger.Debug("Failed to find related user", zap.Error(err), mzap.AccRef(t.AccountRef))
|
||||
return response.NotFound(ar.logger, ar.service, err.Error())
|
||||
}
|
||||
return response.Internal(ar.logger, ar.service, err)
|
||||
}
|
||||
if a.Status == customermodel.AccountBlocked || a.IsArchived() {
|
||||
return response.Unauthorized(ar.logger, ar.service, "customer is inactive")
|
||||
}
|
||||
return handler(r, &a, t)
|
||||
}
|
||||
ar.tokenHandler(service, endpoint, method, hndlr)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package authorized
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
re "git.sendico.io/tech/sxchnge/product/auth/http/v1/endpoint"
|
||||
emodel "git.sendico.io/tech/sxchnge/product/auth/model"
|
||||
"git.sendico.io/tech/sxchnge/product/customers/store"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/metrics"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/store"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging/fields"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type AuthorizedRouter struct {
|
||||
logger mlogger.Logger
|
||||
db account.DB
|
||||
rtdb refreshtokens.DB
|
||||
imp *re.HttpEndpointRouter
|
||||
service mservice.Type
|
||||
}
|
||||
|
||||
func Install(logger mlogger.Logger, apiEndpoint string, router chi.Router, db account.DB, rtdb refreshtokens.DB, 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))
|
||||
l := logger.Named("authorized")
|
||||
ar := AuthorizedRouter{
|
||||
logger: l,
|
||||
db: db,
|
||||
rtdb: rtdb,
|
||||
imp: re.NewHttpEndpointRouter(l, apiEndpoint, router, config, signature),
|
||||
service: mservice.Accounts,
|
||||
}
|
||||
ar.tokenHandlerWithSessionCheck(mservice.Accounts, "/logout/all", api.Post, false, ar.logoutAll)
|
||||
|
||||
return &ar
|
||||
}
|
||||
|
||||
func (ar *AuthorizedRouter) logoutAll(r *http.Request, token *emodel.AccountToken) http.HandlerFunc {
|
||||
if token.ClientID == "" || token.DeviceID == "" || token.SessionVersion == "" {
|
||||
sessionmetrics.Observe(sessionmetrics.AllLogout, sessionmetrics.MissingIdentity)
|
||||
return response.Unauthorized(ar.logger, ar.service, "session identifier is required")
|
||||
}
|
||||
if err := ar.rtdb.RevokeAll(r.Context(), token.AccountRef, ""); err != nil {
|
||||
sessionmetrics.Observe(sessionmetrics.AllLogout, sessionmetrics.StorageError)
|
||||
ar.logger.Warn("Failed to revoke all account sessions", zap.Error(err), mzap.AccRef(token.AccountRef))
|
||||
return response.Internal(ar.logger, ar.service, err)
|
||||
}
|
||||
sessionmetrics.Observe(sessionmetrics.AllLogout, sessionmetrics.Succeeded)
|
||||
ar.logger.Info("All account sessions revoked", mzap.AccRef(token.AccountRef))
|
||||
return response.Success(ar.logger)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package authorized
|
||||
|
||||
import (
|
||||
"context"
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
emodel "git.sendico.io/tech/sxchnge/product/auth/model"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/store"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type sessionDB struct {
|
||||
refreshtokens.DB
|
||||
token *sessionmodel.RefreshToken
|
||||
err error
|
||||
revokeAllID string
|
||||
}
|
||||
|
||||
func (db *sessionDB) GetSession(context.Context, bson.ObjectID, *sessionmodel.SessionIdentifier) (*sessionmodel.RefreshToken, error) {
|
||||
return db.token, db.err
|
||||
}
|
||||
|
||||
func (db *sessionDB) RevokeAll(_ context.Context, _ bson.ObjectID, deviceID string) error {
|
||||
db.revokeAllID = deviceID
|
||||
return db.err
|
||||
}
|
||||
|
||||
func TestValidateSessionFailsClosed(t *testing.T) {
|
||||
accountRef := bson.NewObjectID()
|
||||
token := &emodel.AccountToken{
|
||||
AccountRef: accountRef,
|
||||
SessionIdentifier: sessionmodel.SessionIdentifier{
|
||||
ClientID: "web",
|
||||
DeviceID: "device-a",
|
||||
SessionVersion: "version-a",
|
||||
},
|
||||
}
|
||||
request := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/accounts", nil)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
db *sessionDB
|
||||
mutate func(*emodel.AccountToken)
|
||||
wantStatus int
|
||||
}{
|
||||
{name: "active", db: &sessionDB{token: &sessionmodel.RefreshToken{}}, wantStatus: 0},
|
||||
{name: "revoked", db: &sessionDB{err: merrors.ErrNoData}, wantStatus: http.StatusUnauthorized},
|
||||
{name: "storage unavailable", db: &sessionDB{err: merrors.Internal("database unavailable")}, wantStatus: http.StatusInternalServerError},
|
||||
{
|
||||
name: "missing version",
|
||||
db: &sessionDB{},
|
||||
mutate: func(token *emodel.AccountToken) {
|
||||
token.SessionVersion = ""
|
||||
},
|
||||
wantStatus: http.StatusUnauthorized,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
candidate := *token
|
||||
if tt.mutate != nil {
|
||||
tt.mutate(&candidate)
|
||||
}
|
||||
router := &AuthorizedRouter{logger: zap.NewNop(), rtdb: tt.db, service: mservice.Accounts}
|
||||
handler := router.validateSession(request, &candidate)
|
||||
if tt.wantStatus == 0 {
|
||||
if handler != nil {
|
||||
t.Fatal("active session was rejected")
|
||||
}
|
||||
return
|
||||
}
|
||||
if handler == nil {
|
||||
t.Fatal("invalid session was accepted")
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
handler(response, request)
|
||||
if response.Code != tt.wantStatus {
|
||||
t.Fatalf("status = %d, want %d", response.Code, tt.wantStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutAllRevokesEveryDevice(t *testing.T) {
|
||||
db := &sessionDB{}
|
||||
router := &AuthorizedRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts}
|
||||
token := &emodel.AccountToken{
|
||||
AccountRef: bson.NewObjectID(),
|
||||
SessionIdentifier: sessionmodel.SessionIdentifier{
|
||||
ClientID: "web",
|
||||
DeviceID: "device-a",
|
||||
SessionVersion: "version-a",
|
||||
},
|
||||
}
|
||||
request := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/accounts/logout/all", nil)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
router.logoutAll(request, token)(response, request)
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", response.Code, http.StatusOK)
|
||||
}
|
||||
if db.revokeAllID != "" {
|
||||
t.Fatalf("device exclusion = %q, want all devices", db.revokeAllID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
rauthorized "git.sendico.io/tech/sxchnge/product/auth/http/v1/authorized"
|
||||
rpublic "git.sendico.io/tech/sxchnge/product/auth/http/v1/public"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"git.sendico.io/tech/sxchnge/product/customers/store"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/store"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"git.sendico.io/tech/sxchnge/shared/ratelimit/store"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type Dispatcher struct {
|
||||
logger mlogger.Logger
|
||||
public APIRouter
|
||||
protected ProtectedAPIRouter
|
||||
}
|
||||
|
||||
func (d *Dispatcher) Handler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc) {
|
||||
d.public.InstallHandler(service, endpoint, method, handler)
|
||||
}
|
||||
|
||||
func (d *Dispatcher) AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc) {
|
||||
d.protected.AccountHandler(service, endpoint, method, handler)
|
||||
}
|
||||
|
||||
func (d *Dispatcher) PendingAccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.PendingAccountHandlerFunc) {
|
||||
d.protected.PendingAccountHandler(service, endpoint, method, handler)
|
||||
}
|
||||
|
||||
func Install(
|
||||
logger mlogger.Logger,
|
||||
router chi.Router,
|
||||
endpoint string,
|
||||
db account.DB,
|
||||
rtdb refreshtokens.DB,
|
||||
attempts ratelimits.Store,
|
||||
token *middleware.TokenConfig,
|
||||
signature middleware.Signature,
|
||||
loginRateLimits middleware.LoginRateLimits,
|
||||
flows ...rpublic.Flows,
|
||||
) *Dispatcher {
|
||||
d := &Dispatcher{
|
||||
logger: logger.Named("api_dispatcher"),
|
||||
}
|
||||
|
||||
d.logger.Debug("Installing endpoints middleware...")
|
||||
router.Group(func(r chi.Router) {
|
||||
d.public = rpublic.Install(d.logger, endpoint, db, rtdb, attempts, r, token, &signature, loginRateLimits, flows...)
|
||||
})
|
||||
router.Group(func(r chi.Router) {
|
||||
d.protected = rauthorized.Install(d.logger, endpoint, r, db, rtdb, token, &signature)
|
||||
})
|
||||
|
||||
return d
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package endpoint
|
||||
|
||||
import (
|
||||
"context"
|
||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type (
|
||||
RegistratorT = func(chi.Router, string, http.HandlerFunc)
|
||||
ResponderFunc = func(ctx context.Context, r *http.Request, session *sessionmodel.SessionIdentifier, account *customermodel.Account, accessToken *sresponse.TokenData) http.HandlerFunc
|
||||
)
|
||||
|
||||
type HttpEndpointRouter struct {
|
||||
logger mlogger.Logger
|
||||
apiEndpoint string
|
||||
router chi.Router
|
||||
config middleware.TokenConfig
|
||||
signature middleware.Signature
|
||||
}
|
||||
|
||||
func NewHttpEndpointRouter(logger mlogger.Logger, apiEndpoint string, router chi.Router, config *middleware.TokenConfig, signature *middleware.Signature) *HttpEndpointRouter {
|
||||
er := HttpEndpointRouter{
|
||||
logger: logger.Named("http"),
|
||||
apiEndpoint: apiEndpoint,
|
||||
router: router,
|
||||
signature: *signature,
|
||||
config: *config,
|
||||
}
|
||||
return &er
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package endpoint
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (er *HttpEndpointRouter) chooseMethod(method api.HTTPMethod) RegistratorT {
|
||||
switch method {
|
||||
case api.Get:
|
||||
return func(r chi.Router, p string, h http.HandlerFunc) { r.Get(p, h) }
|
||||
case api.Post:
|
||||
return func(r chi.Router, p string, h http.HandlerFunc) { r.Post(p, h) }
|
||||
case api.Put:
|
||||
return func(r chi.Router, p string, h http.HandlerFunc) { r.Put(p, h) }
|
||||
case api.Delete:
|
||||
return func(r chi.Router, p string, h http.HandlerFunc) { r.Delete(p, h) }
|
||||
case api.Patch:
|
||||
return func(r chi.Router, p string, h http.HandlerFunc) { r.Patch(p, h) }
|
||||
case api.Options:
|
||||
return func(r chi.Router, p string, h http.HandlerFunc) { r.Options(p, h) }
|
||||
case api.Head:
|
||||
return func(r chi.Router, p string, h http.HandlerFunc) { r.Head(p, h) }
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (er *HttpEndpointRouter) endpoint(service mservice.Type, handler string) string {
|
||||
return path.Join("/", er.apiEndpoint, service, handler)
|
||||
}
|
||||
|
||||
func (er *HttpEndpointRouter) InstallHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc) {
|
||||
ep := er.endpoint(service, endpoint)
|
||||
hm := er.chooseMethod(method)
|
||||
if hm == nil {
|
||||
er.logger.Warn("Handler install skipped because method is unsupported",
|
||||
zap.String("endpoint", ep),
|
||||
zap.String("method", api.HTTPMethod2String(method)),
|
||||
)
|
||||
return
|
||||
}
|
||||
hndlr := func(w http.ResponseWriter, r *http.Request) {
|
||||
res := handler(r)
|
||||
res(w, r)
|
||||
}
|
||||
hm(er.router, ep, hndlr)
|
||||
er.logger.Info("Handler installed", zap.String("endpoint", ep), zap.String("method", api.HTTPMethod2String(method)))
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package endpoint
|
||||
|
||||
import (
|
||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
emodel "git.sendico.io/tech/sxchnge/product/auth/model"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
func (er *HttpEndpointRouter) CreateAccessTokenForSession(user *customermodel.Account, session sessionmodel.SessionIdentifier) (sresponse.TokenData, error) {
|
||||
ja := jwtauth.New(er.signature.Algorithm, er.signature.PrivateKey, er.signature.PublicKey)
|
||||
_, res, err := ja.Encode(emodel.Account2ClaimsForSession(user, er.config.Expiration.Account, session))
|
||||
token := sresponse.TokenData{
|
||||
Token: res,
|
||||
Expiration: time.Now().Add(time.Duration(er.config.Expiration.Account) * time.Hour),
|
||||
}
|
||||
return token, err
|
||||
}
|
||||
|
||||
func (er *HttpEndpointRouter) CreatePendingToken(user *customermodel.Account, ttlMinutes int) (sresponse.TokenData, error) {
|
||||
ja := jwtauth.New(er.signature.Algorithm, er.signature.PrivateKey, er.signature.PublicKey)
|
||||
_, res, err := ja.Encode(emodel.PendingAccount2Claims(user, ttlMinutes))
|
||||
token := sresponse.TokenData{
|
||||
Token: res,
|
||||
Expiration: time.Now().Add(time.Duration(ttlMinutes) * time.Minute),
|
||||
}
|
||||
return token, err
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/ratelimit/store"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
loginScopeAccount = "account"
|
||||
loginScopeIP = "ip"
|
||||
)
|
||||
|
||||
var loginRateLimitDecisions = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "sendico_login_rate_limit_decisions_total",
|
||||
Help: "Login rate-limit decisions grouped by scope kind and outcome.",
|
||||
}, []string{"scope_kind", "outcome"})
|
||||
|
||||
type loginRateScope struct {
|
||||
kind string
|
||||
hash string
|
||||
limit int
|
||||
windowStart time.Time
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
func (pr *PublicRouter) authenticateRateLimited(
|
||||
r *http.Request,
|
||||
req *srequest.Login,
|
||||
) (*customermodel.Account, http.HandlerFunc) {
|
||||
if pr.attempts == nil {
|
||||
account, _, h := pr.authenticateAccount(r.Context(), req)
|
||||
return account, h
|
||||
}
|
||||
|
||||
clock := pr.now
|
||||
if clock == nil {
|
||||
clock = time.Now
|
||||
}
|
||||
now := clock().UTC()
|
||||
scopes := pr.loginRateScopes(r, req.Login, now)
|
||||
if h := pr.checkLoginRateLimits(r.Context(), scopes, now); h != nil {
|
||||
return nil, h
|
||||
}
|
||||
|
||||
account, credentialFailure, h := pr.authenticateAccount(r.Context(), req)
|
||||
if !credentialFailure {
|
||||
for _, scope := range scopes {
|
||||
loginRateLimitDecisions.WithLabelValues(scope.kind, "allowed").Inc()
|
||||
}
|
||||
return account, h
|
||||
}
|
||||
|
||||
limited, retryAfter, failureHandler := pr.recordLoginFailure(r.Context(), scopes, now)
|
||||
if failureHandler != nil {
|
||||
return nil, failureHandler
|
||||
}
|
||||
if limited {
|
||||
return nil, pr.loginRateLimited(retryAfter)
|
||||
}
|
||||
return nil, h
|
||||
}
|
||||
|
||||
func (pr *PublicRouter) loginRateScopes(r *http.Request, login string, now time.Time) []loginRateScope {
|
||||
normalizedLogin := strings.ToLower(strings.TrimSpace(login))
|
||||
clientIP := "unresolved"
|
||||
if resolved := ipguard.ClientIP(r); resolved != nil {
|
||||
clientIP = resolved.String()
|
||||
}
|
||||
return []loginRateScope{
|
||||
newLoginRateScope(loginScopeAccount, normalizedLogin, pr.limits.PerAccount, now),
|
||||
newLoginRateScope(loginScopeIP, clientIP, pr.limits.PerIP, now),
|
||||
}
|
||||
}
|
||||
|
||||
func newLoginRateScope(kind, value string, limit middleware.LoginRateLimitWindow, now time.Time) loginRateScope {
|
||||
windowStart := now.Truncate(limit.Window)
|
||||
return loginRateScope{
|
||||
kind: kind,
|
||||
hash: ratelimits.HashScope(kind, value),
|
||||
limit: limit.Limit,
|
||||
windowStart: windowStart,
|
||||
expiresAt: windowStart.Add(limit.Window),
|
||||
}
|
||||
}
|
||||
|
||||
func (pr *PublicRouter) checkLoginRateLimits(
|
||||
ctx context.Context,
|
||||
scopes []loginRateScope,
|
||||
now time.Time,
|
||||
) http.HandlerFunc {
|
||||
for _, scope := range scopes {
|
||||
count, err := pr.attempts.Current(ctx, scope.kind, scope.hash, scope.windowStart)
|
||||
if err != nil {
|
||||
return pr.loginRateLimitStorageFailure(scope.kind, err)
|
||||
}
|
||||
if count >= scope.limit {
|
||||
loginRateLimitDecisions.WithLabelValues(scope.kind, "denied").Inc()
|
||||
retryAfter := retryAfterDuration(now, scope.expiresAt)
|
||||
pr.logger.Info("Denied login request at rate limit",
|
||||
zap.String("scope_kind", scope.kind),
|
||||
zap.Int64("retry_after_seconds", retryAfterSeconds(retryAfter)))
|
||||
return pr.loginRateLimited(retryAfter)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (pr *PublicRouter) recordLoginFailure(
|
||||
ctx context.Context,
|
||||
scopes []loginRateScope,
|
||||
now time.Time,
|
||||
) (bool, time.Duration, http.HandlerFunc) {
|
||||
limited := false
|
||||
var retryAfter time.Duration
|
||||
for _, scope := range scopes {
|
||||
count, err := pr.attempts.Increment(ctx, scope.kind, scope.hash, scope.windowStart, scope.expiresAt)
|
||||
if err != nil {
|
||||
return false, 0, pr.loginRateLimitStorageFailure(scope.kind, err)
|
||||
}
|
||||
if count >= scope.limit {
|
||||
limited = true
|
||||
loginRateLimitDecisions.WithLabelValues(scope.kind, "denied").Inc()
|
||||
scopeRetry := retryAfterDuration(now, scope.expiresAt)
|
||||
if retryAfter == 0 || scopeRetry < retryAfter {
|
||||
retryAfter = scopeRetry
|
||||
}
|
||||
pr.logger.Info("Login failure reached rate limit",
|
||||
zap.String("scope_kind", scope.kind),
|
||||
zap.Int64("retry_after_seconds", retryAfterSeconds(scopeRetry)))
|
||||
continue
|
||||
}
|
||||
loginRateLimitDecisions.WithLabelValues(scope.kind, "allowed").Inc()
|
||||
}
|
||||
return limited, retryAfter, nil
|
||||
}
|
||||
|
||||
func (pr *PublicRouter) loginRateLimitStorageFailure(scopeKind string, cause error) http.HandlerFunc {
|
||||
loginRateLimitDecisions.WithLabelValues(scopeKind, "error").Inc()
|
||||
pr.logger.Warn("Login protection storage failed", zap.Error(cause), zap.String("scope_kind", scopeKind))
|
||||
err := merrors.OpaqueWrap(merrors.ErrServiceUnavailable, cause, "login protection is temporarily unavailable")
|
||||
return response.Auto(pr.logger, pr.service, err)
|
||||
}
|
||||
|
||||
func (pr *PublicRouter) loginRateLimited(retryAfter time.Duration) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Retry-After", strconv.FormatInt(retryAfterSeconds(retryAfter), 10))
|
||||
response.TooManyRequests(pr.logger, pr.service, "too many login attempts; try again later")(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func retryAfterDuration(now, expiresAt time.Time) time.Duration {
|
||||
retryAfter := expiresAt.Sub(now)
|
||||
if retryAfter <= 0 {
|
||||
return time.Second
|
||||
}
|
||||
return retryAfter
|
||||
}
|
||||
|
||||
func retryAfterSeconds(retryAfter time.Duration) int64 {
|
||||
seconds := int64((retryAfter + time.Second - 1) / time.Second)
|
||||
if seconds < 1 {
|
||||
return 1
|
||||
}
|
||||
return seconds
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type fakeLoginAttemptStore struct {
|
||||
mu sync.Mutex
|
||||
counts map[string]int
|
||||
currentErr error
|
||||
incrementErr error
|
||||
}
|
||||
|
||||
func newFakeLoginAttemptStore() *fakeLoginAttemptStore {
|
||||
return &fakeLoginAttemptStore{counts: make(map[string]int)}
|
||||
}
|
||||
|
||||
func (s *fakeLoginAttemptStore) Current(_ context.Context, kind, hash string, start time.Time) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.currentErr != nil {
|
||||
return 0, s.currentErr
|
||||
}
|
||||
return s.counts[attemptKey(kind, hash, start)], nil
|
||||
}
|
||||
|
||||
func (s *fakeLoginAttemptStore) Increment(
|
||||
_ context.Context,
|
||||
kind, hash string,
|
||||
start, _ time.Time,
|
||||
) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.incrementErr != nil {
|
||||
return 0, s.incrementErr
|
||||
}
|
||||
key := attemptKey(kind, hash, start)
|
||||
s.counts[key]++
|
||||
return s.counts[key], nil
|
||||
}
|
||||
|
||||
func attemptKey(kind, hash string, start time.Time) string {
|
||||
return kind + ":" + hash + ":" + start.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
func testLoginLimits(accountLimit, ipLimit int, window time.Duration) middleware.LoginRateLimits {
|
||||
return middleware.LoginRateLimits{
|
||||
PerAccount: middleware.LoginRateLimitWindow{Limit: accountLimit, Window: window},
|
||||
PerIP: middleware.LoginRateLimitWindow{Limit: ipLimit, Window: window},
|
||||
}
|
||||
}
|
||||
|
||||
func testRateLimitedRouter(
|
||||
db *testAccountDB,
|
||||
store *fakeLoginAttemptStore,
|
||||
limits middleware.LoginRateLimits,
|
||||
now *time.Time,
|
||||
) *PublicRouter {
|
||||
return &PublicRouter{
|
||||
logger: zap.NewNop(),
|
||||
db: db,
|
||||
service: mservice.Accounts,
|
||||
attempts: store,
|
||||
limits: limits,
|
||||
now: func() time.Time {
|
||||
return *now
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func loginRequest(login, password, remoteAddr string) (*http.Request, *srequest.Login) {
|
||||
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/login", nil)
|
||||
req.RemoteAddr = remoteAddr
|
||||
return req, &srequest.Login{LoginData: customermodel.LoginData{
|
||||
UserDataBase: customermodel.UserDataBase{Login: login},
|
||||
Password: password,
|
||||
}}
|
||||
}
|
||||
|
||||
func executeLoginHandler(t *testing.T, h http.HandlerFunc, req *http.Request) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
if h == nil {
|
||||
t.Fatal("expected response handler")
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
h(recorder, req)
|
||||
return recorder
|
||||
}
|
||||
|
||||
func TestLoginRateLimitNormalizesAccountAndBoundsDistributedAttempts(t *testing.T) {
|
||||
now := time.Date(2026, time.August, 13, 9, 0, 0, 0, time.UTC)
|
||||
store := newFakeLoginAttemptStore()
|
||||
db := &testAccountDB{result: testActiveAccount(t)}
|
||||
router := testRateLimitedRouter(db, store, testLoginLimits(3, 100, 10*time.Minute), &now)
|
||||
|
||||
attempts := []struct {
|
||||
login string
|
||||
remote string
|
||||
status int
|
||||
}{
|
||||
{login: " User@Example.com ", remote: "192.0.2.1:1000", status: http.StatusUnauthorized},
|
||||
{login: "user@example.com", remote: "192.0.2.2:1000", status: http.StatusUnauthorized},
|
||||
{login: "USER@EXAMPLE.COM", remote: "192.0.2.3:1000", status: http.StatusTooManyRequests},
|
||||
}
|
||||
for _, attempt := range attempts {
|
||||
req, login := loginRequest(attempt.login, "wrong password", attempt.remote)
|
||||
_, h := router.authenticateRateLimited(req, login)
|
||||
recorder := executeLoginHandler(t, h, req)
|
||||
if recorder.Code != attempt.status {
|
||||
t.Fatalf("login %q status: got %d want %d", attempt.login, recorder.Code, attempt.status)
|
||||
}
|
||||
}
|
||||
|
||||
req, login := loginRequest("user@example.com", "correct horse battery staple", "192.0.2.4:1000")
|
||||
_, h := router.authenticateRateLimited(req, login)
|
||||
recorder := executeLoginHandler(t, h, req)
|
||||
if recorder.Code != http.StatusTooManyRequests || recorder.Header().Get("Retry-After") != "600" {
|
||||
t.Fatalf("blocked recovery mismatch: status=%d retry=%q", recorder.Code, recorder.Header().Get("Retry-After"))
|
||||
}
|
||||
|
||||
now = now.Add(10 * time.Minute)
|
||||
account, h := router.authenticateRateLimited(req, login)
|
||||
if h != nil || account == nil {
|
||||
t.Fatalf("expected legitimate login after window expiry, account=%v handler=%v", account, h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRateLimitBoundsOneSourceAcrossAccounts(t *testing.T) {
|
||||
now := time.Date(2026, time.August, 13, 9, 0, 0, 0, time.UTC)
|
||||
store := newFakeLoginAttemptStore()
|
||||
router := testRateLimitedRouter(
|
||||
&testAccountDB{err: merrors.ErrNoData},
|
||||
store,
|
||||
testLoginLimits(100, 3, 15*time.Minute),
|
||||
&now,
|
||||
)
|
||||
|
||||
for i, loginValue := range []string{"one@example.com", "two@example.com", "three@example.com"} {
|
||||
req, login := loginRequest(loginValue, "wrong password", "198.51.100.10:9000")
|
||||
_, h := router.authenticateRateLimited(req, login)
|
||||
recorder := executeLoginHandler(t, h, req)
|
||||
want := http.StatusUnauthorized
|
||||
if i == 2 {
|
||||
want = http.StatusTooManyRequests
|
||||
}
|
||||
if recorder.Code != want {
|
||||
t.Fatalf("attempt %d status: got %d want %d", i+1, recorder.Code, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginEndpointReturnsStableRateLimitResponse(t *testing.T) {
|
||||
now := time.Date(2026, time.August, 13, 9, 0, 0, 0, time.UTC)
|
||||
router := testRateLimitedRouter(
|
||||
&testAccountDB{err: merrors.ErrNoData},
|
||||
newFakeLoginAttemptStore(),
|
||||
testLoginLimits(2, 50, 15*time.Minute),
|
||||
&now,
|
||||
)
|
||||
body := `{"login":{"login":"missing@example.com","password":"wrong password"}}`
|
||||
|
||||
for attempt := 1; attempt <= 2; attempt++ {
|
||||
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/login", strings.NewReader(body))
|
||||
req.RemoteAddr = "198.51.100.20:9000"
|
||||
recorder := httptest.NewRecorder()
|
||||
router.login(req)(recorder, req)
|
||||
|
||||
want := http.StatusUnauthorized
|
||||
if attempt == 2 {
|
||||
want = http.StatusTooManyRequests
|
||||
}
|
||||
if recorder.Code != want {
|
||||
t.Fatalf("attempt %d status: got %d want %d", attempt, recorder.Code, want)
|
||||
}
|
||||
if attempt == 2 {
|
||||
if recorder.Header().Get("Retry-After") != "900" {
|
||||
t.Fatalf("unexpected Retry-After: %q", recorder.Header().Get("Retry-After"))
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), `"error":"too_many_requests"`) {
|
||||
t.Fatalf("unexpected 429 body: %s", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidCredentialsDoNotDiscloseAccountExistence(t *testing.T) {
|
||||
request := &srequest.Login{LoginData: customermodel.LoginData{
|
||||
UserDataBase: customermodel.UserDataBase{Login: "user@example.com"},
|
||||
Password: "wrong password",
|
||||
}}
|
||||
|
||||
responses := make([]*httptest.ResponseRecorder, 0, 2)
|
||||
for _, db := range []*testAccountDB{
|
||||
{result: testActiveAccount(t)},
|
||||
{err: merrors.ErrNoData},
|
||||
} {
|
||||
router := &PublicRouter{logger: zap.NewNop(), db: db, service: mservice.Accounts}
|
||||
_, failed, h := router.authenticateAccount(context.Background(), request)
|
||||
if !failed {
|
||||
t.Fatal("invalid credentials were not classified as a failure")
|
||||
}
|
||||
req, _ := loginRequest("user@example.com", "wrong password", "192.0.2.1:1000")
|
||||
responses = append(responses, executeLoginHandler(t, h, req))
|
||||
}
|
||||
|
||||
if responses[0].Code != http.StatusUnauthorized || responses[1].Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unexpected statuses: existing=%d unknown=%d", responses[0].Code, responses[1].Code)
|
||||
}
|
||||
if responses[0].Body.String() != responses[1].Body.String() ||
|
||||
!strings.Contains(responses[0].Body.String(), "invalid credentials") {
|
||||
t.Fatalf("credential responses differ: existing=%s unknown=%s", responses[0].Body.String(), responses[1].Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRateLimitIsAtomicAcrossRouterInstances(t *testing.T) {
|
||||
now := time.Date(2026, time.August, 13, 9, 0, 0, 0, time.UTC)
|
||||
store := newFakeLoginAttemptStore()
|
||||
db := &testAccountDB{err: merrors.ErrNoData}
|
||||
limits := testLoginLimits(5, 1000, 15*time.Minute)
|
||||
routers := []*PublicRouter{
|
||||
testRateLimitedRouter(db, store, limits, &now),
|
||||
testRateLimitedRouter(db, store, limits, &now),
|
||||
}
|
||||
|
||||
var unauthorized atomic.Int64
|
||||
var limited atomic.Int64
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 20; i++ {
|
||||
wg.Add(1)
|
||||
go func(index int) {
|
||||
defer wg.Done()
|
||||
req, login := loginRequest("shared@example.com", "wrong password", "203.0.113."+strconv.Itoa(index+1)+":9000")
|
||||
_, h := routers[index%len(routers)].authenticateRateLimited(req, login)
|
||||
recorder := httptest.NewRecorder()
|
||||
h(recorder, req)
|
||||
switch recorder.Code {
|
||||
case http.StatusUnauthorized:
|
||||
unauthorized.Add(1)
|
||||
case http.StatusTooManyRequests:
|
||||
limited.Add(1)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if unauthorized.Load() != 4 || limited.Load() != 16 {
|
||||
t.Fatalf("concurrent decisions: unauthorized=%d limited=%d", unauthorized.Load(), limited.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRateLimitFailsClosedOnStorageError(t *testing.T) {
|
||||
now := time.Date(2026, time.August, 13, 9, 0, 0, 0, time.UTC)
|
||||
store := newFakeLoginAttemptStore()
|
||||
store.currentErr = merrors.Internal("database unavailable")
|
||||
db := &testAccountDB{result: testActiveAccount(t)}
|
||||
router := testRateLimitedRouter(db, store, testLoginLimits(5, 50, 15*time.Minute), &now)
|
||||
req, login := loginRequest("user@example.com", "correct horse battery staple", "192.0.2.1:1000")
|
||||
|
||||
_, h := router.authenticateRateLimited(req, login)
|
||||
recorder := executeLoginHandler(t, h, req)
|
||||
if recorder.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected service unavailable, got %d", recorder.Code)
|
||||
}
|
||||
if db.getByEmailCalls != 0 {
|
||||
t.Fatalf("storage failure must stop before password lookup, got %d lookups", db.getByEmailCalls)
|
||||
}
|
||||
if strings.Contains(recorder.Body.String(), "database unavailable") {
|
||||
t.Fatal("storage cause leaked to the public response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRateLimitFailsClosedWhenFailureCannotBeRecorded(t *testing.T) {
|
||||
now := time.Date(2026, time.August, 13, 9, 0, 0, 0, time.UTC)
|
||||
store := newFakeLoginAttemptStore()
|
||||
store.incrementErr = merrors.Internal("write unavailable")
|
||||
router := testRateLimitedRouter(
|
||||
&testAccountDB{err: merrors.ErrNoData},
|
||||
store,
|
||||
testLoginLimits(5, 50, 15*time.Minute),
|
||||
&now,
|
||||
)
|
||||
req, login := loginRequest("missing@example.com", "wrong password", "192.0.2.1:1000")
|
||||
|
||||
_, h := router.authenticateRateLimited(req, login)
|
||||
recorder := executeLoginHandler(t, h, req)
|
||||
if recorder.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected service unavailable, got %d", recorder.Code)
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), "login protection is temporarily unavailable") {
|
||||
t.Fatalf("unexpected public failure body: %s", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDummyPasswordHashRemainsValid(t *testing.T) {
|
||||
if !(&customermodel.Account{Password: dummyPasswordHash}).MatchPassword("sendico-login-dummy-password") {
|
||||
t.Fatal("dummy password hash is not a valid bcrypt hash")
|
||||
}
|
||||
if (&customermodel.Account{Password: dummyPasswordHash}).MatchPassword("different") {
|
||||
t.Fatal("dummy password hash unexpectedly matched another password")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestLoginRejectsUnknownClientID(t *testing.T) {
|
||||
testRejectsUnknownClientID(t, "/login", `{
|
||||
"login":{"login":"user@example.com","password":"correct horse battery staple"},
|
||||
"clientId":"missing-client",
|
||||
"deviceId":"device-1"
|
||||
}`, 0, func(router *PublicRouter, req *http.Request) http.HandlerFunc {
|
||||
return router.login(req)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAPILoginRejectsUnknownClientID(t *testing.T) {
|
||||
testRejectsUnknownClientID(t, "/login/api", `{
|
||||
"login":{"login":"user@example.com","password":"correct horse battery staple"},
|
||||
"clientId":"missing-client",
|
||||
"deviceId":"device-1",
|
||||
"clientSecret":"secret"
|
||||
}`, 1, func(router *PublicRouter, req *http.Request) http.HandlerFunc {
|
||||
return router.apiLogin(req)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoginAuthenticatesAgainstInjectedAccountStorage(t *testing.T) {
|
||||
account := testActiveAccount(t)
|
||||
unusedDB := &testAccountDB{err: merrors.ErrNoData}
|
||||
activeDB := &testAccountDB{result: account}
|
||||
router := &PublicRouter{
|
||||
logger: zap.NewNop(),
|
||||
db: activeDB,
|
||||
service: mservice.Accounts,
|
||||
}
|
||||
|
||||
got, failed, h := router.authenticateAccount(context.Background(), &srequest.Login{
|
||||
LoginData: customermodel.LoginData{
|
||||
UserDataBase: customermodel.UserDataBase{
|
||||
Login: "user@example.com",
|
||||
},
|
||||
Password: "correct horse battery staple",
|
||||
},
|
||||
})
|
||||
if h != nil {
|
||||
t.Fatal("expected login authentication to succeed against injected account store")
|
||||
}
|
||||
if failed {
|
||||
t.Fatal("successful login was classified as a credential failure")
|
||||
}
|
||||
if got == nil || got.Login != account.Login {
|
||||
t.Fatalf("expected account %q, got %#v", account.Login, got)
|
||||
}
|
||||
if activeDB.getByEmailCalls != 1 {
|
||||
t.Fatalf("expected one active account store lookup, got %d", activeDB.getByEmailCalls)
|
||||
}
|
||||
if unusedDB.getByEmailCalls != 0 {
|
||||
t.Fatalf("expected no unused account store lookups, got %d", unusedDB.getByEmailCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func testRejectsUnknownClientID(
|
||||
t *testing.T,
|
||||
path string,
|
||||
body string,
|
||||
wantLookups int,
|
||||
handler func(*PublicRouter, *http.Request) http.HandlerFunc,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
account := testActiveAccount(t)
|
||||
accountDB := &testAccountDB{result: account}
|
||||
router := &PublicRouter{
|
||||
logger: zap.NewNop(),
|
||||
db: accountDB,
|
||||
rtdb: &testRefreshTokenDB{clientErr: merrors.ErrNoData},
|
||||
service: mservice.Accounts,
|
||||
}
|
||||
|
||||
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, path, strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler(router, req)(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code)
|
||||
}
|
||||
if accountDB.getByEmailCalls != wantLookups {
|
||||
t.Fatalf("expected %d account lookups, got %d", wantLookups, accountDB.getByEmailCalls)
|
||||
}
|
||||
|
||||
resp := decodeErrorResponse(t, rec.Body.Bytes())
|
||||
if resp.Data.Details != "unknown client id" {
|
||||
t.Fatalf("expected unknown client id message, got %q", resp.Data.Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRejectsPendingVerificationAccount(t *testing.T) {
|
||||
account := testAccountWithStatus(t, customermodel.AccountPendingVerification)
|
||||
router := &PublicRouter{
|
||||
logger: zap.NewNop(),
|
||||
db: &testAccountDB{result: account},
|
||||
service: mservice.Accounts,
|
||||
}
|
||||
|
||||
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/login", strings.NewReader(`{
|
||||
"login":{"login":"user@example.com","password":"correct horse battery staple"}
|
||||
}`))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.login(req)(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusForbidden, rec.Code)
|
||||
}
|
||||
resp := decodeErrorResponse(t, rec.Body.Bytes())
|
||||
if resp.Data.Details != "Account verification required" {
|
||||
t.Fatalf("expected account verification required message, got %q", resp.Data.Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRejectsBlockedAccount(t *testing.T) {
|
||||
account := testAccountWithStatus(t, customermodel.AccountBlocked)
|
||||
router := &PublicRouter{
|
||||
logger: zap.NewNop(),
|
||||
db: &testAccountDB{result: account},
|
||||
service: mservice.Accounts,
|
||||
}
|
||||
|
||||
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/login", strings.NewReader(`{
|
||||
"login":{"login":"user@example.com","password":"correct horse battery staple"}
|
||||
}`))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.login(req)(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusForbidden, rec.Code)
|
||||
}
|
||||
resp := decodeErrorResponse(t, rec.Body.Bytes())
|
||||
if resp.Data.Details != "Account verification required" {
|
||||
t.Fatalf("expected account verification required message, got %q", resp.Data.Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPILoginAllowsLoginAgainAfterRevokeSameSession(t *testing.T) {
|
||||
account := testActiveAccount(t)
|
||||
clientID := "api-client"
|
||||
deviceID := "device-1"
|
||||
|
||||
rtdb := &testRefreshTokenDB{
|
||||
client: &sessionmodel.Client{
|
||||
ClientID: clientID,
|
||||
ClientSecret: "secret",
|
||||
GrantTypes: []string{"password"},
|
||||
TokenEndpointAuthMethod: "client_secret_post",
|
||||
AccountRef: account.GetID(),
|
||||
},
|
||||
}
|
||||
|
||||
var cfg middleware.TokenConfig
|
||||
cfg.Length = 16
|
||||
cfg.Expiration.Account = 1
|
||||
cfg.Expiration.Refresh = 24
|
||||
|
||||
signature := middleware.Signature{
|
||||
Algorithm: "HS256",
|
||||
PrivateKey: []byte("test-secret"),
|
||||
}
|
||||
|
||||
router := Install(
|
||||
zap.NewNop(),
|
||||
"/",
|
||||
&testAccountDB{result: account},
|
||||
rtdb,
|
||||
nil,
|
||||
chi.NewRouter(),
|
||||
&cfg,
|
||||
&signature,
|
||||
middleware.LoginRateLimits{},
|
||||
)
|
||||
|
||||
body := `{
|
||||
"login":{"login":"user@example.com","password":"correct horse battery staple"},
|
||||
"clientId":"api-client",
|
||||
"deviceId":"device-1",
|
||||
"clientSecret":"secret"
|
||||
}`
|
||||
|
||||
firstReq := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/login/api", strings.NewReader(body))
|
||||
firstRec := httptest.NewRecorder()
|
||||
router.apiLogin(firstReq)(firstRec, firstReq)
|
||||
|
||||
if firstRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected first login status %d, got %d", http.StatusOK, firstRec.Code)
|
||||
}
|
||||
firstResp := decodeLoginResponse(t, firstRec.Body.Bytes())
|
||||
if firstResp.Data.RefreshToken.Token == "" {
|
||||
t.Fatal("expected first login to return refresh token")
|
||||
}
|
||||
|
||||
err := rtdb.Revoke(context.Background(), *account.GetID(), &sessionmodel.SessionIdentifier{
|
||||
ClientID: clientID,
|
||||
DeviceID: deviceID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to revoke refresh token: %v", err)
|
||||
}
|
||||
|
||||
secondReq := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/login/api", strings.NewReader(body))
|
||||
secondRec := httptest.NewRecorder()
|
||||
router.apiLogin(secondReq)(secondRec, secondReq)
|
||||
|
||||
if secondRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected second login status %d, got %d", http.StatusOK, secondRec.Code)
|
||||
}
|
||||
secondResp := decodeLoginResponse(t, secondRec.Body.Bytes())
|
||||
if secondResp.Data.RefreshToken.Token == "" {
|
||||
t.Fatal("expected second login to return refresh token")
|
||||
}
|
||||
if secondResp.Data.RefreshToken.Token == firstResp.Data.RefreshToken.Token {
|
||||
t.Fatal("expected second login to issue a different refresh token after revocation")
|
||||
}
|
||||
|
||||
_, err = rtdb.GetByCRT(context.Background(), &sessionmodel.ClientRefreshToken{
|
||||
SessionIdentifier: sessionmodel.SessionIdentifier{
|
||||
ClientID: clientID,
|
||||
DeviceID: deviceID,
|
||||
},
|
||||
RefreshToken: firstResp.Data.RefreshToken.Token,
|
||||
})
|
||||
if !errors.Is(err, merrors.ErrNoData) {
|
||||
t.Fatalf("expected old refresh token to be unusable, got %v", err)
|
||||
}
|
||||
|
||||
_, err = rtdb.GetByCRT(context.Background(), &sessionmodel.ClientRefreshToken{
|
||||
SessionIdentifier: sessionmodel.SessionIdentifier{
|
||||
ClientID: clientID,
|
||||
DeviceID: deviceID,
|
||||
},
|
||||
RefreshToken: secondResp.Data.RefreshToken.Token,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected new refresh token to be active, got %v", err)
|
||||
}
|
||||
|
||||
if got := rtdb.tokenCountForSession(*account.GetID(), clientID, deviceID); got != 2 {
|
||||
t.Fatalf("expected 2 refresh token records for session, got %d", got)
|
||||
}
|
||||
if got := rtdb.activeTokenCountForSession(*account.GetID(), clientID, deviceID); got != 1 {
|
||||
t.Fatalf("expected 1 active refresh token for session, got %d", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type testErrorResponse struct {
|
||||
Data struct {
|
||||
Details string `json:"details"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type testLoginResponse struct {
|
||||
Data struct {
|
||||
AccessToken struct {
|
||||
Token string `json:"token"`
|
||||
} `json:"accessToken"`
|
||||
RefreshToken struct {
|
||||
Token string `json:"token"`
|
||||
} `json:"refreshToken"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func decodeErrorResponse(t *testing.T, body []byte) testErrorResponse {
|
||||
t.Helper()
|
||||
|
||||
var resp testErrorResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
t.Fatalf("failed to decode response body: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func decodeLoginResponse(t *testing.T, body []byte) testLoginResponse {
|
||||
t.Helper()
|
||||
|
||||
var resp testLoginResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
t.Fatalf("failed to decode login response body: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func testActiveAccount(t *testing.T) *customermodel.Account {
|
||||
t.Helper()
|
||||
return testAccountWithStatus(t, customermodel.AccountActive)
|
||||
}
|
||||
|
||||
func testAccountWithStatus(t *testing.T, status customermodel.AccountStatus) *customermodel.Account {
|
||||
t.Helper()
|
||||
|
||||
account := &customermodel.Account{
|
||||
AccountPublic: customermodel.AccountPublic{
|
||||
UserDataBase: customermodel.UserDataBase{
|
||||
Login: "user@example.com",
|
||||
},
|
||||
},
|
||||
Password: "correct horse battery staple",
|
||||
Status: status,
|
||||
}
|
||||
account.SetID(bson.NewObjectID())
|
||||
if err := account.HashPassword(); err != nil {
|
||||
t.Fatalf("failed to hash password: %v", err)
|
||||
}
|
||||
return account
|
||||
}
|
||||
|
||||
type testAccountDB struct {
|
||||
mu sync.Mutex
|
||||
result *customermodel.Account
|
||||
err error
|
||||
getByEmailCalls int
|
||||
}
|
||||
|
||||
func (db *testAccountDB) GetByEmail(ctx context.Context, email string) (*customermodel.Account, error) {
|
||||
db.mu.Lock()
|
||||
defer db.mu.Unlock()
|
||||
db.getByEmailCalls++
|
||||
return db.result, db.err
|
||||
}
|
||||
|
||||
func (db *testAccountDB) Create(ctx context.Context, object *customermodel.Account) error {
|
||||
return merrors.NotImplemented("stub")
|
||||
}
|
||||
|
||||
func (db *testAccountDB) InsertMany(ctx context.Context, objects []*customermodel.Account) error {
|
||||
return merrors.NotImplemented("stub")
|
||||
}
|
||||
|
||||
func (db *testAccountDB) Get(ctx context.Context, objectRef bson.ObjectID, result *customermodel.Account) error {
|
||||
if db.err != nil {
|
||||
return db.err
|
||||
}
|
||||
if db.result == nil || db.result.GetID() == nil || *db.result.GetID() != objectRef {
|
||||
return merrors.ErrNoData
|
||||
}
|
||||
*result = *db.result
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *testAccountDB) Update(ctx context.Context, object *customermodel.Account) error {
|
||||
return merrors.NotImplemented("stub")
|
||||
}
|
||||
|
||||
func (db *testAccountDB) Patch(ctx context.Context, objectRef bson.ObjectID, patch builder.Patch) error {
|
||||
return merrors.NotImplemented("stub")
|
||||
}
|
||||
|
||||
func (db *testAccountDB) Delete(ctx context.Context, objectRef bson.ObjectID) error {
|
||||
return merrors.NotImplemented("stub")
|
||||
}
|
||||
|
||||
func (db *testAccountDB) DeleteMany(ctx context.Context, query builder.Query) error {
|
||||
return merrors.NotImplemented("stub")
|
||||
}
|
||||
|
||||
func (db *testAccountDB) DeleteCascade(ctx context.Context, objectRef bson.ObjectID) error {
|
||||
return merrors.NotImplemented("stub")
|
||||
}
|
||||
|
||||
func (db *testAccountDB) FindOne(ctx context.Context, query builder.Query, result *customermodel.Account) error {
|
||||
return merrors.NotImplemented("stub")
|
||||
}
|
||||
|
||||
type testRefreshTokenDB struct {
|
||||
client *sessionmodel.Client
|
||||
clientErr error
|
||||
tokens []*sessionmodel.RefreshToken
|
||||
}
|
||||
|
||||
func (db *testRefreshTokenDB) GetClient(ctx context.Context, clientID string) (*sessionmodel.Client, error) {
|
||||
return db.client, db.clientErr
|
||||
}
|
||||
|
||||
func (db *testRefreshTokenDB) Revoke(ctx context.Context, accountRef bson.ObjectID, session *sessionmodel.SessionIdentifier) error {
|
||||
for _, token := range db.tokens {
|
||||
if token.AccountRef == nil || *token.AccountRef != accountRef {
|
||||
continue
|
||||
}
|
||||
if token.ClientID == session.ClientID && token.DeviceID == session.DeviceID && !token.IsRevoked {
|
||||
token.IsRevoked = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *testRefreshTokenDB) RevokeToken(ctx context.Context, token *sessionmodel.ClientRefreshToken) (bool, error) {
|
||||
for _, stored := range db.tokens {
|
||||
if stored.ClientID == token.ClientID && stored.DeviceID == token.DeviceID && stored.RefreshToken == token.RefreshToken && !stored.IsRevoked {
|
||||
stored.IsRevoked = true
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (db *testRefreshTokenDB) RevokeAll(ctx context.Context, accountRef bson.ObjectID, deviceID string) error {
|
||||
return merrors.NotImplemented("stub")
|
||||
}
|
||||
|
||||
func (db *testRefreshTokenDB) GetSession(ctx context.Context, accountRef bson.ObjectID, session *sessionmodel.SessionIdentifier) (*sessionmodel.RefreshToken, error) {
|
||||
for _, token := range db.tokens {
|
||||
if token.AccountRef != nil && *token.AccountRef == accountRef && token.ClientID == session.ClientID && token.DeviceID == session.DeviceID && !token.IsRevoked {
|
||||
return cloneRefreshToken(token), nil
|
||||
}
|
||||
}
|
||||
return nil, merrors.ErrNoData
|
||||
}
|
||||
|
||||
func (db *testRefreshTokenDB) GetByCRT(ctx context.Context, t *sessionmodel.ClientRefreshToken) (*sessionmodel.RefreshToken, error) {
|
||||
for _, token := range db.tokens {
|
||||
if token.ClientID == t.ClientID && token.DeviceID == t.DeviceID && token.RefreshToken == t.RefreshToken {
|
||||
if token.IsRevoked {
|
||||
return nil, merrors.ErrNoData
|
||||
}
|
||||
return cloneRefreshToken(token), nil
|
||||
}
|
||||
}
|
||||
return nil, merrors.ErrNoData
|
||||
}
|
||||
|
||||
func (db *testRefreshTokenDB) Create(ctx context.Context, object *sessionmodel.RefreshToken) error {
|
||||
for _, token := range db.tokens {
|
||||
if token.AccountRef == nil || object.AccountRef == nil {
|
||||
continue
|
||||
}
|
||||
if *token.AccountRef == *object.AccountRef && token.ClientID == object.ClientID && token.DeviceID == object.DeviceID && !token.IsRevoked {
|
||||
token.RefreshToken = object.RefreshToken
|
||||
token.SessionVersion = object.SessionVersion
|
||||
token.ExpiresAt = object.ExpiresAt
|
||||
token.UserAgent = object.UserAgent
|
||||
token.IPAddress = object.IPAddress
|
||||
token.LastUsedAt = object.LastUsedAt
|
||||
token.IsRevoked = object.IsRevoked
|
||||
if token.GetID() == nil || token.GetID().IsZero() {
|
||||
token.SetID(bson.NewObjectID())
|
||||
}
|
||||
object.SetID(*token.GetID())
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
clone := cloneRefreshToken(object)
|
||||
if clone.GetID() == nil || clone.GetID().IsZero() {
|
||||
clone.SetID(bson.NewObjectID())
|
||||
}
|
||||
object.SetID(*clone.GetID())
|
||||
db.tokens = append(db.tokens, clone)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *testRefreshTokenDB) InsertMany(ctx context.Context, objects []*sessionmodel.RefreshToken) error {
|
||||
return merrors.NotImplemented("stub")
|
||||
}
|
||||
|
||||
func (db *testRefreshTokenDB) Get(ctx context.Context, objectRef bson.ObjectID, result *sessionmodel.RefreshToken) error {
|
||||
return merrors.NotImplemented("stub")
|
||||
}
|
||||
|
||||
func (db *testRefreshTokenDB) Update(ctx context.Context, object *sessionmodel.RefreshToken) error {
|
||||
return merrors.NotImplemented("stub")
|
||||
}
|
||||
|
||||
func (db *testRefreshTokenDB) Patch(ctx context.Context, objectRef bson.ObjectID, patch builder.Patch) error {
|
||||
return merrors.NotImplemented("stub")
|
||||
}
|
||||
|
||||
func (db *testRefreshTokenDB) Delete(ctx context.Context, objectRef bson.ObjectID) error {
|
||||
return merrors.NotImplemented("stub")
|
||||
}
|
||||
|
||||
func (db *testRefreshTokenDB) DeleteMany(ctx context.Context, query builder.Query) error {
|
||||
return merrors.NotImplemented("stub")
|
||||
}
|
||||
|
||||
func (db *testRefreshTokenDB) DeleteCascade(ctx context.Context, objectRef bson.ObjectID) error {
|
||||
return merrors.NotImplemented("stub")
|
||||
}
|
||||
|
||||
func (db *testRefreshTokenDB) FindOne(ctx context.Context, query builder.Query, result *sessionmodel.RefreshToken) error {
|
||||
return merrors.NotImplemented("stub")
|
||||
}
|
||||
|
||||
func (db *testRefreshTokenDB) tokenCountForSession(accountRef bson.ObjectID, clientID, deviceID string) int {
|
||||
count := 0
|
||||
for _, token := range db.tokens {
|
||||
if token.AccountRef != nil && *token.AccountRef == accountRef && token.ClientID == clientID && token.DeviceID == deviceID {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func (db *testRefreshTokenDB) activeTokenCountForSession(accountRef bson.ObjectID, clientID, deviceID string) int {
|
||||
count := 0
|
||||
for _, token := range db.tokens {
|
||||
if token.AccountRef != nil && *token.AccountRef == accountRef && token.ClientID == clientID && token.DeviceID == deviceID && !token.IsRevoked {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func cloneRefreshToken(token *sessionmodel.RefreshToken) *sessionmodel.RefreshToken {
|
||||
if token == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := *token
|
||||
if token.AccountRef != nil {
|
||||
accountRef := *token.AccountRef
|
||||
cloned.AccountRef = &accountRef
|
||||
}
|
||||
return &cloned
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/browser"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/metrics"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (pr *PublicRouter) logout(r *http.Request) http.HandlerFunc {
|
||||
respond := func(handler http.HandlerFunc, clearCookie bool) http.HandlerFunc {
|
||||
if !clearCookie || !browser.UsesCookie(r) {
|
||||
return handler
|
||||
}
|
||||
return func(w http.ResponseWriter, request *http.Request) {
|
||||
browser.ClearRefreshCookie(w, request)
|
||||
handler.ServeHTTP(w, request)
|
||||
}
|
||||
}
|
||||
var req srequest.AccessTokenRefresh
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
pr.logger.Info("Rejected malformed logout request", zap.Error(err))
|
||||
return respond(response.BadPayload(pr.logger, mservice.RefreshTokens, err), false)
|
||||
}
|
||||
credential, err := browser.RefreshCredential(r, req.RefreshToken)
|
||||
if err != nil {
|
||||
sessionmetrics.Observe(sessionmetrics.CurrentLogout, sessionmetrics.AlreadyInactive)
|
||||
return respond(response.Success(pr.logger), true)
|
||||
}
|
||||
req.RefreshToken = credential
|
||||
if req.ClientID == "" || req.DeviceID == "" || req.RefreshToken == "" {
|
||||
sessionmetrics.Observe(sessionmetrics.CurrentLogout, sessionmetrics.MissingIdentity)
|
||||
return respond(response.BadRequest(pr.logger, pr.service, "missing_session", "refresh token and session identifier are required"), false)
|
||||
}
|
||||
|
||||
revoked, err := pr.rtdb.RevokeToken(r.Context(), &req)
|
||||
if err != nil {
|
||||
sessionmetrics.Observe(sessionmetrics.CurrentLogout, sessionmetrics.StorageError)
|
||||
pr.logger.Warn("Failed to revoke current session", zap.Error(err),
|
||||
zap.String("client_id", req.ClientID), zap.String("device_id", req.DeviceID))
|
||||
return respond(response.Internal(pr.logger, pr.service, err), false)
|
||||
}
|
||||
if !revoked {
|
||||
sessionmetrics.Observe(sessionmetrics.CurrentLogout, sessionmetrics.AlreadyInactive)
|
||||
return respond(response.Success(pr.logger), true)
|
||||
}
|
||||
sessionmetrics.Observe(sessionmetrics.CurrentLogout, sessionmetrics.Succeeded)
|
||||
pr.logger.Info("Current session revoked", zap.String("client_id", req.ClientID), zap.String("device_id", req.DeviceID))
|
||||
return respond(response.Success(pr.logger), true)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/browser"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/store"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type logoutDB struct {
|
||||
refreshtokens.DB
|
||||
mu sync.Mutex
|
||||
tokens map[string]*sessionmodel.RefreshToken
|
||||
lookupErr error
|
||||
revokeErr error
|
||||
}
|
||||
|
||||
func (db *logoutDB) RevokeToken(_ context.Context, request *sessionmodel.ClientRefreshToken) (bool, error) {
|
||||
db.mu.Lock()
|
||||
defer db.mu.Unlock()
|
||||
if db.lookupErr != nil {
|
||||
return false, db.lookupErr
|
||||
}
|
||||
token := db.tokens[request.DeviceID]
|
||||
if token == nil || token.IsRevoked || token.RefreshToken != request.RefreshToken {
|
||||
return false, nil
|
||||
}
|
||||
if db.revokeErr != nil {
|
||||
return false, db.revokeErr
|
||||
}
|
||||
token.IsRevoked = true
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func newLogoutToken(accountRef bson.ObjectID, deviceID, value string) *sessionmodel.RefreshToken {
|
||||
return &sessionmodel.RefreshToken{
|
||||
ClientRefreshToken: sessionmodel.ClientRefreshToken{
|
||||
SessionIdentifier: sessionmodel.SessionIdentifier{ClientID: "web", DeviceID: deviceID, SessionVersion: "version-" + deviceID},
|
||||
RefreshToken: value,
|
||||
},
|
||||
AccountRef: &accountRef,
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
}
|
||||
}
|
||||
|
||||
func executeLogout(router *PublicRouter, deviceID, refreshToken string) int {
|
||||
body := `{"clientId":"web","deviceId":"` + deviceID + `","token":"` + refreshToken + `"}`
|
||||
request := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/accounts/logout", strings.NewReader(body))
|
||||
response := httptest.NewRecorder()
|
||||
router.logout(request)(response, request)
|
||||
return response.Code
|
||||
}
|
||||
|
||||
func TestLogoutRevokesOnlyCurrentSessionAndIsIdempotent(t *testing.T) {
|
||||
accountRef := bson.NewObjectID()
|
||||
db := &logoutDB{tokens: map[string]*sessionmodel.RefreshToken{
|
||||
"device-a": newLogoutToken(accountRef, "device-a", "refresh-a"),
|
||||
"device-b": newLogoutToken(accountRef, "device-b", "refresh-b"),
|
||||
}}
|
||||
router := &PublicRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts}
|
||||
|
||||
if status := executeLogout(router, "device-a", "refresh-a"); status != http.StatusOK {
|
||||
t.Fatalf("first logout status = %d", status)
|
||||
}
|
||||
if status := executeLogout(router, "device-a", "refresh-a"); status != http.StatusOK {
|
||||
t.Fatalf("repeated logout status = %d", status)
|
||||
}
|
||||
if !db.tokens["device-a"].IsRevoked {
|
||||
t.Fatal("current device session was not revoked")
|
||||
}
|
||||
if db.tokens["device-b"].IsRevoked {
|
||||
t.Fatal("another device session was revoked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentLogoutIsIdempotent(t *testing.T) {
|
||||
accountRef := bson.NewObjectID()
|
||||
db := &logoutDB{tokens: map[string]*sessionmodel.RefreshToken{
|
||||
"device-a": newLogoutToken(accountRef, "device-a", "refresh-a"),
|
||||
}}
|
||||
router := &PublicRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts}
|
||||
|
||||
statuses := make(chan int, 8)
|
||||
var workers sync.WaitGroup
|
||||
for range 8 {
|
||||
workers.Add(1)
|
||||
go func() {
|
||||
defer workers.Done()
|
||||
statuses <- executeLogout(router, "device-a", "refresh-a")
|
||||
}()
|
||||
}
|
||||
workers.Wait()
|
||||
close(statuses)
|
||||
for status := range statuses {
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("concurrent logout status = %d", status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelayedLogoutDoesNotRevokeReplacementSession(t *testing.T) {
|
||||
accountRef := bson.NewObjectID()
|
||||
db := &logoutDB{tokens: map[string]*sessionmodel.RefreshToken{
|
||||
"device-a": newLogoutToken(accountRef, "device-a", "new-refresh"),
|
||||
}}
|
||||
router := &PublicRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts}
|
||||
|
||||
if status := executeLogout(router, "device-a", "old-refresh"); status != http.StatusOK {
|
||||
t.Fatalf("delayed logout status = %d", status)
|
||||
}
|
||||
if db.tokens["device-a"].IsRevoked {
|
||||
t.Fatal("replacement session was revoked by a stale logout request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutAcceptsAnAlreadyExpiredSession(t *testing.T) {
|
||||
accountRef := bson.NewObjectID()
|
||||
expired := newLogoutToken(accountRef, "device-a", "expired-refresh")
|
||||
expired.ExpiresAt = time.Now().Add(-time.Hour)
|
||||
db := &logoutDB{tokens: map[string]*sessionmodel.RefreshToken{"device-a": expired}}
|
||||
router := &PublicRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts}
|
||||
|
||||
if status := executeLogout(router, "device-a", "expired-refresh"); status != http.StatusOK {
|
||||
t.Fatalf("expired logout status = %d", status)
|
||||
}
|
||||
if !expired.IsRevoked {
|
||||
t.Fatal("expired session was not marked revoked before cleanup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutFailsClosedWhenSessionStoreIsUnavailable(t *testing.T) {
|
||||
db := &logoutDB{lookupErr: merrors.Internal("database unavailable")}
|
||||
router := &PublicRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts}
|
||||
if status := executeLogout(router, "device-a", "refresh-a"); status != http.StatusInternalServerError {
|
||||
t.Fatalf("logout status = %d, want %d", status, http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserLogoutUsesCookieCredentialAndClearsIt(t *testing.T) {
|
||||
accountRef := bson.NewObjectID()
|
||||
token := newLogoutToken(accountRef, "device-a", "cookie-refresh")
|
||||
db := &logoutDB{tokens: map[string]*sessionmodel.RefreshToken{"device-a": token}}
|
||||
router := &PublicRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts}
|
||||
body := `{"clientId":"web","deviceId":"device-a","token":"body-must-be-ignored"}`
|
||||
request := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/api/v1/accounts/logout", strings.NewReader(body))
|
||||
request.Header.Set(browser.TransportHeader, browser.TransportCookie)
|
||||
request.AddCookie(&http.Cookie{Name: browser.RefreshCookie, Value: "cookie-refresh"})
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
router.logout(request)(response, request)
|
||||
|
||||
require.Equal(t, http.StatusOK, response.Code)
|
||||
require.True(t, token.IsRevoked)
|
||||
cookies := response.Result().Cookies()
|
||||
require.Len(t, cookies, 1)
|
||||
require.Equal(t, browser.RefreshCookie, cookies[0].Name)
|
||||
require.Equal(t, -1, cookies[0].MaxAge)
|
||||
require.Equal(t, "/api/v1/accounts", cookies[0].Path)
|
||||
}
|
||||
|
||||
func TestBrowserLogoutWithoutCookieIsIdempotentAndClearsBrowserState(t *testing.T) {
|
||||
db := &logoutDB{tokens: map[string]*sessionmodel.RefreshToken{}}
|
||||
router := &PublicRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts}
|
||||
body := `{"clientId":"web","deviceId":"device-a","token":"body-must-be-ignored"}`
|
||||
request := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/api/v1/accounts/logout", strings.NewReader(body))
|
||||
request.Header.Set(browser.TransportHeader, browser.TransportCookie)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
router.logout(request)(response, request)
|
||||
|
||||
require.Equal(t, http.StatusOK, response.Code)
|
||||
require.Contains(t, response.Header().Get("Set-Cookie"), browser.RefreshCookie+"=")
|
||||
require.Contains(t, response.Header().Get("Set-Cookie"), "Max-Age=0")
|
||||
}
|
||||
|
||||
func TestBrowserLogoutKeepsCookieWhenRevocationFails(t *testing.T) {
|
||||
db := &logoutDB{
|
||||
tokens: map[string]*sessionmodel.RefreshToken{},
|
||||
lookupErr: merrors.Internal("database unavailable"),
|
||||
}
|
||||
router := &PublicRouter{logger: zap.NewNop(), rtdb: db, service: mservice.Accounts}
|
||||
body := `{"clientId":"web","deviceId":"device-a"}`
|
||||
request := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/api/v1/accounts/logout", strings.NewReader(body))
|
||||
request.Header.Set(browser.TransportHeader, browser.TransportCookie)
|
||||
request.AddCookie(&http.Cookie{Name: browser.RefreshCookie, Value: "cookie-refresh"})
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
router.logout(request)(response, request)
|
||||
|
||||
require.Equal(t, http.StatusInternalServerError, response.Code)
|
||||
require.Empty(t, response.Header().Get("Set-Cookie"), "client must retain the cookie so logout can retry revocation")
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
sresponse "git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
var flowRequests = promauto.NewCounterVec(prometheus.CounterOpts{Name: "sxchnge_auth_flow_requests_total", Help: "Authentication flow outcomes; success means this HTTP step completed, not email delivery."}, []string{"operation", "outcome"})
|
||||
var flowDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{Name: "sxchnge_auth_flow_duration_seconds", Help: "Authentication HTTP step latency."}, []string{"operation"})
|
||||
|
||||
func measured(operation string, handler sresponse.HandlerFunc) sresponse.HandlerFunc {
|
||||
return func(r *http.Request) http.HandlerFunc {
|
||||
start := time.Now()
|
||||
response := handler(r)
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
wrapped := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||
response(wrapped, r)
|
||||
outcome := "accepted"
|
||||
if wrapped.Status() >= 500 {
|
||||
outcome = "unavailable"
|
||||
} else if wrapped.Status() == 429 {
|
||||
outcome = "limited"
|
||||
} else if wrapped.Status() >= 400 {
|
||||
outcome = "rejected"
|
||||
}
|
||||
flowRequests.WithLabelValues(operation, outcome).Inc()
|
||||
flowDuration.WithLabelValues(operation).Observe(time.Since(start).Seconds())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"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/sessions/browser"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (pr *PublicRouter) refreshAccessToken(r *http.Request) http.HandlerFunc {
|
||||
pr.logger.Debug("Processing access token refresh request")
|
||||
var req srequest.AccessTokenRefresh
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
pr.logger.Info("Failed to decode token rotation request", zap.Error(err))
|
||||
return response.BadPayload(pr.logger, mservice.RefreshTokens, err)
|
||||
}
|
||||
|
||||
account, token, refreshToken, 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")
|
||||
}
|
||||
if isExpectedRefreshValidationError(err) {
|
||||
pr.logger.Debug("Access token refresh rejected", zap.Error(err))
|
||||
return pr.rejectRefresh(r, err)
|
||||
}
|
||||
pr.logger.Warn("Failed to process access token refreshment request", zap.Error(err))
|
||||
return response.Auto(pr.logger, pr.service, err)
|
||||
}
|
||||
if browser.UsesCookie(r) && refreshToken.ExpiresAt.Before(time.Now().Add(7*24*time.Hour)) {
|
||||
return pr.refreshAndRespondLogin(r.Context(), r, &refreshToken.SessionIdentifier, account)
|
||||
}
|
||||
|
||||
return sresponse.Account(pr.logger, account, token)
|
||||
}
|
||||
|
||||
func (pr *PublicRouter) rejectRefresh(r *http.Request, err error) http.HandlerFunc {
|
||||
handler := response.Auto(pr.logger, pr.service, err)
|
||||
if !browser.UsesCookie(r) {
|
||||
return handler
|
||||
}
|
||||
return func(w http.ResponseWriter, request *http.Request) {
|
||||
browser.ClearRefreshCookie(w, request)
|
||||
handler.ServeHTTP(w, request)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/browser"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func newBrowserRefreshRouter(t *testing.T, expiresAt time.Time) (*PublicRouter, *testRefreshTokenDB) {
|
||||
t.Helper()
|
||||
account := testActiveAccount(t)
|
||||
clientID := "web"
|
||||
deviceID := "browser-1"
|
||||
rtdb := &testRefreshTokenDB{
|
||||
client: &sessionmodel.Client{ClientID: clientID},
|
||||
tokens: []*sessionmodel.RefreshToken{
|
||||
{
|
||||
ClientRefreshToken: sessionmodel.ClientRefreshToken{
|
||||
SessionIdentifier: sessionmodel.SessionIdentifier{
|
||||
ClientID: clientID,
|
||||
DeviceID: deviceID,
|
||||
SessionVersion: "version-1",
|
||||
},
|
||||
RefreshToken: "cookie-refresh",
|
||||
},
|
||||
AccountRef: account.GetID(),
|
||||
ExpiresAt: expiresAt,
|
||||
},
|
||||
},
|
||||
}
|
||||
config := middleware.TokenConfig{
|
||||
Expiration: middleware.TokenExpiration{Account: 1, Refresh: 720},
|
||||
Length: 32,
|
||||
}
|
||||
signature := middleware.Signature{
|
||||
Algorithm: "HS256",
|
||||
PrivateKey: []byte("browser-refresh-test-secret"),
|
||||
}
|
||||
return Install(
|
||||
zap.NewNop(),
|
||||
"/api/v1",
|
||||
&testAccountDB{result: account},
|
||||
rtdb,
|
||||
nil,
|
||||
chi.NewRouter(),
|
||||
&config,
|
||||
&signature,
|
||||
middleware.LoginRateLimits{},
|
||||
), rtdb
|
||||
}
|
||||
|
||||
func executeBrowserRefresh(t *testing.T, router *PublicRouter) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
body := `{"clientId":"web","deviceId":"browser-1","token":"body-must-be-ignored"}`
|
||||
request := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "https://app.sendico.io/api/v1/accounts/refresh", strings.NewReader(body))
|
||||
request.Header.Set(browser.TransportHeader, browser.TransportCookie)
|
||||
request.AddCookie(&http.Cookie{Name: browser.RefreshCookie, Value: "cookie-refresh"})
|
||||
response := httptest.NewRecorder()
|
||||
router.refreshAccessToken(request)(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
func executeNativeRefresh(t *testing.T, router *PublicRouter, token string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
body := `{"clientId":"web","deviceId":"browser-1","token":"` + token + `"}`
|
||||
request := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "https://app.sendico.io/api/v1/accounts/refresh", strings.NewReader(body))
|
||||
response := httptest.NewRecorder()
|
||||
router.refreshAccessToken(request)(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
func TestBrowserRefreshRestoresAccessWithoutExposingRefreshCredential(t *testing.T) {
|
||||
router, _ := newBrowserRefreshRouter(t, time.Now().Add(30*24*time.Hour))
|
||||
|
||||
response := executeBrowserRefresh(t, router)
|
||||
|
||||
require.Equal(t, http.StatusOK, response.Code)
|
||||
require.NotContains(t, response.Body.String(), "cookie-refresh")
|
||||
require.NotEmpty(t, decodeLoginResponse(t, response.Body.Bytes()).Data.AccessToken.Token)
|
||||
require.Empty(t, response.Header().Get("Set-Cookie"))
|
||||
}
|
||||
|
||||
func TestBrowserRefreshRotatesExpiringCookieServerSide(t *testing.T) {
|
||||
t.Setenv("API_ENDPOINT", "/api/v1")
|
||||
router, rtdb := newBrowserRefreshRouter(t, time.Now().Add(24*time.Hour))
|
||||
|
||||
response := executeBrowserRefresh(t, router)
|
||||
|
||||
require.Equal(t, http.StatusOK, response.Code)
|
||||
require.NotContains(t, response.Body.String(), "cookie-refresh")
|
||||
cookies := response.Result().Cookies()
|
||||
require.Len(t, cookies, 1)
|
||||
require.Equal(t, browser.RefreshCookie, cookies[0].Name)
|
||||
require.NotEqual(t, "cookie-refresh", cookies[0].Value)
|
||||
require.Empty(t, decodeLoginResponse(t, response.Body.Bytes()).Data.RefreshToken.Token)
|
||||
require.Equal(t, cookies[0].Value, rtdb.tokens[0].RefreshToken)
|
||||
}
|
||||
|
||||
func TestBrowserRefreshRejectsStaleCookieAndClearsIt(t *testing.T) {
|
||||
t.Setenv("API_ENDPOINT", "/api/v1")
|
||||
router, rtdb := newBrowserRefreshRouter(t, time.Now().Add(30*24*time.Hour))
|
||||
rtdb.tokens = nil
|
||||
|
||||
response := executeBrowserRefresh(t, router)
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, response.Code)
|
||||
require.Contains(t, response.Body.String(), `"error":"unauthorized"`)
|
||||
require.NotContains(t, response.Body.String(), "cookie-refresh")
|
||||
cookies := response.Result().Cookies()
|
||||
require.Len(t, cookies, 1)
|
||||
require.Equal(t, browser.RefreshCookie, cookies[0].Name)
|
||||
require.Empty(t, cookies[0].Value)
|
||||
require.Equal(t, -1, cookies[0].MaxAge)
|
||||
require.True(t, cookies[0].HttpOnly)
|
||||
require.True(t, cookies[0].Secure)
|
||||
}
|
||||
|
||||
func TestNativeRefreshRejectsStaleTokenWithoutCookieMutation(t *testing.T) {
|
||||
router, _ := newBrowserRefreshRouter(t, time.Now().Add(30*24*time.Hour))
|
||||
|
||||
response := executeNativeRefresh(t, router, "stale-refresh")
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, response.Code)
|
||||
require.Contains(t, response.Body.String(), `"error":"unauthorized"`)
|
||||
require.Empty(t, response.Header().Get("Set-Cookie"))
|
||||
}
|
||||
|
||||
func TestRefreshRejectsInactiveCustomerAndClientMismatch(t *testing.T) {
|
||||
for _, status := range []customermodel.AccountStatus{customermodel.AccountBlocked, customermodel.AccountPendingVerification} {
|
||||
t.Run(string(status), func(t *testing.T) {
|
||||
router, _ := newBrowserRefreshRouter(t, time.Now().Add(24*time.Hour))
|
||||
router.db.(*testAccountDB).result.Status = status
|
||||
require.Equal(t, http.StatusUnauthorized, executeBrowserRefresh(t, router).Code)
|
||||
})
|
||||
}
|
||||
router, sessions := newBrowserRefreshRouter(t, time.Now().Add(24*time.Hour))
|
||||
other := testActiveAccount(t)
|
||||
sessions.client.AccountRef = &other.ID
|
||||
require.Equal(t, http.StatusUnauthorized, executeBrowserRefresh(t, router).Code)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
rtokens "git.sendico.io/tech/sxchnge/product/sessions/tokens"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging/fields"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (pr *PublicRouter) refreshAndRespondLogin(
|
||||
ctx context.Context,
|
||||
r *http.Request,
|
||||
session *sessionmodel.SessionIdentifier,
|
||||
account *customermodel.Account,
|
||||
) http.HandlerFunc {
|
||||
result, err := pr.prepareLogin(ctx, r, session, account)
|
||||
if err != nil {
|
||||
return response.Internal(pr.logger, pr.service, err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (pr *PublicRouter) prepareLogin(ctx context.Context, r *http.Request, session *sessionmodel.SessionIdentifier, account *customermodel.Account) (http.HandlerFunc, error) {
|
||||
refreshToken, err := rtokens.PrepareRefreshToken(
|
||||
ctx,
|
||||
r,
|
||||
pr.rtdb,
|
||||
pr.config.Length,
|
||||
pr.config.Expiration.Refresh,
|
||||
session,
|
||||
account,
|
||||
pr.logger,
|
||||
)
|
||||
if err != nil {
|
||||
pr.logger.Warn("Failed to create refresh token", zap.Error(err), mzap.StorableRef(account),
|
||||
zap.String("client_id", session.ClientID), zap.String("device_id", session.DeviceID))
|
||||
return nil, err
|
||||
}
|
||||
accessToken, err := pr.imp.CreateAccessTokenForSession(account, refreshToken.SessionIdentifier)
|
||||
if err != nil {
|
||||
pr.logger.Warn("Failed to generate access token", zap.Error(err), mzap.StorableRef(account),
|
||||
zap.String("client_id", session.ClientID), zap.String("device_id", session.DeviceID))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
token := sresponse.TokenData{
|
||||
Token: refreshToken.RefreshToken,
|
||||
Expiration: refreshToken.ExpiresAt,
|
||||
}
|
||||
return sresponse.Login(pr.logger, account, &accessToken, &token), nil
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/request"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (pr *PublicRouter) rotateRefreshToken(r *http.Request) http.HandlerFunc {
|
||||
pr.logger.Debug("Processing token rotation request...")
|
||||
var req srequest.TokenRefreshRotate
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
pr.logger.Info("Failed to decode token rotation request", zap.Error(err))
|
||||
return response.BadPayload(pr.logger, mservice.RefreshTokens, err)
|
||||
}
|
||||
|
||||
account, _, _, 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")
|
||||
}
|
||||
if isExpectedRefreshValidationError(err) {
|
||||
pr.logger.Debug("Refresh token rotation rejected", zap.Error(err))
|
||||
return pr.rejectRefresh(r, err)
|
||||
}
|
||||
pr.logger.Warn("Failed to validate refresh token", zap.Error(err))
|
||||
return response.Auto(pr.logger, pr.service, err)
|
||||
}
|
||||
|
||||
return pr.refreshAndRespondLogin(r.Context(), r, &req.SessionIdentifier, account)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
||||
re "git.sendico.io/tech/sxchnge/product/auth/http/v1/endpoint"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"git.sendico.io/tech/sxchnge/product/customers/store"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/store"
|
||||
"git.sendico.io/tech/sxchnge/product/twofactor"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"git.sendico.io/tech/sxchnge/shared/ratelimit/store"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type Flows struct {
|
||||
Onboarding *flow.Service
|
||||
Factors twofactor.API
|
||||
}
|
||||
|
||||
type PublicRouter struct {
|
||||
factors twofactor.API
|
||||
flow *flow.Service
|
||||
logger mlogger.Logger
|
||||
db account.DB
|
||||
imp *re.HttpEndpointRouter
|
||||
rtdb refreshtokens.DB
|
||||
config middleware.TokenConfig
|
||||
signature middleware.Signature
|
||||
service mservice.Type
|
||||
attempts ratelimits.Store
|
||||
limits middleware.LoginRateLimits
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func (pr *PublicRouter) InstallHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc) {
|
||||
pr.imp.InstallHandler(service, endpoint, method, handler)
|
||||
}
|
||||
|
||||
func Install(
|
||||
logger mlogger.Logger,
|
||||
apiEndpoint string,
|
||||
db account.DB,
|
||||
rtdb refreshtokens.DB,
|
||||
attempts ratelimits.Store,
|
||||
router chi.Router,
|
||||
config *middleware.TokenConfig,
|
||||
signature *middleware.Signature,
|
||||
limits middleware.LoginRateLimits,
|
||||
flows ...Flows,
|
||||
) *PublicRouter {
|
||||
l := logger.Named("public")
|
||||
hr := PublicRouter{
|
||||
logger: l,
|
||||
db: db,
|
||||
rtdb: rtdb,
|
||||
config: *config,
|
||||
signature: *signature,
|
||||
imp: re.NewHttpEndpointRouter(l, apiEndpoint, router, config, signature),
|
||||
service: mservice.Accounts,
|
||||
attempts: attempts,
|
||||
limits: limits,
|
||||
now: time.Now,
|
||||
}
|
||||
|
||||
if len(flows) > 0 {
|
||||
hr.flow = flows[0].Onboarding
|
||||
hr.factors = flows[0].Factors
|
||||
}
|
||||
if hr.flow != nil {
|
||||
hr.InstallHandler(hr.service, "/signup", api.Post, measured("signup", hr.signup))
|
||||
hr.InstallHandler(hr.service, "/verification/send", api.Post, measured("activation_send", hr.resendActivation))
|
||||
hr.InstallHandler(hr.service, "/verification/confirm", api.Post, measured("activation", hr.activate))
|
||||
}
|
||||
if hr.factors != nil {
|
||||
hr.InstallHandler(hr.service, "/login/resend", api.Post, measured("login_send", hr.resendLogin))
|
||||
hr.InstallHandler(hr.service, "/login/verify", api.Post, measured("login_verify", hr.verifyLogin))
|
||||
}
|
||||
hr.InstallHandler(hr.service, "/login", api.Post, measured("login", 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)
|
||||
hr.InstallHandler(hr.service, "/logout", api.Post, hr.logout)
|
||||
|
||||
return &hr
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"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/sessions/browser"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging/fields"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var errClientIPNotAllowed = merrors.Define("client_ip_not_allowed")
|
||||
|
||||
func isExpectedRefreshValidationError(err error) bool {
|
||||
return errors.Is(err, merrors.ErrUnauthorized)
|
||||
}
|
||||
|
||||
func validateToken(token string, rt *sessionmodel.RefreshToken) string {
|
||||
if rt.AccountRef == nil {
|
||||
return "missing account reference"
|
||||
}
|
||||
if token != rt.RefreshToken {
|
||||
return "tokens do not match"
|
||||
}
|
||||
if rt.ExpiresAt.Before(time.Now()) {
|
||||
return "token expired"
|
||||
}
|
||||
if rt.IsRevoked {
|
||||
return "token has been revoked"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (pr *PublicRouter) validateRefreshToken(ctx context.Context, r *http.Request, req *srequest.TokenRefreshRotate) (*customermodel.Account, *sresponse.TokenData, *sessionmodel.RefreshToken, error) {
|
||||
credential, err := browser.RefreshCredential(r, req.RefreshToken)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
req.RefreshToken = credential
|
||||
client, err := pr.rtdb.GetClient(ctx, req.ClientID)
|
||||
if errors.Is(err, merrors.ErrNoData) || client == nil {
|
||||
pr.logger.Debug("Refresh token rejected: client not found", zap.String("client_id", req.ClientID))
|
||||
return nil, 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, nil, err
|
||||
}
|
||||
if client.IsRevoked {
|
||||
return nil, nil, nil, merrors.Unauthorized("client has been revoked")
|
||||
}
|
||||
if h := pr.validateClientIPPolicy(r, req.ClientID, client); h != nil {
|
||||
return nil, nil, nil, errClientIPNotAllowed
|
||||
}
|
||||
|
||||
rt, err := pr.rtdb.GetByCRT(ctx, req)
|
||||
if errors.Is(err, merrors.ErrNoData) || errors.Is(err, merrors.ErrAccessDenied) {
|
||||
pr.logger.Debug("Refresh session is inactive", zap.String("client_id", req.ClientID), zap.String("device_id", req.DeviceID))
|
||||
return nil, nil, nil, merrors.Unauthorized("refresh session is inactive")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if rt.SessionVersion == "" {
|
||||
pr.logger.Debug("Refresh token rejected: legacy session requires reauthentication",
|
||||
zap.String("client_id", req.ClientID), zap.String("device_id", req.DeviceID))
|
||||
return nil, nil, nil, merrors.Unauthorized("session reauthentication required")
|
||||
}
|
||||
|
||||
if reason := validateToken(req.RefreshToken, rt); len(reason) > 0 {
|
||||
pr.logger.Debug("Token validation failed", zap.String("reason", reason))
|
||||
return nil, nil, nil, merrors.Unauthorized(reason)
|
||||
}
|
||||
|
||||
var account customermodel.Account
|
||||
if err := pr.db.Get(ctx, *rt.AccountRef, &account); errors.Is(err, merrors.ErrNoData) {
|
||||
pr.logger.Debug("User not found while rotating refresh token", zap.Error(err), mzap.AccRef(*rt.AccountRef))
|
||||
return nil, nil, nil, merrors.Unauthorized("user not found")
|
||||
} else if err != nil {
|
||||
pr.logger.Warn("Failed to fetch user while validating refresh token", zap.Error(err), mzap.AccRef(*rt.AccountRef))
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
if !account.IsActive() || account.IsArchived() {
|
||||
return nil, nil, nil, merrors.Unauthorized("customer is inactive")
|
||||
}
|
||||
if client.AccountRef != nil && *client.AccountRef != account.ID {
|
||||
return nil, nil, nil, merrors.Unauthorized("client account mismatch")
|
||||
}
|
||||
|
||||
accessToken, err := pr.imp.CreateAccessTokenForSession(&account, rt.SessionIdentifier)
|
||||
if err != nil {
|
||||
pr.logger.Warn("Failed to generate access token", zap.Error(err))
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
return &account, &accessToken, rt, nil
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
sresponse "git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
|
||||
authmodel "git.sendico.io/tech/sxchnge/product/auth/model"
|
||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
session "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
factor "git.sendico.io/tech/sxchnge/product/twofactor/model"
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
verification "git.sendico.io/tech/sxchnge/product/verification/store"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type signupRequest struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Locale string `json:"locale"`
|
||||
Key string `json:"idempotencyKey"`
|
||||
}
|
||||
type challengeRequest struct {
|
||||
ChallengeID string `json:"challengeId"`
|
||||
PendingToken string `json:"pendingToken"`
|
||||
Code string `json:"code"`
|
||||
Key string `json:"idempotencyKey"`
|
||||
Session session.SessionIdentifier `json:"sessionIdentifier"`
|
||||
}
|
||||
type challengeResponse struct {
|
||||
ChallengeID string `json:"challengeId"`
|
||||
Channel factor.Channel `json:"channel"`
|
||||
UserID string `json:"userId"`
|
||||
PendingToken sresponse.TokenData `json:"pendingToken"`
|
||||
Target string `json:"target"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
ResendAt time.Time `json:"resendAt"`
|
||||
}
|
||||
|
||||
func decodeFlow(r *http.Request, dst any) error {
|
||||
d := json.NewDecoder(io.LimitReader(r.Body, 8193))
|
||||
d.DisallowUnknownFields()
|
||||
if err := d.Decode(dst); err != nil {
|
||||
return merrors.InvalidArgument("invalid request")
|
||||
}
|
||||
var extra any
|
||||
if err := d.Decode(&extra); err != io.EOF {
|
||||
return merrors.InvalidArgument("invalid request")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func requestIP(r *http.Request) string {
|
||||
if ip := ipguard.ClientIP(r); ip != nil {
|
||||
return ip.String()
|
||||
}
|
||||
return "unresolved"
|
||||
}
|
||||
func (pr *PublicRouter) flowError(err error) http.HandlerFunc {
|
||||
if details, ok := verification.Throttle(err); ok {
|
||||
return pr.loginRateLimited(details.RetryAfter)
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, verification.ErrTokenExpired):
|
||||
return response.BadRequest(pr.logger, pr.service, "expired", "Verification expired; request a new email")
|
||||
case errors.Is(err, verification.ErrTokenAttemptsExceeded):
|
||||
return response.BadRequest(pr.logger, pr.service, "locked", "Too many attempts; request a new code")
|
||||
case errors.Is(err, verification.ErrTokenNotFound), errors.Is(err, verification.ErrTokenAlreadyUsed):
|
||||
return response.BadRequest(pr.logger, pr.service, "invalid", "Verification is invalid or already used")
|
||||
case errors.Is(err, merrors.ErrDataConflict):
|
||||
return response.BadRequest(pr.logger, pr.service, "email_in_use", "Email is already registered")
|
||||
case errors.Is(err, merrors.ErrInvalidArg):
|
||||
return response.BadRequest(pr.logger, pr.service, "invalid_request", "Check the request fields")
|
||||
case errors.Is(err, merrors.ErrUnauthorized):
|
||||
return response.Unauthorized(pr.logger, pr.service, "credentials required")
|
||||
default:
|
||||
// Do not include downstream errors: they may contain transport/request details.
|
||||
pr.logger.Warn("Authentication flow dependency failed")
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = w.Write([]byte(`{"data":{"code":"unavailable","details":"Unable to complete verification. Please retry."}}`))
|
||||
}
|
||||
}
|
||||
}
|
||||
func (pr *PublicRouter) signup(r *http.Request) http.HandlerFunc {
|
||||
var req signupRequest
|
||||
if err := decodeFlow(r, &req); err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
if req.Key == "" || len(req.Key) > 128 {
|
||||
return pr.flowError(merrors.InvalidArgument("idempotency key required"))
|
||||
}
|
||||
a := &customer.Account{Password: req.Password}
|
||||
a.Name = req.Name
|
||||
a.Login = req.Email
|
||||
a.Locale = req.Locale
|
||||
a, err := pr.flow.Signup(r.Context(), a, requestIP(r), req.Key)
|
||||
if err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
pr.logger.Info("Signup verification queued", zap.String("account_ref", a.ID.Hex()))
|
||||
return response.Accepted(pr.logger, map[string]string{"userId": a.ID.Hex()})
|
||||
}
|
||||
func (pr *PublicRouter) resendActivation(r *http.Request) http.HandlerFunc {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
Key string `json:"idempotencyKey"`
|
||||
}
|
||||
if err := decodeFlow(r, &req); err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
if req.Key == "" || len(req.Key) > 128 {
|
||||
return pr.flowError(merrors.InvalidArgument("idempotency key required"))
|
||||
}
|
||||
if err := pr.flow.ResendActivation(r.Context(), req.Email, requestIP(r), req.Key); err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
return response.Accepted(pr.logger, map[string]bool{"accepted": true})
|
||||
}
|
||||
func (pr *PublicRouter) activate(r *http.Request) http.HandlerFunc {
|
||||
var req struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := decodeFlow(r, &req); err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
if err := pr.flow.Activate(r.Context(), req.Token); err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
pr.logger.Info("Account activation completed")
|
||||
return response.Accepted(pr.logger, map[string]bool{"verified": true})
|
||||
}
|
||||
func challengeScope(raw string) string {
|
||||
h := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
func (pr *PublicRouter) beginLogin(r *http.Request, a *customer.Account, sessionID session.SessionIdentifier, key string) http.HandlerFunc {
|
||||
if sessionID.ClientID == "" || sessionID.DeviceID == "" || len(sessionID.DeviceID) > 128 {
|
||||
return pr.flowError(merrors.InvalidArgument("session identifier required"))
|
||||
}
|
||||
claims := authmodel.PendingAccount2Claims(a, pendingLoginTTLMinutes)
|
||||
claims["clientId"] = sessionID.ClientID
|
||||
claims["deviceId"] = sessionID.DeviceID
|
||||
// Independent challenges, even when password requests arrive within one second.
|
||||
claims["nonce"] = randomKey()
|
||||
ja := jwtauth.New(pr.signature.Algorithm, pr.signature.PrivateKey, pr.signature.PublicKey)
|
||||
_, raw, err := ja.Encode(claims)
|
||||
if err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
token := sresponse.TokenData{Token: raw, Expiration: time.Unix(claims["exp"].(int64), 0)}
|
||||
return pr.queueLogin(r, a, token, key, "")
|
||||
}
|
||||
func randomKey() string { return rand.Text() }
|
||||
func loginScope(raw string) factor.Scope {
|
||||
return factor.Scope{Purpose: verificationmodel.PurposeLogin, ContextRef: challengeScope(raw)}
|
||||
}
|
||||
func (pr *PublicRouter) queueLogin(r *http.Request, a *customer.Account, token sresponse.TokenData, key, id string) http.HandlerFunc {
|
||||
if pr.factors == nil {
|
||||
return pr.flowError(merrors.ServiceUnavailable("second-factor service unavailable"))
|
||||
}
|
||||
req := factor.Request{Subject: a.ID, Scope: loginScope(token.Token), Deadline: token.Expiration, IdempotencyKey: key, IP: requestIP(r)}
|
||||
var challenge factor.Challenge
|
||||
var err error
|
||||
if id == "" {
|
||||
challenge, err = pr.factors.Begin(r.Context(), req)
|
||||
} else {
|
||||
challenge, err = pr.factors.Resend(r.Context(), req, id)
|
||||
}
|
||||
if err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
return response.Accepted(pr.logger, challengeResponse{ChallengeID: challenge.ID, Channel: challenge.Channel, UserID: a.ID.Hex(), PendingToken: token, Target: challenge.Target, ExpiresAt: challenge.ExpiresAt, ResendAt: challenge.ResendAt})
|
||||
}
|
||||
func (pr *PublicRouter) pending(ctx context.Context, raw string) (*customer.Account, *authmodel.AccountToken, error) {
|
||||
if len(raw) > 8192 {
|
||||
return nil, nil, merrors.Unauthorized("invalid pending token")
|
||||
}
|
||||
ja := jwtauth.New(pr.signature.Algorithm, pr.signature.PrivateKey, pr.signature.PublicKey)
|
||||
t, err := jwtauth.VerifyToken(ja, raw)
|
||||
if err != nil {
|
||||
return nil, nil, merrors.Unauthorized("invalid pending token")
|
||||
}
|
||||
_, claims, err := jwtauth.FromContext(jwtauth.NewContext(ctx, t, nil))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
token, err := authmodel.Claims2Token(claims)
|
||||
if err != nil || !token.Pending || token.ClientID == "" || token.DeviceID == "" {
|
||||
return nil, nil, merrors.Unauthorized("pending login required")
|
||||
}
|
||||
a := &customer.Account{}
|
||||
if err = pr.db.Get(ctx, token.AccountRef, a); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !a.IsActive() || a.IsArchived() {
|
||||
return nil, nil, merrors.Unauthorized("inactive account")
|
||||
}
|
||||
return a, token, nil
|
||||
}
|
||||
func (pr *PublicRouter) resendLogin(r *http.Request) http.HandlerFunc {
|
||||
var req challengeRequest
|
||||
if err := decodeFlow(r, &req); err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
if req.ChallengeID == "" {
|
||||
return pr.flowError(merrors.InvalidArgument("challenge ID required"))
|
||||
}
|
||||
a, t, err := pr.pending(r.Context(), req.PendingToken)
|
||||
if err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
return pr.queueLogin(r, a, sresponse.TokenData{Token: req.PendingToken, Expiration: t.Expiration}, req.Key, req.ChallengeID)
|
||||
}
|
||||
func (pr *PublicRouter) verifyLogin(r *http.Request) http.HandlerFunc {
|
||||
var req challengeRequest
|
||||
if err := decodeFlow(r, &req); err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
a, t, err := pr.pending(r.Context(), req.PendingToken)
|
||||
if err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
client, h := pr.resolveLoginClient(r.Context(), r, t.ClientID)
|
||||
if h != nil {
|
||||
return h
|
||||
}
|
||||
if h = pr.validateClientAccountBinding(client, a); h != nil {
|
||||
return h
|
||||
}
|
||||
if pr.factors == nil {
|
||||
return pr.flowError(merrors.ServiceUnavailable("second-factor service unavailable"))
|
||||
}
|
||||
scope := loginScope(req.PendingToken)
|
||||
var result http.HandlerFunc
|
||||
err = pr.factors.Complete(r.Context(), a.ID, scope, req.ChallengeID, req.Code, func(tx context.Context) error {
|
||||
var err error
|
||||
result, err = pr.prepareLogin(tx, r, &t.SessionIdentifier, a)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
pr.logger.Info("Login completed after second-factor proof consumption", zap.String("account_ref", a.ID.Hex()))
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
middleware "git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
||||
authstorage "git.sendico.io/tech/sxchnge/product/auth/storage"
|
||||
accountservice "git.sendico.io/tech/sxchnge/product/customers"
|
||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
||||
session "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"git.sendico.io/tech/sxchnge/product/twofactor"
|
||||
"git.sendico.io/tech/sxchnge/product/twofactor/delivery"
|
||||
factorstore "git.sendico.io/tech/sxchnge/product/twofactor/store"
|
||||
db "git.sendico.io/tech/sxchnge/shared/database"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type capturedMail struct {
|
||||
messages []notifications.Message
|
||||
fail bool
|
||||
}
|
||||
|
||||
func (m *capturedMail) Send(_ context.Context, r notifications.Message) error {
|
||||
m.messages = append(m.messages, r)
|
||||
if m.fail {
|
||||
return merrors.ServiceUnavailable("test queue unavailable")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
container, err := mongodb.Run(ctx, "mongo:latest", mongodb.WithReplicaSet("rs"))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
|
||||
uri, err := container.ConnectionString(ctx)
|
||||
require.NoError(t, err)
|
||||
u, err := url.Parse(uri)
|
||||
require.NoError(t, err)
|
||||
q := u.Query()
|
||||
q.Set("directConnection", "true")
|
||||
u.RawQuery = q.Encode()
|
||||
factory, err := db.NewConnection(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: sharedmodel.SettingsT{"uri": u.String(), "database": "auth_flow"}})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(factory.CloseConnection)
|
||||
stores, err := authstorage.OpenStores(zap.NewNop(), factory, nil)
|
||||
require.NoError(t, err)
|
||||
customers, err := accountservice.NewAccountService(zap.NewNop(), factory, &middleware.PasswordConfig{Check: middleware.PasswordChecks{MinLength: 12}}, nil)
|
||||
require.NoError(t, err)
|
||||
mail := &capturedMail{}
|
||||
service := &flow.Service{Accounts: stores.Accounts, Customers: customers, Tokens: stores.Verification, Transactions: factory.TransactionFactory(), Attempts: stores.LoginAttempts, Sender: mail, Secret: []byte(strings.Repeat("s", 32)), ActivationURL: "https://app.example/verify-email"}
|
||||
require.NoError(t, service.Validate())
|
||||
// Keep the real verification/customer stores and capture only session writes.
|
||||
sessions := &testRefreshTokenDB{client: &session.Client{ClientID: "web"}}
|
||||
records, err := factorstore.Open(zap.NewNop(), factory)
|
||||
require.NoError(t, err)
|
||||
factors, err := twofactor.New(stores.Accounts, stores.Verification, records, factory.TransactionFactory(), service.Secret, zap.NewNop(), delivery.Email{Sender: mail})
|
||||
require.NoError(t, err)
|
||||
routes := chi.NewRouter()
|
||||
Install(zap.NewNop(), "/api/v1", stores.Accounts, sessions, nil, routes, &middleware.TokenConfig{Length: 32, Expiration: middleware.TokenExpiration{Account: 1, Refresh: 24}}, &middleware.Signature{Algorithm: "HS256", PrivateKey: service.Secret}, middleware.LoginRateLimits{}, Flows{Onboarding: service, Factors: factors})
|
||||
post := func(path string, body any) *httptest.ResponseRecorder {
|
||||
raw, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/v1/accounts/"+path, strings.NewReader(string(raw)))
|
||||
r.RemoteAddr = "192.0.2.1:1234"
|
||||
w := httptest.NewRecorder()
|
||||
routes.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
signup := map[string]any{"name": "Customer", "email": "USER@example.com", "password": " correct password ", "locale": "ru", "idempotencyKey": "signup-one"}
|
||||
w := post("signup", signup)
|
||||
require.Equal(t, 202, w.Code, w.Body.String())
|
||||
require.Len(t, mail.messages, 1)
|
||||
require.Equal(t, "auth.activation", mail.messages[0].Template)
|
||||
require.Equal(t, "user@example.com", mail.messages[0].Email)
|
||||
require.Equal(t, "ru", mail.messages[0].Locale)
|
||||
activationURL, err := url.Parse(mail.messages[0].Parameters["url"])
|
||||
require.NoError(t, err)
|
||||
activation := activationURL.Query().Get("token")
|
||||
require.NotEmpty(t, activation)
|
||||
a, err := stores.Accounts.GetByEmail(ctx, "user@example.com")
|
||||
require.NoError(t, err)
|
||||
require.False(t, a.IsActive())
|
||||
login := map[string]any{"login": map[string]string{"login": "user@example.com", "password": " correct password "}, "clientId": "web", "deviceId": "browser"}
|
||||
require.Equal(t, 403, post("login", login).Code)
|
||||
require.Equal(t, 400, post("verification/confirm", map[string]string{"token": "invalid"}).Code)
|
||||
w = post("verification/confirm", map[string]string{"token": activation})
|
||||
require.Equal(t, 202, w.Code, w.Body.String())
|
||||
require.Equal(t, 400, post("verification/confirm", map[string]string{"token": activation}).Code)
|
||||
a, err = stores.Accounts.GetByEmail(ctx, a.Login)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, customer.AccountActive, a.Status)
|
||||
w = post("login", login)
|
||||
require.Equal(t, 202, w.Code, w.Body.String())
|
||||
require.Len(t, mail.messages, 2)
|
||||
require.Empty(t, sessions.tokens)
|
||||
var pending struct {
|
||||
Data challengeResponse `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &pending))
|
||||
require.NotEmpty(t, pending.Data.PendingToken.Token)
|
||||
require.NotContains(t, w.Body.String(), mail.messages[1].Parameters["code"])
|
||||
code := mail.messages[1].Parameters["code"]
|
||||
require.Len(t, code, 6)
|
||||
wrong := "000000"
|
||||
if code == wrong {
|
||||
wrong = "111111"
|
||||
}
|
||||
require.Equal(t, 400, post("login/verify", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "challengeId": pending.Data.ChallengeID, "code": wrong}).Code)
|
||||
require.Empty(t, sessions.tokens)
|
||||
require.Equal(t, 429, post("login/resend", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "challengeId": pending.Data.ChallengeID, "idempotencyKey": "resend-too-soon"}).Code)
|
||||
w = post("login/verify", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "challengeId": pending.Data.ChallengeID, "code": code})
|
||||
require.Equal(t, 200, w.Code, w.Body.String())
|
||||
require.Len(t, sessions.tokens, 1)
|
||||
require.Equal(t, "browser", sessions.tokens[0].DeviceID)
|
||||
require.Equal(t, 400, post("login/verify", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "challengeId": pending.Data.ChallengeID, "code": code}).Code)
|
||||
require.Len(t, sessions.tokens, 1)
|
||||
// A queue outage cannot report a successful login challenge or create a session.
|
||||
mail.fail = true
|
||||
require.Equal(t, 503, post("login", login).Code)
|
||||
require.Len(t, sessions.tokens, 1)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package srequest
|
||||
|
||||
import sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
|
||||
import customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
|
||||
type Login struct {
|
||||
sessionmodel.SessionIdentifier `json:",inline"`
|
||||
customermodel.LoginData `json:"login"`
|
||||
ClientSecret string `json:"clientSecret,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package srequest
|
||||
|
||||
import sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
|
||||
type AccessTokenRefresh = sessionmodel.ClientRefreshToken
|
||||
@@ -0,0 +1,5 @@
|
||||
package srequest
|
||||
|
||||
import sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
|
||||
type TokenRefreshRotate = sessionmodel.ClientRefreshToken
|
||||
@@ -0,0 +1,5 @@
|
||||
package srequest
|
||||
|
||||
type Validatable interface {
|
||||
Validate() error
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package srequest
|
||||
|
||||
import sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
|
||||
type VerificationCodeRequest struct {
|
||||
Purpose string `json:"purpose"`
|
||||
Target string `json:"target,omitempty"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
}
|
||||
|
||||
type CodeVerificationRequest struct {
|
||||
VerificationCodeRequest `json:",inline"`
|
||||
Code string `json:"code"`
|
||||
SessionIdentifier sessionmodel.SessionIdentifier `json:"sessionIdentifier"`
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package sresponse
|
||||
|
||||
import (
|
||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
)
|
||||
|
||||
type AccountData struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
LastName string `json:"lastName"`
|
||||
Login string `json:"login"`
|
||||
Locale string `json:"locale"`
|
||||
AvatarURL *string `json:"avatarUrl,omitempty"`
|
||||
}
|
||||
|
||||
type accountResponse struct {
|
||||
authResponse `json:",inline"`
|
||||
Account AccountData `json:"account"`
|
||||
}
|
||||
|
||||
func NewAccountData(account *customermodel.Account) *AccountData {
|
||||
return &AccountData{
|
||||
ID: account.ID.Hex(), Name: account.Name, LastName: account.LastName,
|
||||
Login: account.Login, Locale: account.Locale, AvatarURL: account.AvatarURL,
|
||||
}
|
||||
}
|
||||
|
||||
func Account(logger mlogger.Logger, account *customermodel.Account, accessToken *TokenData) http.HandlerFunc {
|
||||
return response.Ok(
|
||||
logger,
|
||||
&accountResponse{
|
||||
Account: *NewAccountData(account),
|
||||
authResponse: authResponse{AccessToken: *accessToken},
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package sresponse
|
||||
|
||||
type AuthResponse struct {
|
||||
AccessToken TokenData `json:"accessToken"`
|
||||
}
|
||||
|
||||
type authResponse = AuthResponse
|
||||
@@ -0,0 +1,16 @@
|
||||
package sresponse
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
)
|
||||
|
||||
type fileUploaded struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func FileUploaded(logger mlogger.Logger, url string) http.HandlerFunc {
|
||||
return response.Ok(logger, &fileUploaded{URL: url})
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package sresponse
|
||||
|
||||
import (
|
||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/browser"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
)
|
||||
|
||||
type loginResponse struct {
|
||||
accountResponse
|
||||
RefreshToken TokenData `json:"refreshToken"`
|
||||
}
|
||||
|
||||
func Login(logger mlogger.Logger, account *customermodel.Account, accessToken, refreshToken *TokenData) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
responseRefreshToken := *refreshToken
|
||||
if browser.UsesCookie(r) {
|
||||
browser.SetRefreshCookie(w, r, refreshToken.Token, refreshToken.Expiration)
|
||||
responseRefreshToken.Token = ""
|
||||
}
|
||||
response.Ok(
|
||||
logger,
|
||||
&loginResponse{
|
||||
accountResponse: accountResponse{
|
||||
Account: *NewAccountData(account),
|
||||
authResponse: authResponse{AccessToken: *accessToken},
|
||||
},
|
||||
RefreshToken: responseRefreshToken,
|
||||
},
|
||||
).ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package sresponse
|
||||
|
||||
import (
|
||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
)
|
||||
|
||||
type pendingLoginResponse struct {
|
||||
Account accountResponse `json:"account"`
|
||||
PendingToken TokenData `json:"pendingToken"`
|
||||
Target string `json:"target"`
|
||||
}
|
||||
|
||||
func LoginPending(logger mlogger.Logger, account *customermodel.Account, pendingToken *TokenData, target string) http.HandlerFunc {
|
||||
return response.Accepted(
|
||||
logger,
|
||||
&pendingLoginResponse{
|
||||
Account: accountResponse{
|
||||
Account: *NewAccountData(account),
|
||||
authResponse: authResponse{},
|
||||
},
|
||||
PendingToken: *pendingToken,
|
||||
Target: target,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package sresponse
|
||||
|
||||
import (
|
||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/browser"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestBrowserLoginMovesRefreshCredentialToHttpOnlyCookie(t *testing.T) {
|
||||
refresh := &TokenData{Token: "refresh-secret", Expiration: time.Now().Add(time.Hour)}
|
||||
access := &TokenData{Token: "access-secret", Expiration: time.Now().Add(time.Minute)}
|
||||
req := httptest.NewRequest(http.MethodPost, "http://app.sendico.io/api/v1/accounts/login", nil)
|
||||
req.Header.Set(browser.TransportHeader, browser.TransportCookie)
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
Login(zap.NewNop(), &customermodel.Account{}, access, refresh).ServeHTTP(recorder, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
require.NotContains(t, recorder.Body.String(), refresh.Token)
|
||||
require.Contains(t, recorder.Body.String(), access.Token)
|
||||
setCookie := recorder.Header().Get("Set-Cookie")
|
||||
require.True(t, strings.HasPrefix(setCookie, browser.RefreshCookie+"="))
|
||||
require.Contains(t, setCookie, "HttpOnly")
|
||||
require.Contains(t, setCookie, "Secure")
|
||||
require.Contains(t, setCookie, "SameSite=Strict")
|
||||
}
|
||||
|
||||
func TestNativeLoginKeepsRefreshCredentialInResponse(t *testing.T) {
|
||||
refresh := &TokenData{Token: "native-refresh", Expiration: time.Now().Add(time.Hour)}
|
||||
access := &TokenData{Token: "native-access", Expiration: time.Now().Add(time.Minute)}
|
||||
req := httptest.NewRequest(http.MethodPost, "http://native.test/api/v1/accounts/login", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
Login(zap.NewNop(), &customermodel.Account{}, access, refresh).ServeHTTP(recorder, req)
|
||||
|
||||
require.Contains(t, recorder.Body.String(), refresh.Token)
|
||||
require.Empty(t, recorder.Header().Get("Set-Cookie"))
|
||||
}
|
||||
|
||||
func TestCustomerResponseExcludesStorageAndPlatformFields(t *testing.T) {
|
||||
account := &customermodel.Account{Password: "private-password-hash", Status: customermodel.AccountActive}
|
||||
account.Login = "customer@example.test"
|
||||
account.Name = "Customer"
|
||||
recorder := httptest.NewRecorder()
|
||||
Account(zap.NewNop(), account, &TokenData{}).ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/accounts/me", nil))
|
||||
require.Contains(t, recorder.Body.String(), "customer@example.test")
|
||||
for _, field := range []string{"private-password-hash", "password", "status\":\"active", "organization", "role", "policy", "isAnonymous", "createdAt", "isArchived"} {
|
||||
require.NotContains(t, recorder.Body.String(), field)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package sresponse
|
||||
|
||||
import (
|
||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
"net/http"
|
||||
|
||||
emodel "git.sendico.io/tech/sxchnge/product/auth/model"
|
||||
)
|
||||
|
||||
type (
|
||||
HandlerFunc = func(r *http.Request) http.HandlerFunc
|
||||
AccountHandlerFunc = func(r *http.Request, account *customermodel.Account, accessToken *TokenData) http.HandlerFunc
|
||||
PendingAccountHandlerFunc = func(r *http.Request, account *customermodel.Account, token *emodel.AccountToken) http.HandlerFunc
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
package sresponse
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
)
|
||||
|
||||
type resultAuth struct {
|
||||
authResponse `json:",inline"`
|
||||
response.Result `json:",inline"`
|
||||
}
|
||||
|
||||
func Success(logger mlogger.Logger, accessToken *TokenData) http.HandlerFunc {
|
||||
return response.Ok(logger, &resultAuth{
|
||||
Result: response.Result{Result: true},
|
||||
authResponse: authResponse{AccessToken: *accessToken},
|
||||
})
|
||||
}
|
||||
|
||||
func Failed(logger mlogger.Logger, accessToken *TokenData) http.HandlerFunc {
|
||||
return response.Accepted(logger, &resultAuth{
|
||||
Result: response.Result{Result: false},
|
||||
authResponse: authResponse{AccessToken: *accessToken},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package sresponse
|
||||
|
||||
import "time"
|
||||
|
||||
type TokenData struct {
|
||||
Token string `json:"token"`
|
||||
Expiration time.Time `json:"expiration"`
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package sresponse
|
||||
|
||||
type VerificationResponse struct {
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
TTLSeconds int `json:"ttl_seconds"`
|
||||
CooldownSeconds int `json:"cooldown_seconds"`
|
||||
Target string `json:"target"`
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/product/auth/http/v1/response"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
)
|
||||
|
||||
type APIRouter interface {
|
||||
InstallHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc)
|
||||
}
|
||||
|
||||
type ProtectedAPIRouter interface {
|
||||
AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc)
|
||||
PendingAccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.PendingAccountHandlerFunc)
|
||||
}
|
||||
Reference in new issue
Block a user