Organize backend into product executable and shared Go modules
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user