Organize backend into product executable and shared Go modules

This commit is contained in:
Stephan D committed 2026-09-12 14:14:08 +02:00
1 parent 981e84a4b4
commit f0720ec66b
382 files changed
+1944 -6989

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"
emodel "git.sendico.io/tech/sxchnge/product/auth/model"
"git.sendico.io/tech/sxchnge/product/auth/response"
"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 := NewRouter(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/ipguard"
emodel "git.sendico.io/tech/sxchnge/product/auth/model"
"git.sendico.io/tech/sxchnge/product/auth/response"
"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/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 NewRouter(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)
}
}