Bootstrap copied BFF and local accounts with public Sendico contracts (#34)
This commit is contained in:
1 parent
67dc5b5b1e
commit
7062672a87
556 files changed
+42291
-777
No files matched your search
@@ -0,0 +1,119 @@
|
||||
package accountserviceimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/organization"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// fakeMembershipOrgDB answers membership from its own state, standing in for
|
||||
// the transaction-scoped reads a real repository would serve.
|
||||
type fakeMembershipOrgDB struct {
|
||||
organization.DB
|
||||
member bool
|
||||
isMemberCalls int
|
||||
addMemberCalls int
|
||||
addedMember bson.ObjectID
|
||||
}
|
||||
|
||||
func (db *fakeMembershipOrgDB) IsMember(_ context.Context, _, _ bson.ObjectID) (bool, error) {
|
||||
db.isMemberCalls++
|
||||
return db.member, nil
|
||||
}
|
||||
|
||||
func (db *fakeMembershipOrgDB) AddMember(_ context.Context, _, accountRef bson.ObjectID) (bool, error) {
|
||||
db.addMemberCalls++
|
||||
db.addedMember = accountRef
|
||||
db.member = true
|
||||
return true, nil
|
||||
}
|
||||
|
||||
type recordingRoleManager struct {
|
||||
assigned []*model.Role
|
||||
}
|
||||
|
||||
func (m *recordingRoleManager) Create(_ context.Context, orgRef bson.ObjectID, _ *model.Describable) (*model.RoleDescription, error) {
|
||||
description := &model.RoleDescription{}
|
||||
description.SetID(bson.NewObjectID())
|
||||
description.OrganizationRef = orgRef
|
||||
return description, nil
|
||||
}
|
||||
|
||||
func (m *recordingRoleManager) Delete(_ context.Context, _ bson.ObjectID) error { return nil }
|
||||
|
||||
func (m *recordingRoleManager) Assign(_ context.Context, role *model.Role) error {
|
||||
m.assigned = append(m.assigned, role)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *recordingRoleManager) Revoke(_ context.Context, _, _, _ bson.ObjectID) error { return nil }
|
||||
|
||||
func (m *recordingRoleManager) List(_ context.Context, _ bson.ObjectID) ([]model.RoleDescription, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func joinTestService(orgDB *fakeMembershipOrgDB, roles *recordingRoleManager) *service {
|
||||
return &service{
|
||||
logger: zap.NewNop(),
|
||||
orgDB: orgDB,
|
||||
roleManager: roles,
|
||||
}
|
||||
}
|
||||
|
||||
func joinTestOrganization(members ...bson.ObjectID) *model.Organization {
|
||||
org := &model.Organization{Members: members}
|
||||
org.SetID(bson.NewObjectID())
|
||||
return org
|
||||
}
|
||||
|
||||
func joinTestAccount() *model.Account {
|
||||
account := &model.Account{}
|
||||
account.SetID(bson.NewObjectID())
|
||||
return account
|
||||
}
|
||||
|
||||
// A transaction replay rolls back the database but leaves marks on the shared
|
||||
// in-memory organization. Membership decisions must come from the database,
|
||||
// otherwise the replay skips the role assignment and the membership write and
|
||||
// commits an organization without its owner.
|
||||
func TestJoinOrganizationDecidesMembershipByDatabase(t *testing.T) {
|
||||
account := joinTestAccount()
|
||||
// The in-memory list already carries the account: exactly the state a
|
||||
// first, aborted transaction attempt leaves behind.
|
||||
org := joinTestOrganization(account.ID)
|
||||
orgDB := &fakeMembershipOrgDB{member: false}
|
||||
roles := &recordingRoleManager{}
|
||||
svc := joinTestService(orgDB, roles)
|
||||
|
||||
if err := svc.JoinOrganization(context.Background(), org, account, bson.NewObjectID()); err != nil {
|
||||
t.Fatalf("join failed: %v", err)
|
||||
}
|
||||
if len(roles.assigned) != 1 {
|
||||
t.Fatalf("role assignment mismatch: got=%d want=1", len(roles.assigned))
|
||||
}
|
||||
if orgDB.addMemberCalls != 1 || orgDB.addedMember != account.ID {
|
||||
t.Fatalf("membership write mismatch: calls=%d member=%s", orgDB.addMemberCalls, orgDB.addedMember.Hex())
|
||||
}
|
||||
if len(org.Members) != 1 {
|
||||
t.Fatalf("in-memory members must stay deduplicated: got=%v", org.Members)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJoinOrganizationSkipsExistingMember(t *testing.T) {
|
||||
account := joinTestAccount()
|
||||
org := joinTestOrganization()
|
||||
orgDB := &fakeMembershipOrgDB{member: true}
|
||||
roles := &recordingRoleManager{}
|
||||
svc := joinTestService(orgDB, roles)
|
||||
|
||||
if err := svc.JoinOrganization(context.Background(), org, account, bson.NewObjectID()); err != nil {
|
||||
t.Fatalf("join failed: %v", err)
|
||||
}
|
||||
if len(roles.assigned) != 0 || orgDB.addMemberCalls != 0 {
|
||||
t.Fatalf("an existing member must not be re-joined: roles=%d adds=%d", len(roles.assigned), orgDB.addMemberCalls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
package accountserviceimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
|
||||
edgestorage "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/mongo"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/organization"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/verification"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/management"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/account"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/policy"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type service struct {
|
||||
logger mlogger.Logger
|
||||
accountDB account.DB
|
||||
orgDB organization.DB
|
||||
enforcer auth.Enforcer
|
||||
roleManager management.Role
|
||||
config *middleware.PasswordConfig
|
||||
|
||||
policyDB policy.DB
|
||||
vdb verification.DB
|
||||
}
|
||||
|
||||
func validateUserRequest(u *model.Account) error {
|
||||
if u.Name == "" {
|
||||
return merrors.InvalidArgument("Name must not be empty")
|
||||
}
|
||||
if u.Login == "" {
|
||||
return merrors.InvalidArgument("Login must not be empty")
|
||||
}
|
||||
if u.Password == "" {
|
||||
return merrors.InvalidArgument("Password must not be empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) ValidatePassword(
|
||||
password string,
|
||||
oldPassword *string,
|
||||
) error {
|
||||
var hasDigit, hasUpper, hasLower, hasSpecial bool
|
||||
|
||||
if oldPassword != nil {
|
||||
if *oldPassword == password {
|
||||
return merrors.InvalidArgument("New password cannot be the same as the old password")
|
||||
}
|
||||
}
|
||||
|
||||
if len(password) < s.config.Check.MinLength {
|
||||
return merrors.InvalidArgument(fmt.Sprintf("Password must be at least %d characters long", s.config.Check.MinLength))
|
||||
}
|
||||
|
||||
// Check for digit, uppercase, lowercase, and special character
|
||||
for _, char := range password {
|
||||
switch {
|
||||
case unicode.IsDigit(char):
|
||||
hasDigit = true
|
||||
case unicode.IsUpper(char):
|
||||
hasUpper = true
|
||||
case unicode.IsLower(char):
|
||||
hasLower = true
|
||||
case unicode.IsPunct(char) || unicode.IsSymbol(char):
|
||||
hasSpecial = true
|
||||
}
|
||||
}
|
||||
|
||||
if s.config.Check.Digit && !hasDigit {
|
||||
return merrors.InvalidArgument("Password must contain at least one digit")
|
||||
}
|
||||
if s.config.Check.Upper && !hasUpper {
|
||||
return merrors.InvalidArgument("Password must contain at least one uppercase letter")
|
||||
}
|
||||
if s.config.Check.Lower && !hasLower {
|
||||
return merrors.InvalidArgument("Password must contain at least one lowercase letter")
|
||||
}
|
||||
if s.config.Check.Special && !hasSpecial {
|
||||
return merrors.InvalidArgument("Password must contain at least one special character")
|
||||
}
|
||||
|
||||
// If all checks pass, return nil (no error)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) ValidateAccount(acct *model.Account) error {
|
||||
if err := validateUserRequest(acct); err != nil {
|
||||
s.logger.Warn("Invalid signup acccount received", zap.Error(err), zap.String("account", acct.Login))
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.ValidatePassword(acct.Password, nil); err != nil {
|
||||
s.logger.Warn("Password validation failed", zap.Error(err), zap.String("account", acct.Login))
|
||||
return err
|
||||
}
|
||||
|
||||
if err := acct.HashPassword(); err != nil {
|
||||
s.logger.Warn("Failed to hash password", zap.Error(err), zap.String("account", acct.Login))
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) CreateAccount(
|
||||
ctx context.Context,
|
||||
org *model.Organization,
|
||||
acct *model.Account,
|
||||
roleDescID bson.ObjectID,
|
||||
) (string, error) {
|
||||
if org == nil {
|
||||
return "", merrors.InvalidArgument("Organization must not be nil")
|
||||
}
|
||||
if acct == nil || len(acct.Login) == 0 {
|
||||
return "", merrors.InvalidArgument("Account must have a non-empty login")
|
||||
}
|
||||
if roleDescID == bson.NilObjectID {
|
||||
return "", merrors.InvalidArgument("Role description must be provided")
|
||||
}
|
||||
|
||||
// 1) Create the account
|
||||
acct.Status = model.AccountPendingVerification
|
||||
if err := s.accountDB.Create(ctx, acct); err != nil {
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
s.logger.Info("Username is already taken", zap.String("login", acct.Login))
|
||||
} else {
|
||||
s.logger.Warn("Failed to signup a user", zap.Error(err), zap.String("login", acct.Login))
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 2) Add to organization
|
||||
if err := s.JoinOrganization(ctx, org, acct, roleDescID); err != nil {
|
||||
s.logger.Warn("Failed to register new organization member", zap.Error(err), mzap.StorableRef(acct))
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 3) Issue verification token
|
||||
return s.VerifyAccount(ctx, acct)
|
||||
}
|
||||
|
||||
func (s *service) VerifyAccount(
|
||||
ctx context.Context,
|
||||
acct *model.Account,
|
||||
) (string, error) {
|
||||
token, err := s.vdb.Create(
|
||||
ctx,
|
||||
verification.NewLinkRequest(*acct.GetID(), model.PurposeAccountActivation, "").
|
||||
WithTTL(time.Hour*24),
|
||||
)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to create verification token for new account", zap.Error(err), mzap.StorableRef(acct))
|
||||
return "", err
|
||||
}
|
||||
|
||||
return token, nil
|
||||
|
||||
}
|
||||
|
||||
func (s *service) DeleteAccount(
|
||||
ctx context.Context,
|
||||
org *model.Organization,
|
||||
accountRef bson.ObjectID,
|
||||
) error {
|
||||
// Check if this is the only member in the organization
|
||||
if len(org.Members) <= 1 {
|
||||
s.logger.Warn("Cannot delete account - it's the only member in the organization",
|
||||
mzap.AccRef(accountRef), mzap.StorableRef(org))
|
||||
return merrors.InvalidArgument("Cannot delete the only member of an organization")
|
||||
}
|
||||
|
||||
// 1) Remove from organization
|
||||
if err := s.RemoveAccountFromOrganization(ctx, org, accountRef); err != nil {
|
||||
s.logger.Warn("Failed to revoke account role", zap.Error(err), mzap.AccRef(accountRef))
|
||||
return err
|
||||
}
|
||||
|
||||
// 2) Delete the account document
|
||||
if err := s.accountDB.Delete(ctx, accountRef); err != nil {
|
||||
s.logger.Warn("Failed to delete account", zap.Error(err), mzap.AccRef(accountRef))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) RemoveAccountFromOrganization(
|
||||
ctx context.Context,
|
||||
org *model.Organization,
|
||||
accountRef bson.ObjectID,
|
||||
) error {
|
||||
if org == nil {
|
||||
return merrors.InvalidArgument("Organization must not be nil")
|
||||
}
|
||||
roles, err := s.enforcer.GetRoles(ctx, accountRef, org.ID)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to fetch account permissions", zap.Error(err), mzap.StorableRef(org),
|
||||
mzap.AccRef(accountRef))
|
||||
return err
|
||||
}
|
||||
for _, role := range roles {
|
||||
if err := s.roleManager.Revoke(ctx, role.DescriptionRef, accountRef, org.ID); err != nil {
|
||||
s.logger.Warn("Failed to revoke account role", zap.Error(err),
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("role_ref", role.DescriptionRef))
|
||||
return err
|
||||
}
|
||||
}
|
||||
for i, member := range org.Members {
|
||||
if member == accountRef {
|
||||
// Remove the member by slicing it out
|
||||
org.Members = append(org.Members[:i], org.Members[i+1:]...)
|
||||
if err := s.orgDB.Update(ctx, accountRef, org); err != nil {
|
||||
s.logger.Warn("Failed to remove member from organization", zap.Error(err), mzap.AccRef(accountRef))
|
||||
return err
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) ResetPassword(
|
||||
ctx context.Context,
|
||||
acct *model.Account,
|
||||
) (string, error) {
|
||||
return s.vdb.Create(
|
||||
ctx,
|
||||
verification.NewOTPRequest(*acct.GetID(), model.PurposePasswordReset, "").
|
||||
WithTTL(time.Hour),
|
||||
)
|
||||
}
|
||||
|
||||
func (s *service) UpdateLogin(
|
||||
ctx context.Context,
|
||||
acct *model.Account,
|
||||
newLogin string,
|
||||
) (string, error) {
|
||||
return s.vdb.Create(
|
||||
ctx,
|
||||
verification.NewOTPRequest(*acct.GetID(), model.PurposeEmailChange, newLogin).
|
||||
WithTTL(time.Hour),
|
||||
)
|
||||
}
|
||||
|
||||
func (s *service) JoinOrganization(
|
||||
ctx context.Context,
|
||||
org *model.Organization,
|
||||
account *model.Account,
|
||||
roleDescID bson.ObjectID,
|
||||
) error {
|
||||
// Membership is decided by the database, not the in-memory copy: this method
|
||||
// runs inside provisioning transactions whose retries replay the callback,
|
||||
// and a stale in-memory list would silently skip the role assignment.
|
||||
member, err := s.orgDB.IsMember(ctx, org.ID, account.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if member {
|
||||
s.logger.Debug("Account is already a member", mzap.StorableRef(org), mzap.StorableRef(account))
|
||||
return nil
|
||||
}
|
||||
|
||||
role := &model.Role{
|
||||
DescriptionRef: roleDescID,
|
||||
OrganizationRef: org.ID,
|
||||
AccountRef: account.ID,
|
||||
}
|
||||
if err := s.roleManager.Assign(ctx, role); err != nil {
|
||||
s.logger.Warn("Failed to assign role to account", zap.Error(err), mzap.StorableRef(account),
|
||||
mzap.StorableRef(org), mzap.ObjRef("role_description_ref", roleDescID))
|
||||
return err
|
||||
}
|
||||
|
||||
// A point patch on purpose: replacing the whole document would clobber
|
||||
// concurrent writes, most notably the provisioning lease that guards
|
||||
// finalization against attempts whose lease was taken over.
|
||||
if _, err := s.orgDB.AddMember(ctx, org.ID, account.ID); err != nil {
|
||||
s.logger.Warn("Failed to update organization members list",
|
||||
zap.Error(err), mzap.StorableRef(account), mzap.StorableRef(org))
|
||||
return err
|
||||
}
|
||||
if !slices.Contains(org.Members, account.ID) {
|
||||
org.Members = append(org.Members, account.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) deleteOrganizationRoles(ctx context.Context, orgRef bson.ObjectID) error {
|
||||
return s.deleteOrganizationEntities(ctx, orgRef, "roles", "role", "role_ref", func(ctx context.Context, orgRef bson.ObjectID) ([]bson.ObjectID, error) {
|
||||
roles, err := s.roleManager.List(ctx, orgRef)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
roleIDs := make([]bson.ObjectID, 0, len(roles))
|
||||
for _, role := range roles {
|
||||
roleIDs = append(roleIDs, role.ID)
|
||||
}
|
||||
return roleIDs, nil
|
||||
}, s.roleManager.Delete)
|
||||
}
|
||||
|
||||
func (s *service) deleteOrganizationPolicies(ctx context.Context, orgRef bson.ObjectID) error {
|
||||
return s.deleteOrganizationEntities(ctx, orgRef, "policies", "policy", "policy_ref", func(ctx context.Context, orgRef bson.ObjectID) ([]bson.ObjectID, error) {
|
||||
policies, err := s.policyDB.All(ctx, orgRef)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
policyIDs := make([]bson.ObjectID, 0, len(policies))
|
||||
for _, policy := range policies {
|
||||
policyIDs = append(policyIDs, policy.ID)
|
||||
}
|
||||
return policyIDs, nil
|
||||
}, s.policyDB.Delete)
|
||||
}
|
||||
|
||||
func (s *service) deleteOrganizationEntities(
|
||||
ctx context.Context,
|
||||
orgRef bson.ObjectID,
|
||||
entityName string,
|
||||
entityLabel string,
|
||||
entityRefField string,
|
||||
load func(context.Context, bson.ObjectID) ([]bson.ObjectID, error),
|
||||
deleteFn func(context.Context, bson.ObjectID) error,
|
||||
) error {
|
||||
s.logger.Info(fmt.Sprintf("Deleting %s for organization", entityName), mzap.OrgRef(orgRef))
|
||||
|
||||
entityIDs, err := load(ctx, orgRef)
|
||||
if err != nil && !errors.Is(err, merrors.ErrNoData) {
|
||||
s.logger.Warn(fmt.Sprintf("Failed to fetch %s for deletion", entityName), zap.Error(err), mzap.OrgRef(orgRef))
|
||||
return err
|
||||
}
|
||||
|
||||
for _, entityID := range entityIDs {
|
||||
if err := deleteFn(ctx, entityID); err != nil {
|
||||
s.logger.Warn(fmt.Sprintf("Failed to delete %s", entityLabel), zap.Error(err), mzap.ObjRef(entityRefField, entityID))
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
s.logger.Info(fmt.Sprintf("Successfully deleted %s", entityName), zap.Int("count", len(entityIDs)), mzap.OrgRef(orgRef))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) DeleteOrganization(
|
||||
ctx context.Context,
|
||||
org *model.Organization,
|
||||
) error {
|
||||
s.logger.Info("Starting organization deletion", mzap.StorableRef(org))
|
||||
|
||||
// Use transaction to ensure atomicity
|
||||
// 8. Delete all roles and role descriptions in the organization
|
||||
if err := s.deleteOrganizationRoles(ctx, org.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 9. Delete all policies in the organization
|
||||
if err := s.deleteOrganizationPolicies(ctx, org.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 10. Finally, delete the organization itself
|
||||
if err := s.orgDB.Delete(ctx, bson.NilObjectID, org.ID); err != nil {
|
||||
s.logger.Warn("Failed to delete organization", zap.Error(err), mzap.StorableRef(org))
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("Organization deleted successfully", mzap.StorableRef(org))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) DeleteAll(
|
||||
ctx context.Context,
|
||||
org *model.Organization,
|
||||
accountRef bson.ObjectID,
|
||||
) error {
|
||||
s.logger.Info("Starting complete deletion (organization + account)",
|
||||
mzap.StorableRef(org), mzap.AccRef(accountRef))
|
||||
|
||||
// 1. First delete the organization and all its data
|
||||
if err := s.DeleteOrganization(ctx, org); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. Then delete the account
|
||||
if err := s.accountDB.Delete(ctx, accountRef); err != nil {
|
||||
s.logger.Warn("Failed to delete account", zap.Error(err), mzap.AccRef(accountRef))
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("Complete deletion successful", mzap.StorableRef(org), mzap.AccRef(accountRef))
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewAccountService wires in your logger plus the three dependencies.
|
||||
func NewAccountService(
|
||||
l mlogger.Logger,
|
||||
dbf db.Factory,
|
||||
enforcer auth.Enforcer,
|
||||
ra management.Role,
|
||||
config *middleware.PasswordConfig,
|
||||
) (*service, error) {
|
||||
logger := l.Named("account_service")
|
||||
|
||||
if config == nil {
|
||||
return nil, merrors.Internal("Invalid account service configuration provides")
|
||||
}
|
||||
|
||||
res := &service{
|
||||
logger: logger,
|
||||
enforcer: enforcer,
|
||||
roleManager: ra,
|
||||
config: config,
|
||||
}
|
||||
deps, err := edgestorage.ResolveDependencies(dbf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.accountDB, err = dbf.NewAccountDB(); err != nil {
|
||||
logger.Error("Failed to create accounts database", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
if res.orgDB, err = organization.Create(context.Background(), logger, deps.Enforcer, deps.Policies, deps.Database); err != nil {
|
||||
logger.Error("Failed to create organizations database", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Initialize database dependencies for cascade deletion
|
||||
if res.policyDB, err = dbf.NewPoliciesDB(); err != nil {
|
||||
logger.Error("Failed to create policies database", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
if res.vdb, err = verification.Create(logger, deps.Database, deps.Transactions); err != nil {
|
||||
logger.Error("Failed to create verification database", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package accountserviceimp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func TestDeleteAccount_Validation(t *testing.T) {
|
||||
t.Run("DeleteAccount_LastMemberFails", func(t *testing.T) {
|
||||
orgID := bson.NewObjectID()
|
||||
accountID := bson.NewObjectID()
|
||||
|
||||
org := &model.Organization{
|
||||
OrganizationBase: model.OrganizationBase{
|
||||
Describable: model.Describable{Name: "Single Member Org"},
|
||||
},
|
||||
Members: []bson.ObjectID{accountID}, // Only one member
|
||||
}
|
||||
org.ID = orgID
|
||||
|
||||
// This should fail because it's the only member
|
||||
err := validateDeleteAccount(org)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Cannot delete the only member")
|
||||
})
|
||||
|
||||
t.Run("DeleteAccount_MultipleMembersSuccess", func(t *testing.T) {
|
||||
orgID := bson.NewObjectID()
|
||||
accountID := bson.NewObjectID()
|
||||
otherAccountID := bson.NewObjectID()
|
||||
|
||||
org := &model.Organization{
|
||||
OrganizationBase: model.OrganizationBase{
|
||||
Describable: model.Describable{Name: "Multi Member Org"},
|
||||
},
|
||||
Members: []bson.ObjectID{accountID, otherAccountID}, // Multiple members
|
||||
}
|
||||
org.ID = orgID
|
||||
|
||||
// This should succeed because there are multiple members
|
||||
err := validateDeleteAccount(org)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("DeleteAccount_EmptyMembersList", func(t *testing.T) {
|
||||
orgID := bson.NewObjectID()
|
||||
|
||||
org := &model.Organization{
|
||||
OrganizationBase: model.OrganizationBase{
|
||||
Describable: model.Describable{Name: "Empty Org"},
|
||||
},
|
||||
Members: []bson.ObjectID{}, // No members
|
||||
}
|
||||
org.ID = orgID
|
||||
|
||||
// This should fail because there are no members
|
||||
err := validateDeleteAccount(org)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Cannot delete the only member")
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteOrganization_Validation(t *testing.T) {
|
||||
t.Run("DeleteOrganization_NilOrganization", func(t *testing.T) {
|
||||
err := validateDeleteOrganization(nil)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "organization cannot be nil")
|
||||
})
|
||||
|
||||
t.Run("DeleteOrganization_EmptyOrganization", func(t *testing.T) {
|
||||
org := &model.Organization{}
|
||||
err := validateDeleteOrganization(org)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "organization ID cannot be empty")
|
||||
})
|
||||
|
||||
t.Run("DeleteOrganization_ValidOrganization", func(t *testing.T) {
|
||||
org := &model.Organization{
|
||||
OrganizationBase: model.OrganizationBase{
|
||||
Describable: model.Describable{Name: "Valid Organization"},
|
||||
},
|
||||
}
|
||||
org.ID = bson.NewObjectID()
|
||||
|
||||
err := validateDeleteOrganization(org)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteAll_Validation(t *testing.T) {
|
||||
t.Run("DeleteAll_NilOrganization", func(t *testing.T) {
|
||||
accountID := bson.NewObjectID()
|
||||
err := validateDeleteAll(nil, accountID)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "organization cannot be nil")
|
||||
})
|
||||
|
||||
t.Run("DeleteAll_EmptyAccountID", func(t *testing.T) {
|
||||
org := &model.Organization{
|
||||
OrganizationBase: model.OrganizationBase{
|
||||
Describable: model.Describable{Name: "Valid Organization"},
|
||||
},
|
||||
}
|
||||
org.ID = bson.NewObjectID()
|
||||
|
||||
err := validateDeleteAll(org, bson.NilObjectID)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "account ID cannot be empty")
|
||||
})
|
||||
|
||||
t.Run("DeleteAll_ValidInput", func(t *testing.T) {
|
||||
org := &model.Organization{
|
||||
OrganizationBase: model.OrganizationBase{
|
||||
Describable: model.Describable{Name: "Valid Organization"},
|
||||
},
|
||||
}
|
||||
org.ID = bson.NewObjectID()
|
||||
accountID := bson.NewObjectID()
|
||||
|
||||
err := validateDeleteAll(org, accountID)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// Helper functions that implement the validation logic from the service
|
||||
func validateDeleteAccount(org *model.Organization) error {
|
||||
if len(org.Members) <= 1 {
|
||||
return merrors.InvalidArgument("Cannot delete the only member of an organization")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDeleteOrganization(org *model.Organization) error {
|
||||
if org == nil {
|
||||
return merrors.InvalidArgument("organization cannot be nil")
|
||||
}
|
||||
if org.ID == bson.NilObjectID {
|
||||
return merrors.InvalidArgument("organization ID cannot be empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDeleteAll(org *model.Organization, accountRef bson.ObjectID) error {
|
||||
if org == nil {
|
||||
return merrors.InvalidArgument("organization cannot be nil")
|
||||
}
|
||||
if accountRef == bson.NilObjectID {
|
||||
return merrors.InvalidArgument("account ID cannot be empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package accountserviceimp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
apiconfig "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// TestValidatePassword tests the password validation logic directly
|
||||
func TestValidatePassword(t *testing.T) {
|
||||
config := &apiconfig.PasswordConfig{
|
||||
Check: apiconfig.PasswordChecks{
|
||||
MinLength: 8,
|
||||
Digit: true,
|
||||
Upper: true,
|
||||
Lower: true,
|
||||
Special: true,
|
||||
},
|
||||
TokenLength: 32,
|
||||
}
|
||||
|
||||
// Create a minimal service for testing password validation
|
||||
logger := zap.NewNop() // Use no-op logger for tests
|
||||
service := &service{
|
||||
config: config,
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
t.Run("ValidPassword", func(t *testing.T) {
|
||||
err := service.ValidatePassword("TestPassword123!", nil)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("PasswordTooShort", func(t *testing.T) {
|
||||
err := service.ValidatePassword("Test1!", nil)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "at least 8 characters")
|
||||
})
|
||||
|
||||
t.Run("PasswordMissingDigit", func(t *testing.T) {
|
||||
err := service.ValidatePassword("TestPassword!", nil)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "at least one digit")
|
||||
})
|
||||
|
||||
t.Run("PasswordMissingUppercase", func(t *testing.T) {
|
||||
err := service.ValidatePassword("testpassword123!", nil)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "at least one uppercase")
|
||||
})
|
||||
|
||||
t.Run("PasswordMissingLowercase", func(t *testing.T) {
|
||||
err := service.ValidatePassword("TESTPASSWORD123!", nil)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "at least one lowercase")
|
||||
})
|
||||
|
||||
t.Run("PasswordMissingSpecialCharacter", func(t *testing.T) {
|
||||
err := service.ValidatePassword("TestPassword123", nil)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "at least one special character")
|
||||
})
|
||||
|
||||
t.Run("PasswordSameAsOld", func(t *testing.T) {
|
||||
oldPassword := "TestPassword123!"
|
||||
err := service.ValidatePassword("TestPassword123!", &oldPassword)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "cannot be the same as the old password")
|
||||
})
|
||||
}
|
||||
|
||||
// TestValidateAccount tests the account validation logic directly
|
||||
func TestValidateAccount(t *testing.T) {
|
||||
config := &apiconfig.PasswordConfig{
|
||||
Check: apiconfig.PasswordChecks{
|
||||
MinLength: 8,
|
||||
Digit: true,
|
||||
Upper: true,
|
||||
Lower: true,
|
||||
Special: true,
|
||||
},
|
||||
TokenLength: 32,
|
||||
}
|
||||
|
||||
logger := zap.NewNop() // Use no-op logger for tests
|
||||
service := &service{
|
||||
config: config,
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
t.Run("ValidAccount", func(t *testing.T) {
|
||||
account := &model.Account{
|
||||
AccountPublic: model.AccountPublic{
|
||||
AccountBase: model.AccountBase{
|
||||
Describable: model.Describable{
|
||||
Name: "Test User",
|
||||
},
|
||||
},
|
||||
UserDataBase: model.UserDataBase{
|
||||
Login: "test@example.com",
|
||||
},
|
||||
},
|
||||
Password: "TestPassword123!",
|
||||
}
|
||||
|
||||
originalPassword := account.Password
|
||||
err := service.ValidateAccount(account)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Password should be hashed after validation
|
||||
assert.NotEqual(t, originalPassword, account.Password)
|
||||
})
|
||||
|
||||
t.Run("AccountMissingName", func(t *testing.T) {
|
||||
account := &model.Account{
|
||||
AccountPublic: model.AccountPublic{
|
||||
UserDataBase: model.UserDataBase{
|
||||
Login: "test@example.com",
|
||||
},
|
||||
},
|
||||
Password: "TestPassword123!",
|
||||
}
|
||||
|
||||
err := service.ValidateAccount(account)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Name must not be empty")
|
||||
})
|
||||
|
||||
t.Run("AccountMissingLogin", func(t *testing.T) {
|
||||
account := &model.Account{
|
||||
AccountPublic: model.AccountPublic{
|
||||
AccountBase: model.AccountBase{
|
||||
Describable: model.Describable{
|
||||
Name: "Test User",
|
||||
},
|
||||
},
|
||||
},
|
||||
Password: "TestPassword123!",
|
||||
}
|
||||
|
||||
err := service.ValidateAccount(account)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Login must not be empty")
|
||||
})
|
||||
|
||||
t.Run("AccountMissingPassword", func(t *testing.T) {
|
||||
account := &model.Account{
|
||||
AccountPublic: model.AccountPublic{
|
||||
AccountBase: model.AccountBase{
|
||||
Describable: model.Describable{
|
||||
Name: "Test User",
|
||||
},
|
||||
},
|
||||
UserDataBase: model.UserDataBase{
|
||||
Login: "test@example.com",
|
||||
},
|
||||
},
|
||||
Password: "",
|
||||
}
|
||||
|
||||
err := service.ValidateAccount(account)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Password must not be empty")
|
||||
})
|
||||
|
||||
t.Run("AccountInvalidPassword", func(t *testing.T) {
|
||||
account := &model.Account{
|
||||
AccountPublic: model.AccountPublic{
|
||||
AccountBase: model.AccountBase{
|
||||
Describable: model.Describable{
|
||||
Name: "Test User",
|
||||
},
|
||||
},
|
||||
UserDataBase: model.UserDataBase{
|
||||
Login: "test@example.com",
|
||||
},
|
||||
},
|
||||
Password: "weak", // Should fail validation
|
||||
}
|
||||
|
||||
err := service.ValidateAccount(account)
|
||||
assert.Error(t, err)
|
||||
// Should fail on password validation
|
||||
assert.Contains(t, err.Error(), "at least 8 characters")
|
||||
})
|
||||
}
|
||||
|
||||
// TestPasswordConfiguration verifies different password rule configurations
|
||||
func TestPasswordConfiguration(t *testing.T) {
|
||||
t.Run("MinimalRequirements", func(t *testing.T) {
|
||||
config := &apiconfig.PasswordConfig{
|
||||
Check: apiconfig.PasswordChecks{
|
||||
MinLength: 4,
|
||||
Digit: false,
|
||||
Upper: false,
|
||||
Lower: false,
|
||||
Special: false,
|
||||
},
|
||||
TokenLength: 16,
|
||||
}
|
||||
|
||||
logger := zap.NewNop() // Use no-op logger for tests
|
||||
service := &service{
|
||||
config: config,
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
// Should pass with minimal requirements
|
||||
err := service.ValidatePassword("test", nil)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("StrictRequirements", func(t *testing.T) {
|
||||
config := &apiconfig.PasswordConfig{
|
||||
Check: apiconfig.PasswordChecks{
|
||||
MinLength: 12,
|
||||
Digit: true,
|
||||
Upper: true,
|
||||
Lower: true,
|
||||
Special: true,
|
||||
},
|
||||
TokenLength: 64,
|
||||
}
|
||||
|
||||
logger := zap.NewNop() // Use no-op logger for tests
|
||||
service := &service{
|
||||
config: config,
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
// Should fail with shorter password
|
||||
err := service.ValidatePassword("Test123!", nil)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "at least 12 characters")
|
||||
|
||||
// Should pass with longer password
|
||||
err = service.ValidatePassword("TestPassword123!", nil)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package accountservice
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
accountserviceimp "git.sendico.io/tech/sxchnge/bff/edge/product/interface/accountservice/internal"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/management"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// AccountService defines all account-related workflows.
|
||||
type AccountService interface {
|
||||
// ValidateAccount will:
|
||||
// 1) check it's completeness
|
||||
// 2) hash password
|
||||
// 3) prepare verification token
|
||||
ValidateAccount(
|
||||
acct *model.Account,
|
||||
) error
|
||||
|
||||
// ValidatePassword will:
|
||||
// 1) check passsword conformance
|
||||
ValidatePassword(
|
||||
password string,
|
||||
oldPassword *string,
|
||||
) error
|
||||
|
||||
// ResetPassword will:
|
||||
// 1) generate reset password token
|
||||
ResetPassword(
|
||||
ctx context.Context,
|
||||
acct *model.Account,
|
||||
) (verificationToken string, err error)
|
||||
|
||||
// CreateAccount will:
|
||||
// 1) create the account
|
||||
// 2) add it to the org’s member list
|
||||
// 3) assign the given role description to it
|
||||
CreateAccount(
|
||||
ctx context.Context,
|
||||
org *model.Organization,
|
||||
acct *model.Account,
|
||||
roleDescID bson.ObjectID,
|
||||
) (verificationToken string, err error)
|
||||
|
||||
VerifyAccount(
|
||||
ctx context.Context,
|
||||
acct *model.Account,
|
||||
) (verificationToken string, err error)
|
||||
|
||||
JoinOrganization(
|
||||
ctx context.Context,
|
||||
org *model.Organization,
|
||||
acct *model.Account,
|
||||
roleDescID bson.ObjectID,
|
||||
) error
|
||||
|
||||
UpdateLogin(
|
||||
ctx context.Context,
|
||||
acct *model.Account,
|
||||
newLogin string,
|
||||
) (verificationToken string, err error)
|
||||
|
||||
// DeleteAccount deletes the account and removes it from the org.
|
||||
DeleteAccount(
|
||||
ctx context.Context,
|
||||
org *model.Organization,
|
||||
accountRef bson.ObjectID,
|
||||
) error
|
||||
|
||||
// RemoveAccountFromOrganization just drops it from the member slice.
|
||||
RemoveAccountFromOrganization(
|
||||
ctx context.Context,
|
||||
org *model.Organization,
|
||||
accountRef bson.ObjectID,
|
||||
) error
|
||||
|
||||
DeleteOrganization(
|
||||
ctx context.Context,
|
||||
org *model.Organization,
|
||||
) error
|
||||
|
||||
// DeleteAll deletes both the organization and the account.
|
||||
DeleteAll(
|
||||
ctx context.Context,
|
||||
org *model.Organization,
|
||||
accountRef bson.ObjectID,
|
||||
) error
|
||||
}
|
||||
|
||||
func NewAccountService(
|
||||
logger mlogger.Logger,
|
||||
dbf db.Factory,
|
||||
enforcer auth.Enforcer,
|
||||
roleManeger management.Role,
|
||||
config *middleware.PasswordConfig,
|
||||
) (AccountService, error) {
|
||||
return accountserviceimp.NewAccountService(logger, dbf, enforcer, roleManeger, config)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package appversion
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/version"
|
||||
vf "git.sendico.io/tech/sxchnge/bff/pkg/version/factory"
|
||||
)
|
||||
|
||||
// Build information. Populated at build-time.
|
||||
var (
|
||||
Version string
|
||||
Revision string
|
||||
Branch string
|
||||
BuildUser string
|
||||
BuildDate string
|
||||
)
|
||||
|
||||
func Create() version.Printer {
|
||||
vi := version.Info{
|
||||
Program: "SXCHNGE BFF",
|
||||
Revision: Revision,
|
||||
Branch: Branch,
|
||||
BuildUser: BuildUser,
|
||||
BuildDate: BuildDate,
|
||||
Version: Version,
|
||||
}
|
||||
return vf.Create(&vi)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
)
|
||||
|
||||
const (
|
||||
TransportHeader = "X-Auth-Transport"
|
||||
TransportCookie = "cookie"
|
||||
CSRFHeader = "X-CSRF-Protection"
|
||||
CSRFValue = "1"
|
||||
RefreshCookie = "sxchnge_refresh"
|
||||
)
|
||||
|
||||
func UsesCookie(r *http.Request) bool {
|
||||
return r != nil && strings.EqualFold(strings.TrimSpace(r.Header.Get(TransportHeader)), TransportCookie)
|
||||
}
|
||||
|
||||
func RefreshCredential(r *http.Request, fallback string) (string, error) {
|
||||
if !UsesCookie(r) {
|
||||
return strings.TrimSpace(fallback), nil
|
||||
}
|
||||
cookie, err := r.Cookie(RefreshCookie)
|
||||
if err != nil || strings.TrimSpace(cookie.Value) == "" {
|
||||
return "", merrors.Unauthorized("browser session is missing")
|
||||
}
|
||||
return cookie.Value, nil
|
||||
}
|
||||
|
||||
func SetRefreshCookie(w http.ResponseWriter, r *http.Request, token string, expiresAt time.Time) {
|
||||
if !UsesCookie(r) {
|
||||
return
|
||||
}
|
||||
maxAge := int(time.Until(expiresAt).Seconds())
|
||||
if maxAge < 1 {
|
||||
maxAge = 1
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: RefreshCookie,
|
||||
Value: token,
|
||||
Path: cookiePath(r),
|
||||
Expires: expiresAt.UTC(),
|
||||
MaxAge: maxAge,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
func ClearRefreshCookie(w http.ResponseWriter, r *http.Request) {
|
||||
if !UsesCookie(r) {
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: RefreshCookie,
|
||||
Value: "",
|
||||
Path: cookiePath(r),
|
||||
Expires: time.Unix(1, 0).UTC(),
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
func ValidateCSRF(r *http.Request, allowedOrigins []string) error {
|
||||
if !UsesCookie(r) || isSafeMethod(r.Method) {
|
||||
return nil
|
||||
}
|
||||
if r.Header.Get(CSRFHeader) != CSRFValue {
|
||||
return merrors.PermissionDenied("browser CSRF protection header is missing")
|
||||
}
|
||||
origin := strings.TrimSpace(r.Header.Get("Origin"))
|
||||
if origin == "" || !originAllowed(origin, allowedOrigins) {
|
||||
return merrors.PermissionDenied("browser request origin is not allowed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cookiePath(r *http.Request) string {
|
||||
const accountsSegment = "/accounts"
|
||||
if apiEndpoint := strings.TrimSpace(os.Getenv("API_ENDPOINT")); apiEndpoint != "" {
|
||||
return path.Join("/", apiEndpoint, accountsSegment)
|
||||
}
|
||||
if r != nil && r.URL != nil {
|
||||
if index := strings.Index(r.URL.Path, accountsSegment); index >= 0 {
|
||||
return r.URL.Path[:index+len(accountsSegment)]
|
||||
}
|
||||
}
|
||||
return accountsSegment
|
||||
}
|
||||
|
||||
func isSafeMethod(method string) bool {
|
||||
switch method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func originAllowed(origin string, allowedOrigins []string) bool {
|
||||
parsed, err := url.Parse(origin)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.User != nil || parsed.Path != "" {
|
||||
return false
|
||||
}
|
||||
normalized := strings.TrimSuffix(strings.ToLower(origin), "/")
|
||||
for _, allowed := range allowedOrigins {
|
||||
if strings.TrimSuffix(strings.ToLower(strings.TrimSpace(allowed)), "/") == normalized {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSetRefreshCookieUsesHardenedHostOnlyPolicy(t *testing.T) {
|
||||
t.Setenv("API_ENDPOINT", "/api/v1")
|
||||
req := httptest.NewRequest(http.MethodPost, "http://app.sendico.io/api/v1/accounts/login", nil)
|
||||
req.Header.Set(TransportHeader, TransportCookie)
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
SetRefreshCookie(recorder, req, "secret-refresh-token", time.Now().Add(time.Hour))
|
||||
|
||||
cookies := recorder.Result().Cookies()
|
||||
require.Len(t, cookies, 1)
|
||||
cookie := cookies[0]
|
||||
require.Equal(t, RefreshCookie, cookie.Name)
|
||||
require.Equal(t, "secret-refresh-token", cookie.Value)
|
||||
require.Equal(t, "/api/v1/accounts", cookie.Path)
|
||||
require.Empty(t, cookie.Domain, "an omitted Domain attribute keeps the cookie host-only")
|
||||
require.True(t, cookie.HttpOnly)
|
||||
require.True(t, cookie.Secure)
|
||||
require.Equal(t, http.SameSiteStrictMode, cookie.SameSite)
|
||||
require.Positive(t, cookie.MaxAge)
|
||||
}
|
||||
|
||||
func TestSetRefreshCookieUsesConfiguredAPIPathOutsideAccountsRouter(t *testing.T) {
|
||||
t.Setenv("API_ENDPOINT", "/gateway/v2/")
|
||||
req := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/gateway/v2/verification/login", nil)
|
||||
req.Header.Set(TransportHeader, TransportCookie)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
SetRefreshCookie(recorder, req, "refresh", time.Now().Add(time.Hour))
|
||||
|
||||
require.Equal(t, "/gateway/v2/accounts", recorder.Result().Cookies()[0].Path)
|
||||
}
|
||||
|
||||
func TestRefreshCookieIsAlwaysSecure(t *testing.T) {
|
||||
t.Setenv("API_ENDPOINT", "/api/v1")
|
||||
tests := []struct {
|
||||
name string
|
||||
target string
|
||||
forwardedProto string
|
||||
}{
|
||||
{name: "public hostname", target: "http://app.sendico.io/api/v1/accounts/login"},
|
||||
{name: "localhost", target: "http://localhost:4000/api/v1/accounts/login"},
|
||||
{name: "loopback IP", target: "http://127.0.0.1:4000/api/v1/accounts/login"},
|
||||
{name: "private IP", target: "http://192.168.1.20:4000/api/v1/accounts/login"},
|
||||
{name: "forwarded HTTP", target: "http://app.sendico.io/api/v1/accounts/login", forwardedProto: "http"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, test.target, nil)
|
||||
req.Header.Set(TransportHeader, TransportCookie)
|
||||
if test.forwardedProto != "" {
|
||||
req.Header.Set("X-Forwarded-Proto", test.forwardedProto)
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
SetRefreshCookie(recorder, req, "refresh", time.Now().Add(time.Hour))
|
||||
|
||||
require.True(t, recorder.Result().Cookies()[0].Secure)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearRefreshCookieUsesHardenedPolicy(t *testing.T) {
|
||||
t.Setenv("API_ENDPOINT", "/api/v1")
|
||||
req := httptest.NewRequest(http.MethodPost, "http://192.168.1.20:4000/api/v1/accounts/logout", nil)
|
||||
req.Header.Set(TransportHeader, TransportCookie)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
ClearRefreshCookie(recorder, req)
|
||||
|
||||
cookies := recorder.Result().Cookies()
|
||||
require.Len(t, cookies, 1)
|
||||
cookie := cookies[0]
|
||||
require.Equal(t, RefreshCookie, cookie.Name)
|
||||
require.Empty(t, cookie.Value)
|
||||
require.Equal(t, -1, cookie.MaxAge)
|
||||
require.True(t, cookie.HttpOnly)
|
||||
require.True(t, cookie.Secure)
|
||||
require.Equal(t, http.SameSiteStrictMode, cookie.SameSite)
|
||||
}
|
||||
|
||||
func TestValidateCSRFRejectsCrossSiteAndMissingHeaderRequests(t *testing.T) {
|
||||
allowed := []string{"https://app.sendico.io"}
|
||||
|
||||
missingHeader := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/api/v1/payments", nil)
|
||||
missingHeader.Header.Set(TransportHeader, TransportCookie)
|
||||
missingHeader.Header.Set("Origin", allowed[0])
|
||||
require.Error(t, ValidateCSRF(missingHeader, allowed))
|
||||
|
||||
crossSite := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/api/v1/payments", nil)
|
||||
crossSite.Header.Set(TransportHeader, TransportCookie)
|
||||
crossSite.Header.Set(CSRFHeader, CSRFValue)
|
||||
crossSite.Header.Set("Origin", "https://attacker.example")
|
||||
require.Error(t, ValidateCSRF(crossSite, allowed))
|
||||
|
||||
valid := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/api/v1/payments", nil)
|
||||
valid.Header.Set(TransportHeader, TransportCookie)
|
||||
valid.Header.Set(CSRFHeader, CSRFValue)
|
||||
valid.Header.Set("Origin", allowed[0])
|
||||
require.NoError(t, ValidateCSRF(valid, allowed))
|
||||
}
|
||||
|
||||
func TestRefreshCredentialNeverFallsBackForCookieTransport(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/api/v1/accounts/refresh", nil)
|
||||
req.Header.Set(TransportHeader, TransportCookie)
|
||||
|
||||
credential, err := RefreshCredential(req, "body-token")
|
||||
require.Error(t, err)
|
||||
require.Empty(t, credential)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
)
|
||||
|
||||
var ErrUnknownClientID = merrors.Define("unknown client id")
|
||||
|
||||
func Resolve(ctx context.Context, rtdb refreshtokens.DB, clientID string) (*model.Client, error) {
|
||||
clientID = strings.TrimSpace(clientID)
|
||||
if clientID == "" {
|
||||
return nil, ErrUnknownClientID
|
||||
}
|
||||
|
||||
client, err := rtdb.GetClient(ctx, clientID)
|
||||
if errors.Is(err, merrors.ErrNoData) || client == nil {
|
||||
return nil, ErrUnknownClientID
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
)
|
||||
|
||||
type resolveClientDB struct {
|
||||
refreshtokens.DB
|
||||
calls int
|
||||
client *model.Client
|
||||
err error
|
||||
}
|
||||
|
||||
func (db *resolveClientDB) GetClient(ctx context.Context, clientID string) (*model.Client, error) {
|
||||
db.calls++
|
||||
return db.client, db.err
|
||||
}
|
||||
|
||||
func TestResolveRejectsBlankClientIDWithoutStorageLookup(t *testing.T) {
|
||||
db := &resolveClientDB{}
|
||||
|
||||
client, err := Resolve(context.Background(), db, " ")
|
||||
if !errors.Is(err, ErrUnknownClientID) {
|
||||
t.Fatalf("expected unknown client id, got %v", err)
|
||||
}
|
||||
if client != nil {
|
||||
t.Fatalf("expected nil client, got %#v", client)
|
||||
}
|
||||
if db.calls != 0 {
|
||||
t.Fatalf("expected no storage lookup for blank client id, got %d", db.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTrimsClientID(t *testing.T) {
|
||||
want := &model.Client{ClientID: "client-1"}
|
||||
db := &resolveClientDB{client: want}
|
||||
|
||||
client, err := Resolve(context.Background(), db, " client-1 ")
|
||||
if err != nil {
|
||||
t.Fatalf("expected client resolution to succeed, got %v", err)
|
||||
}
|
||||
if client != want {
|
||||
t.Fatalf("expected resolved client %#v, got %#v", want, client)
|
||||
}
|
||||
if db.calls != 1 {
|
||||
t.Fatalf("expected one storage lookup, got %d", db.calls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package sessionmetrics
|
||||
|
||||
import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
type Operation string
|
||||
|
||||
const (
|
||||
Validation Operation = "validation"
|
||||
CurrentLogout Operation = "current_logout"
|
||||
AllLogout Operation = "all_logout"
|
||||
)
|
||||
|
||||
type Outcome string
|
||||
|
||||
const (
|
||||
Active Outcome = "active"
|
||||
Succeeded Outcome = "succeeded"
|
||||
AlreadyInactive Outcome = "already_inactive"
|
||||
MissingIdentity Outcome = "missing_identity"
|
||||
StorageError Outcome = "storage_error"
|
||||
)
|
||||
|
||||
var lifecycle = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Namespace: "sendico",
|
||||
Subsystem: "auth",
|
||||
Name: "session_lifecycle_total",
|
||||
Help: "Session validation and logout outcomes.",
|
||||
}, []string{"operation", "outcome"})
|
||||
|
||||
func Observe(operation Operation, outcome Outcome) {
|
||||
lifecycle.WithLabelValues(string(operation), string(outcome)).Inc()
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
edgeapi "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api"
|
||||
edgemongo "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/mongo"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/ratelimits"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/verification"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/account"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type Stores struct {
|
||||
Accounts account.DB
|
||||
Verification verification.DB
|
||||
RefreshTokens refreshtokens.DB
|
||||
LoginAttempts ratelimits.Store
|
||||
}
|
||||
|
||||
type TokenStores struct {
|
||||
Verification verification.DB
|
||||
RefreshTokens refreshtokens.DB
|
||||
}
|
||||
|
||||
type resolvedConfig struct {
|
||||
deps *edgemongo.Dependencies
|
||||
storage edgeapi.AuthStorageConfig
|
||||
}
|
||||
|
||||
func OpenStores(
|
||||
logger mlogger.Logger,
|
||||
dbf db.Factory,
|
||||
config *edgeapi.AuthStorageConfig,
|
||||
) (*Stores, error) {
|
||||
resolved, err := resolve(logger, dbf, config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logger.Info(
|
||||
"Auth storage configured",
|
||||
zap.String("accounts_collection", resolved.storage.AccountsCollection),
|
||||
zap.String("refresh_tokens_collection", resolved.storage.RefreshTokensCollection),
|
||||
zap.String("clients_collection", resolved.storage.ClientsCollection),
|
||||
zap.String("verification_tokens_collection", resolved.storage.VerificationTokensCollection),
|
||||
zap.String("login_attempts_collection", resolved.storage.LoginAttemptsCollection),
|
||||
zap.String("verification_rate_limits_collection", resolved.storage.VerificationRateLimitsCollection),
|
||||
)
|
||||
|
||||
adb, err := dbf.NewAccountDBWithCollection(resolved.storage.AccountsCollection)
|
||||
if err != nil {
|
||||
logger.Error("Failed to create account database", zap.Error(err), zap.String("collection", resolved.storage.AccountsCollection))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tokens, err := openTokenStores(logger, resolved)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
attempts, err := ratelimits.Create(logger, resolved.deps.Database, resolved.storage.LoginAttemptsCollection)
|
||||
if err != nil {
|
||||
logger.Error("Failed to create login attempt database", zap.Error(err), zap.String("collection", resolved.storage.LoginAttemptsCollection))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Stores{
|
||||
Accounts: adb,
|
||||
Verification: tokens.Verification,
|
||||
RefreshTokens: tokens.RefreshTokens,
|
||||
LoginAttempts: attempts,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func OpenTokenStores(
|
||||
logger mlogger.Logger,
|
||||
dbf db.Factory,
|
||||
config *edgeapi.AuthStorageConfig,
|
||||
) (*TokenStores, error) {
|
||||
resolved, err := resolve(logger, dbf, config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logger.Info(
|
||||
"Auth token storage configured",
|
||||
zap.String("refresh_tokens_collection", resolved.storage.RefreshTokensCollection),
|
||||
zap.String("clients_collection", resolved.storage.ClientsCollection),
|
||||
zap.String("verification_tokens_collection", resolved.storage.VerificationTokensCollection),
|
||||
zap.String("verification_rate_limits_collection", resolved.storage.VerificationRateLimitsCollection),
|
||||
)
|
||||
return openTokenStores(logger, resolved)
|
||||
}
|
||||
|
||||
func resolve(
|
||||
logger mlogger.Logger,
|
||||
dbf db.Factory,
|
||||
config *edgeapi.AuthStorageConfig,
|
||||
) (*resolvedConfig, error) {
|
||||
deps, err := edgemongo.ResolveDependencies(dbf)
|
||||
if err != nil {
|
||||
logger.Error("Failed to resolve local auth storage dependencies", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &resolvedConfig{
|
||||
deps: deps,
|
||||
storage: config.WithDefaults(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func openTokenStores(logger mlogger.Logger, resolved *resolvedConfig) (*TokenStores, error) {
|
||||
rtdb, err := refreshtokens.CreateWithCollections(logger, resolved.deps.Database, refreshtokens.Collections{
|
||||
RefreshTokens: resolved.storage.RefreshTokensCollection,
|
||||
Clients: resolved.storage.ClientsCollection,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error(
|
||||
"Failed to create refresh token management database",
|
||||
zap.Error(err),
|
||||
zap.String("refresh_tokens_collection", resolved.storage.RefreshTokensCollection),
|
||||
zap.String("clients_collection", resolved.storage.ClientsCollection),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
verificationLimits, err := ratelimits.Create(logger, resolved.deps.Database, resolved.storage.VerificationRateLimitsCollection)
|
||||
if err != nil {
|
||||
logger.Error("Failed to create verification rate-limit database", zap.Error(err), zap.String("collection", resolved.storage.VerificationRateLimitsCollection))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cdb, err := verification.CreateWithCollection(
|
||||
logger,
|
||||
resolved.deps.Database,
|
||||
resolved.deps.Transactions,
|
||||
resolved.storage.VerificationTokensCollection,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Error("Failed to create verification token database", zap.Error(err), zap.String("collection", resolved.storage.VerificationTokensCollection))
|
||||
return nil, err
|
||||
}
|
||||
cdb.WithIssuanceLimits(verificationLimits)
|
||||
|
||||
return &TokenStores{
|
||||
Verification: cdb,
|
||||
RefreshTokens: rtdb,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package tokens
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type recordingRefreshTokenDB struct {
|
||||
refreshtokens.DB
|
||||
token *model.RefreshToken
|
||||
}
|
||||
|
||||
func (db *recordingRefreshTokenDB) Create(_ context.Context, token *model.RefreshToken) error {
|
||||
db.token = token
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestPrepareRefreshTokenStoresResolvedClientIP(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
remoteAddr string
|
||||
realIP string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "proxied IPv4",
|
||||
remoteAddr: "172.19.0.37:45706",
|
||||
realIP: "203.0.113.10",
|
||||
want: "203.0.113.10",
|
||||
},
|
||||
{
|
||||
name: "direct IPv4",
|
||||
remoteAddr: "203.0.113.11:45706",
|
||||
want: "203.0.113.11",
|
||||
},
|
||||
{
|
||||
name: "proxied IPv6",
|
||||
remoteAddr: "172.19.0.37:45706",
|
||||
realIP: "2001:0db8:0000:0000:0000:0000:0000:0012",
|
||||
want: "2001:db8::12",
|
||||
},
|
||||
{
|
||||
name: "direct IPv6",
|
||||
remoteAddr: "[2001:db8::13]:45706",
|
||||
want: "2001:db8::13",
|
||||
},
|
||||
{
|
||||
name: "missing address",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "invalid address",
|
||||
remoteAddr: "not-an-address",
|
||||
realIP: "also-not-an-address",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
request := &http.Request{
|
||||
RemoteAddr: tt.remoteAddr,
|
||||
Header: make(http.Header),
|
||||
}
|
||||
if tt.realIP != "" {
|
||||
request.Header.Set("X-Real-IP", tt.realIP)
|
||||
}
|
||||
|
||||
account := &model.Account{}
|
||||
account.SetID(bson.NewObjectID())
|
||||
db := &recordingRefreshTokenDB{}
|
||||
_, err := PrepareRefreshToken(
|
||||
context.Background(),
|
||||
request,
|
||||
db,
|
||||
32,
|
||||
24,
|
||||
&model.SessionIdentifier{ClientID: "client", DeviceID: "device"},
|
||||
account,
|
||||
zap.NewNop(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare refresh token: %v", err)
|
||||
}
|
||||
if db.token == nil {
|
||||
t.Fatal("expected refresh token to be stored")
|
||||
}
|
||||
if db.token.IPAddress != tt.want {
|
||||
t.Fatalf("unexpected stored IP address: got %q, want %q", db.token.IPAddress, tt.want)
|
||||
}
|
||||
if db.token.SessionVersion == "" {
|
||||
t.Fatal("expected a server-generated session version")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package tokens
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/http/ipguard"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func generateRefreshTokenData(length int) (string, error) {
|
||||
randomBytes := make([]byte, length)
|
||||
if _, err := io.ReadFull(rand.Reader, randomBytes); err != nil {
|
||||
return "", merrors.InternalWrap(err, "failed to generate secure random bytes")
|
||||
}
|
||||
|
||||
return base64.URLEncoding.EncodeToString(randomBytes), nil
|
||||
}
|
||||
|
||||
func PrepareRefreshToken(
|
||||
ctx context.Context,
|
||||
r *http.Request,
|
||||
rtdb refreshtokens.DB,
|
||||
length int,
|
||||
refreshExpiration int,
|
||||
session *model.SessionIdentifier,
|
||||
account *model.Account,
|
||||
logger mlogger.Logger,
|
||||
) (*model.RefreshToken, error) {
|
||||
refreshToken, err := generateRefreshTokenData(length)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to generate refresh token", zap.Error(err), mzap.StorableRef(account))
|
||||
return nil, err
|
||||
}
|
||||
sessionVersion, err := generateRefreshTokenData(16)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to generate session version", zap.Error(err), mzap.StorableRef(account))
|
||||
return nil, err
|
||||
}
|
||||
versionedSession := *session
|
||||
versionedSession.SessionVersion = sessionVersion
|
||||
|
||||
clientIP := ipguard.ClientIP(r)
|
||||
ipAddress := ""
|
||||
if clientIP != nil {
|
||||
ipAddress = clientIP.String()
|
||||
}
|
||||
|
||||
token := &model.RefreshToken{
|
||||
AccountRef: account.GetID(),
|
||||
ClientRefreshToken: model.ClientRefreshToken{
|
||||
SessionIdentifier: versionedSession,
|
||||
RefreshToken: refreshToken,
|
||||
},
|
||||
ExpiresAt: time.Now().Add(time.Duration(refreshExpiration) * time.Hour),
|
||||
IsRevoked: false,
|
||||
UserAgent: r.UserAgent(),
|
||||
IPAddress: ipAddress,
|
||||
}
|
||||
|
||||
if err = rtdb.Create(ctx, token); err != nil {
|
||||
logger.Warn("Failed to store a refresh token", zap.Error(err), mzap.StorableRef(account),
|
||||
zap.String("client_id", token.ClientID), zap.String("device_id", token.DeviceID))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package ipguard
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ClientIP resolves the real caller IP from the request.
|
||||
// When the request arrives via the Caddy reverse proxy, the real client IP is
|
||||
// forwarded in the X-Real-IP header (set via "header_up X-Real-IP {remote_host}"
|
||||
// in the Caddyfile). RemoteAddr is used as a fallback for direct connections
|
||||
// (local development, health checks, etc.).
|
||||
func ClientIP(r *http.Request) net.IP {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
// Prefer X-Real-IP set by the trusted Caddy reverse proxy.
|
||||
if xri := strings.TrimSpace(r.Header.Get("X-Real-IP")); xri != "" {
|
||||
if ip := net.ParseIP(xri); ip != nil {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
raw := strings.TrimSpace(r.RemoteAddr)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
if ip := net.ParseIP(raw); ip != nil {
|
||||
return ip
|
||||
}
|
||||
host, _, err := net.SplitHostPort(raw)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return net.ParseIP(host)
|
||||
}
|
||||
|
||||
func parseCIDRs(raw []string) ([]*net.IPNet, error) {
|
||||
blocks := make([]*net.IPNet, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
clean := strings.TrimSpace(item)
|
||||
if clean == "" {
|
||||
continue
|
||||
}
|
||||
_, block, err := net.ParseCIDR(clean)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blocks = append(blocks, block)
|
||||
}
|
||||
return blocks, nil
|
||||
}
|
||||
|
||||
// Allowed reports whether clientIP is allowed by configured CIDRs.
|
||||
// Empty CIDR list means unrestricted access.
|
||||
func Allowed(clientIP net.IP, cidrs []string) (bool, error) {
|
||||
blocks, err := parseCIDRs(cidrs)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if len(blocks) == 0 {
|
||||
return true, nil
|
||||
}
|
||||
if clientIP == nil {
|
||||
return false, nil
|
||||
}
|
||||
for _, block := range blocks {
|
||||
if block.Contains(clientIP) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package ipguard
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClientIP(t *testing.T) {
|
||||
t.Run("extracts host from remote addr", func(t *testing.T) {
|
||||
req := &http.Request{RemoteAddr: "10.1.2.3:1234"}
|
||||
ip := ClientIP(req)
|
||||
if ip == nil || ip.String() != "10.1.2.3" {
|
||||
t.Fatalf("unexpected ip: %v", ip)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("supports plain ip", func(t *testing.T) {
|
||||
req := &http.Request{RemoteAddr: "8.8.8.8"}
|
||||
ip := ClientIP(req)
|
||||
if ip == nil || ip.String() != "8.8.8.8" {
|
||||
t.Fatalf("unexpected ip: %v", ip)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid remote addr", func(t *testing.T) {
|
||||
req := &http.Request{RemoteAddr: "invalid"}
|
||||
if ip := ClientIP(req); ip != nil {
|
||||
t.Fatalf("expected nil ip, got %v", ip)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("prefers X-Real-IP over remote addr", func(t *testing.T) {
|
||||
req := &http.Request{
|
||||
RemoteAddr: "172.19.0.31:5000",
|
||||
Header: http.Header{"X-Real-Ip": []string{"178.57.67.154"}},
|
||||
}
|
||||
ip := ClientIP(req)
|
||||
if ip == nil || ip.String() != "178.57.67.154" {
|
||||
t.Fatalf("expected real client ip, got %v", ip)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("falls back to remote addr when X-Real-IP absent", func(t *testing.T) {
|
||||
req := &http.Request{RemoteAddr: "10.0.0.1:9000"}
|
||||
ip := ClientIP(req)
|
||||
if ip == nil || ip.String() != "10.0.0.1" {
|
||||
t.Fatalf("expected remote addr ip, got %v", ip)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("falls back to remote addr when X-Real-IP invalid", func(t *testing.T) {
|
||||
req := &http.Request{
|
||||
RemoteAddr: "10.0.0.1:9000",
|
||||
Header: http.Header{"X-Real-Ip": []string{"not-an-ip"}},
|
||||
}
|
||||
ip := ClientIP(req)
|
||||
if ip == nil || ip.String() != "10.0.0.1" {
|
||||
t.Fatalf("expected fallback to remote addr, got %v", ip)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAllowed(t *testing.T) {
|
||||
clientIP := net.ParseIP("10.1.2.3")
|
||||
if clientIP == nil {
|
||||
t.Fatal("failed to parse test ip")
|
||||
}
|
||||
|
||||
t.Run("allows when cidr matches", func(t *testing.T) {
|
||||
allowed, err := Allowed(clientIP, []string{"10.0.0.0/8"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !allowed {
|
||||
t.Fatal("expected allowed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("denies when cidr does not match", func(t *testing.T) {
|
||||
allowed, err := Allowed(clientIP, []string{"192.168.0.0/16"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if allowed {
|
||||
t.Fatal("expected denied")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("allows when cidr list is empty", func(t *testing.T) {
|
||||
allowed, err := Allowed(clientIP, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !allowed {
|
||||
t.Fatal("expected allowed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid cidr fails", func(t *testing.T) {
|
||||
_, err := Allowed(clientIP, []string{"not-a-cidr"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil client ip denied when cidrs configured", func(t *testing.T) {
|
||||
allowed, err := Allowed(nil, []string{"10.0.0.0/8"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if allowed {
|
||||
t.Fatal("expected denied")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/domainprovider"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
)
|
||||
|
||||
type API interface {
|
||||
LifecycleContext() context.Context
|
||||
Logger() mlogger.Logger
|
||||
DomainProvider() domainprovider.DomainProvider
|
||||
Config() *Config
|
||||
ProfileConfig() ConfigProvider
|
||||
DBFactory() db.Factory
|
||||
Permissions() auth.Provider
|
||||
RuntimeComponent(name string) any
|
||||
Register() Register
|
||||
}
|
||||
|
||||
type MicroServiceFactoryT = func(API) (mservice.MicroService, error)
|
||||
@@ -0,0 +1,41 @@
|
||||
package api
|
||||
|
||||
type AuthStorageConfig struct {
|
||||
AccountsCollection string `yaml:"accounts_collection"`
|
||||
RefreshTokensCollection string `yaml:"refresh_tokens_collection"`
|
||||
ClientsCollection string `yaml:"clients_collection"`
|
||||
VerificationTokensCollection string `yaml:"verification_tokens_collection"`
|
||||
LoginAttemptsCollection string `yaml:"login_attempts_collection"`
|
||||
VerificationRateLimitsCollection string `yaml:"verification_rate_limits_collection"`
|
||||
}
|
||||
|
||||
func (c *AuthStorageConfig) WithDefaults() AuthStorageConfig {
|
||||
if c == nil {
|
||||
c = &AuthStorageConfig{}
|
||||
}
|
||||
res := *c
|
||||
if res.AccountsCollection == "" {
|
||||
res.AccountsCollection = "accounts"
|
||||
}
|
||||
if res.RefreshTokensCollection == "" {
|
||||
res.RefreshTokensCollection = "refresh_tokens"
|
||||
}
|
||||
if res.ClientsCollection == "" {
|
||||
res.ClientsCollection = "clients"
|
||||
}
|
||||
if res.VerificationTokensCollection == "" {
|
||||
res.VerificationTokensCollection = "verification_tokens"
|
||||
}
|
||||
if res.LoginAttemptsCollection == "" {
|
||||
res.LoginAttemptsCollection = "login_attempts"
|
||||
}
|
||||
if res.VerificationRateLimitsCollection == "" {
|
||||
res.VerificationRateLimitsCollection = "verification_rate_limits"
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
type VerificationConfig struct {
|
||||
AllowedPurposes []string `yaml:"allowed_purposes"`
|
||||
RateLimits *VerificationRateLimitsConfig `yaml:"rate_limits"`
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
mwa "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
|
||||
storageconfig "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/storage"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Mw *mwa.Config `yaml:"middleware"`
|
||||
AuthStorage *AuthStorageConfig `yaml:"auth_storage"`
|
||||
Verification *VerificationConfig `yaml:"verification"`
|
||||
Storage *storageconfig.Config `yaml:"storage"`
|
||||
}
|
||||
|
||||
func (c *Config) SharedConfig() *Config {
|
||||
return c
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/ws"
|
||||
api "git.sendico.io/tech/sxchnge/bff/pkg/api/http"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
)
|
||||
|
||||
type Register interface {
|
||||
Handler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc)
|
||||
AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc)
|
||||
PendingAccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.PendingAccountHandlerFunc)
|
||||
WSHandler(messageType string, handler ws.HandlerFunc)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package api
|
||||
|
||||
import "git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
|
||||
type ConfigProvider interface {
|
||||
SharedConfig() *Config
|
||||
}
|
||||
|
||||
type ConfigFactory func() ConfigProvider
|
||||
type RuntimeComponents map[string]any
|
||||
type RuntimeBootstrapFunc func(API) (RuntimeComponents, error)
|
||||
type RuntimeFinishFunc func(API) error
|
||||
|
||||
type RuntimeConfig struct {
|
||||
Name string
|
||||
Services []ServiceFactory
|
||||
EnableWebSocket bool
|
||||
ConfigFactory ConfigFactory
|
||||
Bootstrap RuntimeBootstrapFunc
|
||||
Finish RuntimeFinishFunc
|
||||
}
|
||||
|
||||
type ServiceFactory struct {
|
||||
Name mservice.Type
|
||||
Factory MicroServiceFactoryT
|
||||
}
|
||||
|
||||
func (cfg RuntimeConfig) RuntimeName() string {
|
||||
if cfg.Name == "" {
|
||||
return "edge"
|
||||
}
|
||||
return cfg.Name
|
||||
}
|
||||
|
||||
func (cfg RuntimeConfig) NewConfig() ConfigProvider {
|
||||
if cfg.ConfigFactory != nil {
|
||||
return cfg.ConfigFactory()
|
||||
}
|
||||
return &Config{}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package srequest
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type ChangePolicies struct {
|
||||
RoleRef bson.ObjectID `json:"roleRef"`
|
||||
Add *[]model.RolePolicy `json:"add,omitempty"`
|
||||
Remove *[]model.RolePolicy `json:"remove,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package srequest
|
||||
|
||||
import "go.mongodb.org/mongo-driver/v2/bson"
|
||||
|
||||
type ChangeRole struct {
|
||||
AccountRef bson.ObjectID `json:"accountRef"`
|
||||
NewRoleDescriptionRef bson.ObjectID `json:"newRoleDescriptionRef"`
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package srequest
|
||||
|
||||
import "git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
|
||||
type Login struct {
|
||||
model.SessionIdentifier `json:",inline"`
|
||||
model.LoginData `json:"login"`
|
||||
ClientSecret string `json:"clientSecret,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package srequest
|
||||
|
||||
import "git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
|
||||
type AccessTokenRefresh = model.ClientRefreshToken
|
||||
@@ -0,0 +1,5 @@
|
||||
package srequest
|
||||
|
||||
import "git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
|
||||
type TokenRefreshRotate = model.ClientRefreshToken
|
||||
@@ -0,0 +1,5 @@
|
||||
package srequest
|
||||
|
||||
type Validatable interface {
|
||||
Validate() error
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package srequest
|
||||
|
||||
import "git.sendico.io/tech/sxchnge/bff/pkg/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 model.SessionIdentifier `json:"sessionIdentifier"`
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package sresponse
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type AccountData struct {
|
||||
model.AccountPublic `json:",inline"`
|
||||
IsAnonymous bool `json:"isAnonymous"`
|
||||
}
|
||||
|
||||
type accountResponse struct {
|
||||
authResponse `json:",inline"`
|
||||
Account AccountData `json:"account"`
|
||||
}
|
||||
|
||||
type accountsResponse struct {
|
||||
authResponse `json:",inline"`
|
||||
Accounts []AccountData `json:"accounts"`
|
||||
}
|
||||
|
||||
func NewAccountData(account *model.Account, isAnonymous bool) *AccountData {
|
||||
return &AccountData{
|
||||
AccountPublic: account.AccountPublic,
|
||||
IsAnonymous: isAnonymous,
|
||||
}
|
||||
}
|
||||
|
||||
func Account(logger mlogger.Logger, account *model.Account, accessToken *TokenData) http.HandlerFunc {
|
||||
return response.Ok(
|
||||
logger,
|
||||
&accountResponse{
|
||||
Account: *NewAccountData(account, false),
|
||||
authResponse: authResponse{AccessToken: *accessToken},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func Accounts(logger mlogger.Logger, accounts []model.Account, orgRef bson.ObjectID, accessToken *TokenData) http.HandlerFunc {
|
||||
publicAccounts := make([]AccountData, len(accounts))
|
||||
for i := range accounts {
|
||||
publicAccounts[i] = *NewAccountData(
|
||||
&accounts[i],
|
||||
model.AccountIsAnonymous(&accounts[i].UserDataBase, orgRef),
|
||||
)
|
||||
}
|
||||
|
||||
return response.Ok(
|
||||
logger,
|
||||
&accountsResponse{
|
||||
Accounts: publicAccounts,
|
||||
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/bff/pkg/api/http/response"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
)
|
||||
|
||||
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 (
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/auth/browser"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
)
|
||||
|
||||
type loginResponse struct {
|
||||
accountResponse
|
||||
RefreshToken TokenData `json:"refreshToken"`
|
||||
}
|
||||
|
||||
func Login(logger mlogger.Logger, account *model.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, false),
|
||||
authResponse: authResponse{AccessToken: *accessToken},
|
||||
},
|
||||
RefreshToken: responseRefreshToken,
|
||||
},
|
||||
).ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package sresponse
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
)
|
||||
|
||||
type pendingLoginResponse struct {
|
||||
Account accountResponse `json:"account"`
|
||||
PendingToken TokenData `json:"pendingToken"`
|
||||
Target string `json:"target"`
|
||||
}
|
||||
|
||||
func LoginPending(logger mlogger.Logger, account *model.Account, pendingToken *TokenData, target string) http.HandlerFunc {
|
||||
return response.Accepted(
|
||||
logger,
|
||||
&pendingLoginResponse{
|
||||
Account: accountResponse{
|
||||
Account: *NewAccountData(account, false),
|
||||
authResponse: authResponse{},
|
||||
},
|
||||
PendingToken: *pendingToken,
|
||||
Target: target,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package sresponse
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/auth/browser"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"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(), &model.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(), &model.Account{}, access, refresh).ServeHTTP(recorder, req)
|
||||
|
||||
require.Contains(t, recorder.Body.String(), refresh.Token)
|
||||
require.Empty(t, recorder.Header().Get("Set-Cookie"))
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package sresponse
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
)
|
||||
|
||||
type organizationsResponse struct {
|
||||
authResponse `json:",inline"`
|
||||
Organizations []model.Organization `json:"organizations"`
|
||||
}
|
||||
|
||||
func Organization(logger mlogger.Logger, organization *model.Organization, accessToken *TokenData) http.HandlerFunc {
|
||||
return Organizations(logger, []model.Organization{*organization}, accessToken)
|
||||
}
|
||||
|
||||
func Organizations(logger mlogger.Logger, organizations []model.Organization, accessToken *TokenData) http.HandlerFunc {
|
||||
return response.Ok(logger, organizationsResponse{
|
||||
Organizations: organizations,
|
||||
authResponse: authResponse{AccessToken: *accessToken},
|
||||
})
|
||||
}
|
||||
|
||||
type organizationPublicResponse struct {
|
||||
Organizations []model.OrganizationBase `json:"organizations"`
|
||||
}
|
||||
|
||||
func OrganizationPublic(logger mlogger.Logger, organization *model.OrganizationBase) http.HandlerFunc {
|
||||
return response.Ok(logger, organizationPublicResponse{
|
||||
Organizations: []model.OrganizationBase{*organization},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package sresponse
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
)
|
||||
|
||||
type permissionsDescription struct {
|
||||
Roles []model.RoleDescription `json:"roles"`
|
||||
Policies []model.PolicyDescription `json:"policies"`
|
||||
}
|
||||
|
||||
type permissionsData struct {
|
||||
Roles []model.Role `json:"roles"`
|
||||
Policies []model.RolePolicy `json:"policies"`
|
||||
Permissions []model.Permission `json:"permissions"`
|
||||
}
|
||||
|
||||
type permissionsResponse struct {
|
||||
authResponse `json:",inline"`
|
||||
Descriptions permissionsDescription `json:"descriptions"`
|
||||
Permissions permissionsData `json:"permissions"`
|
||||
}
|
||||
|
||||
func Permissions(logger mlogger.Logger,
|
||||
rolesDescs []model.RoleDescription, policiesDescs []model.PolicyDescription,
|
||||
roles []model.Role, policies []model.RolePolicy, permissions []model.Permission,
|
||||
accessToken *TokenData,
|
||||
) http.HandlerFunc {
|
||||
return response.Ok(logger, permissionsResponse{
|
||||
Descriptions: permissionsDescription{
|
||||
Roles: rolesDescs,
|
||||
Policies: policiesDescs,
|
||||
},
|
||||
Permissions: permissionsData{
|
||||
Roles: roles,
|
||||
Policies: policies,
|
||||
Permissions: permissions,
|
||||
},
|
||||
authResponse: authResponse{AccessToken: *accessToken},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package sresponse
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
emodel "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
)
|
||||
|
||||
type (
|
||||
HandlerFunc = func(r *http.Request) http.HandlerFunc
|
||||
AccountHandlerFunc = func(r *http.Request, account *model.Account, accessToken *TokenData) http.HandlerFunc
|
||||
PendingAccountHandlerFunc = func(r *http.Request, account *model.Account, token *emodel.AccountToken) http.HandlerFunc
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
package sresponse
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
)
|
||||
|
||||
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,39 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
sharedlimit "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/ratelimit"
|
||||
)
|
||||
|
||||
type VerificationRateLimitsConfig struct {
|
||||
PerDestination sharedlimit.WindowConfig `yaml:"per_destination"`
|
||||
PerIP sharedlimit.WindowConfig `yaml:"per_ip"`
|
||||
}
|
||||
|
||||
type VerificationRateLimits struct {
|
||||
PerDestination sharedlimit.Window
|
||||
PerIP sharedlimit.Window
|
||||
}
|
||||
|
||||
func DefaultVerificationRateLimits() VerificationRateLimits {
|
||||
return VerificationRateLimits{
|
||||
PerDestination: sharedlimit.Window{Limit: 5, Window: time.Hour},
|
||||
PerIP: sharedlimit.Window{Limit: 20, Window: time.Hour},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *VerificationConfig) ResolveRateLimits() (VerificationRateLimits, error) {
|
||||
if c == nil || c.RateLimits == nil {
|
||||
return DefaultVerificationRateLimits(), nil
|
||||
}
|
||||
destination, err := sharedlimit.ResolveWindow(c.RateLimits.PerDestination, "verification.rate_limits.per_destination")
|
||||
if err != nil {
|
||||
return VerificationRateLimits{}, err
|
||||
}
|
||||
ip, err := sharedlimit.ResolveWindow(c.RateLimits.PerIP, "verification.rate_limits.per_ip")
|
||||
if err != nil {
|
||||
return VerificationRateLimits{}, err
|
||||
}
|
||||
return VerificationRateLimits{PerDestination: destination, PerIP: ip}, nil
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sharedlimit "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/ratelimit"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
)
|
||||
|
||||
func TestVerificationRateLimitsDefaults(t *testing.T) {
|
||||
got, err := (*VerificationConfig)(nil).ResolveRateLimits()
|
||||
if err != nil {
|
||||
t.Fatalf("resolve defaults: %v", err)
|
||||
}
|
||||
if got.PerDestination.Limit != 5 || got.PerDestination.Window != time.Hour ||
|
||||
got.PerIP.Limit != 20 || got.PerIP.Window != time.Hour {
|
||||
t.Fatalf("unexpected defaults: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationRateLimitsRejectInvalidPolicy(t *testing.T) {
|
||||
cfg := &VerificationConfig{RateLimits: &VerificationRateLimitsConfig{
|
||||
PerDestination: sharedlimit.WindowConfig{Limit: 5, Window: "1h"},
|
||||
PerIP: sharedlimit.WindowConfig{Limit: 0, Window: "1h"},
|
||||
}}
|
||||
_, err := cfg.ResolveRateLimits()
|
||||
if !errors.Is(err, merrors.ErrInvalidArg) {
|
||||
t.Fatalf("expected invalid argument, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
|
||||
)
|
||||
|
||||
type (
|
||||
Config = middleware.WebSocketConfig
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"golang.org/x/net/websocket"
|
||||
)
|
||||
|
||||
type (
|
||||
ResponseHandler func(messageType string, conn *websocket.Conn)
|
||||
HandlerFunc func(ctx context.Context, msg Message) ResponseHandler
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
package ws
|
||||
|
||||
import "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
|
||||
|
||||
type Message struct {
|
||||
response.BaseResponse
|
||||
ID string `json:"id"`
|
||||
MessageType string `json:"messageType"`
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package middleware
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCredentialedCORSRequiresExplicitOrigins(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config CORSSettings
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "explicit origin",
|
||||
config: CORSSettings{
|
||||
AllowCredentials: true,
|
||||
AllowedOrigins: []string{"https://app.sendico.io"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "wildcard",
|
||||
config: CORSSettings{
|
||||
AllowCredentials: true,
|
||||
AllowedOrigins: []string{"*"},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing origin",
|
||||
config: CORSSettings{
|
||||
AllowCredentials: true,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "non-credentialed compatibility",
|
||||
config: CORSSettings{
|
||||
AllowedOrigins: []string{"*"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.config.Validate()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("Validate() error = %v, wantErr %t", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
sharedlimit "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/ratelimit"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultLoginAccountLimit = 5
|
||||
defaultLoginAccountWindow = 15 * time.Minute
|
||||
defaultLoginIPLimit = 50
|
||||
defaultLoginIPWindow = 15 * time.Minute
|
||||
)
|
||||
|
||||
type LoginRateLimitWindowConfig = sharedlimit.WindowConfig
|
||||
|
||||
type LoginRateLimitsConfig struct {
|
||||
PerAccount LoginRateLimitWindowConfig `yaml:"per_account"`
|
||||
PerIP LoginRateLimitWindowConfig `yaml:"per_ip"`
|
||||
}
|
||||
|
||||
type LoginRateLimitWindow = sharedlimit.Window
|
||||
|
||||
type LoginRateLimits struct {
|
||||
PerAccount LoginRateLimitWindow
|
||||
PerIP LoginRateLimitWindow
|
||||
}
|
||||
|
||||
func (c *Config) ResolveLoginRateLimits() (LoginRateLimits, error) {
|
||||
if c == nil || c.LoginRateLimits == nil {
|
||||
return LoginRateLimits{
|
||||
PerAccount: LoginRateLimitWindow{Limit: defaultLoginAccountLimit, Window: defaultLoginAccountWindow},
|
||||
PerIP: LoginRateLimitWindow{Limit: defaultLoginIPLimit, Window: defaultLoginIPWindow},
|
||||
}, nil
|
||||
}
|
||||
|
||||
account, err := sharedlimit.ResolveWindow(c.LoginRateLimits.PerAccount, "middleware.login_rate_limits.per_account")
|
||||
if err != nil {
|
||||
return LoginRateLimits{}, err
|
||||
}
|
||||
ip, err := sharedlimit.ResolveWindow(c.LoginRateLimits.PerIP, "middleware.login_rate_limits.per_ip")
|
||||
if err != nil {
|
||||
return LoginRateLimits{}, err
|
||||
}
|
||||
return LoginRateLimits{PerAccount: account, PerIP: ip}, nil
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
)
|
||||
|
||||
func TestResolveLoginRateLimitsUsesSecureDefaults(t *testing.T) {
|
||||
got, err := (&Config{}).ResolveLoginRateLimits()
|
||||
if err != nil {
|
||||
t.Fatalf("resolve defaults: %v", err)
|
||||
}
|
||||
if got.PerAccount.Limit != 5 || got.PerAccount.Window != 15*time.Minute {
|
||||
t.Fatalf("unexpected account defaults: %+v", got.PerAccount)
|
||||
}
|
||||
if got.PerIP.Limit != 50 || got.PerIP.Window != 15*time.Minute {
|
||||
t.Fatalf("unexpected IP defaults: %+v", got.PerIP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLoginRateLimitsRejectsUnsafeConfiguration(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *LoginRateLimitsConfig
|
||||
}{
|
||||
{
|
||||
name: "zero account threshold",
|
||||
cfg: &LoginRateLimitsConfig{
|
||||
PerAccount: LoginRateLimitWindowConfig{Window: "15m"},
|
||||
PerIP: LoginRateLimitWindowConfig{Limit: 50, Window: "15m"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid IP window",
|
||||
cfg: &LoginRateLimitsConfig{
|
||||
PerAccount: LoginRateLimitWindowConfig{Limit: 5, Window: "15m"},
|
||||
PerIP: LoginRateLimitWindowConfig{Limit: 50, Window: "forever"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "excessive account window",
|
||||
cfg: &LoginRateLimitsConfig{
|
||||
PerAccount: LoginRateLimitWindowConfig{Limit: 5, Window: "25h"},
|
||||
PerIP: LoginRateLimitWindowConfig{Limit: 50, Window: "15m"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := (&Config{LoginRateLimits: test.cfg}).ResolveLoginRateLimits()
|
||||
if !errors.Is(err, merrors.ErrInvalidArg) {
|
||||
t.Fatalf("expected invalid argument, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
)
|
||||
|
||||
type CORSSettings struct {
|
||||
MaxAge int `yaml:"max_age"`
|
||||
AllowedOrigins []string `yaml:"allowed_origins"`
|
||||
AllowedMethods []string `yaml:"allowed_methods"`
|
||||
AllowedHeaders []string `yaml:"allowed_headers"`
|
||||
ExposedHeaders []string `yaml:"exposed_headers"`
|
||||
AllowCredentials bool `yaml:"allow_credentials"`
|
||||
}
|
||||
|
||||
func (c CORSSettings) Validate() error {
|
||||
if !c.AllowCredentials {
|
||||
return nil
|
||||
}
|
||||
if len(c.AllowedOrigins) == 0 {
|
||||
return merrors.InvalidArgument("credentialed CORS requires explicit allowed origins", "middleware.CORS.allowed_origins")
|
||||
}
|
||||
for _, origin := range c.AllowedOrigins {
|
||||
if strings.TrimSpace(origin) == "*" {
|
||||
return merrors.InvalidArgument("credentialed CORS does not allow wildcard origins", "middleware.CORS.allowed_origins")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Signature struct {
|
||||
PublicKey any
|
||||
PrivateKey []byte
|
||||
Algorithm string
|
||||
}
|
||||
|
||||
type SignatureConfig struct {
|
||||
PublicKeyEnv string `yaml:"public_key_env,omitempty"`
|
||||
PrivateKeyEnv string `yaml:"secret_key_env"`
|
||||
Algorithm string `yaml:"algorithm"`
|
||||
}
|
||||
|
||||
type TokenExpiration struct {
|
||||
Account int `yaml:"account"`
|
||||
Refresh int `yaml:"refresh"`
|
||||
}
|
||||
|
||||
type TokenConfig struct {
|
||||
Expiration TokenExpiration `yaml:"expiration_hours"`
|
||||
Length int `yaml:"length"`
|
||||
}
|
||||
|
||||
const MaxAccessTokenTTLHours = 1
|
||||
|
||||
func (c TokenConfig) Validate() error {
|
||||
if c.Expiration.Account <= 0 || c.Expiration.Account > MaxAccessTokenTTLHours {
|
||||
return merrors.InvalidArgument("access token expiration must be between 1 and 1 hour", "middleware.token.expiration_hours.account")
|
||||
}
|
||||
if c.Expiration.Refresh <= c.Expiration.Account {
|
||||
return merrors.InvalidArgument("refresh token expiration must exceed access token expiration", "middleware.token.expiration_hours.refresh")
|
||||
}
|
||||
if c.Length < 32 {
|
||||
return merrors.InvalidArgument("refresh token length must be at least 32 bytes", "middleware.token.length")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type WebSocketConfig struct {
|
||||
EndpointEnv string `yaml:"endpoint_env"`
|
||||
Timeout int `yaml:"timeout"`
|
||||
}
|
||||
|
||||
type PasswordChecks struct {
|
||||
Digit bool `yaml:"digit"`
|
||||
Upper bool `yaml:"upper"`
|
||||
Lower bool `yaml:"lower"`
|
||||
Special bool `yaml:"special"`
|
||||
MinLength int `yaml:"min_length"`
|
||||
}
|
||||
|
||||
type PasswordConfig struct {
|
||||
TokenLength int `yaml:"token_length"`
|
||||
Check PasswordChecks `yaml:"check"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
DomainEnv string `yaml:"domain_env"`
|
||||
EndPointEnv string `yaml:"api_endpoint_env"`
|
||||
APIProtocolEnv string `yaml:"api_protocol_env"`
|
||||
Signature SignatureConfig `yaml:"signature"`
|
||||
CORS CORSSettings `yaml:"CORS"`
|
||||
WebSocket WebSocketConfig `yaml:"websocket"`
|
||||
Token TokenConfig `yaml:"token"`
|
||||
Password PasswordConfig `yaml:"password"`
|
||||
LoginRateLimits *LoginRateLimitsConfig `yaml:"login_rate_limits"`
|
||||
}
|
||||
|
||||
type MapClaims = map[string]any
|
||||
|
||||
func getKey(osEnv string) any {
|
||||
if len(osEnv) == 0 {
|
||||
return nil
|
||||
}
|
||||
return []byte(os.Getenv(osEnv))
|
||||
}
|
||||
|
||||
func SignatureConf(conf *Config) Signature {
|
||||
return Signature{
|
||||
PrivateKey: []byte(os.Getenv(conf.Signature.PrivateKeyEnv)),
|
||||
PublicKey: getKey(conf.Signature.PublicKeyEnv),
|
||||
Algorithm: conf.Signature.Algorithm,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package middleware
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTokenConfigValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config TokenConfig
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "secure explicit TTLs",
|
||||
config: TokenConfig{
|
||||
Expiration: TokenExpiration{Account: 1, Refresh: 720},
|
||||
Length: 32,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "rejects legacy 24 hour access token",
|
||||
config: TokenConfig{
|
||||
Expiration: TokenExpiration{Account: 24, Refresh: 720},
|
||||
Length: 32,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "rejects missing access TTL",
|
||||
config: TokenConfig{
|
||||
Expiration: TokenExpiration{Refresh: 720},
|
||||
Length: 32,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "rejects weak refresh token",
|
||||
config: TokenConfig{
|
||||
Expiration: TokenExpiration{Account: 1, Refresh: 720},
|
||||
Length: 16,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.config.Validate()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("Validate() error = %v, wantErr %t", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
mduration "git.sendico.io/tech/sxchnge/bff/pkg/mutil/duration"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type AccountToken struct {
|
||||
AccountRef bson.ObjectID
|
||||
Login string
|
||||
Name string
|
||||
Locale string
|
||||
model.SessionIdentifier
|
||||
Expiration time.Time
|
||||
Pending bool
|
||||
}
|
||||
|
||||
func createAccountToken(a *model.Account, expiration int, session model.SessionIdentifier) AccountToken {
|
||||
return AccountToken{
|
||||
AccountRef: *a.GetID(),
|
||||
Login: a.Login,
|
||||
Name: a.Name,
|
||||
Locale: a.Locale,
|
||||
SessionIdentifier: session,
|
||||
Expiration: time.Now().Add(mduration.Param2Duration(expiration, time.Hour)),
|
||||
Pending: false,
|
||||
}
|
||||
}
|
||||
|
||||
func getTokenParam(claims middleware.MapClaims, param string) (string, error) {
|
||||
id, ok := claims[param].(string)
|
||||
if !ok {
|
||||
return "", merrors.NoData(fmt.Sprintf("param '%s' not found", param))
|
||||
}
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
const (
|
||||
paramNameID = "id"
|
||||
paramNameName = "name"
|
||||
paramNameLocale = "locale"
|
||||
paramNameLogin = "login"
|
||||
paramNameClientID = "clientId"
|
||||
paramNameDeviceID = "deviceId"
|
||||
paramNameSessionVersion = "sessionVersion"
|
||||
paramNameExpiration = "exp"
|
||||
paramNamePending = "pending"
|
||||
)
|
||||
|
||||
func Claims2Token(claims middleware.MapClaims) (*AccountToken, error) {
|
||||
var at AccountToken
|
||||
var err error
|
||||
var account string
|
||||
if account, err = getTokenParam(claims, paramNameID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if at.AccountRef, err = bson.ObjectIDFromHex(account); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if at.Login, err = getTokenParam(claims, paramNameLogin); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if at.Name, err = getTokenParam(claims, paramNameName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if at.Locale, err = getTokenParam(claims, paramNameLocale); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if clientID, ok := claims[paramNameClientID]; ok {
|
||||
if clientIDText, ok := clientID.(string); ok {
|
||||
at.ClientID = clientIDText
|
||||
}
|
||||
}
|
||||
if deviceID, ok := claims[paramNameDeviceID]; ok {
|
||||
if deviceIDText, ok := deviceID.(string); ok {
|
||||
at.DeviceID = deviceIDText
|
||||
}
|
||||
}
|
||||
if version, ok := claims[paramNameSessionVersion]; ok {
|
||||
if versionText, ok := version.(string); ok {
|
||||
at.SessionVersion = versionText
|
||||
}
|
||||
}
|
||||
if pending, ok := claims[paramNamePending]; ok {
|
||||
if pbool, ok := pending.(bool); ok {
|
||||
at.Pending = pbool
|
||||
}
|
||||
}
|
||||
if expValue, ok := claims[paramNameExpiration]; ok {
|
||||
switch exp := expValue.(type) {
|
||||
case time.Time:
|
||||
at.Expiration = exp
|
||||
case float64:
|
||||
at.Expiration = time.Unix(int64(exp), 0)
|
||||
case int64:
|
||||
at.Expiration = time.Unix(exp, 0)
|
||||
default:
|
||||
return nil, merrors.InvalidDataType(fmt.Sprintf("expiration param is of invalid type: %T", expValue))
|
||||
}
|
||||
} else {
|
||||
return nil, merrors.InvalidDataType(fmt.Sprintf("expiration param is of invalid type: %T", expValue))
|
||||
}
|
||||
return &at, nil
|
||||
}
|
||||
|
||||
func Account2ClaimsForSession(a *model.Account, expiration int, session model.SessionIdentifier) middleware.MapClaims {
|
||||
t := createAccountToken(a, expiration, session)
|
||||
return middleware.MapClaims{
|
||||
paramNameID: t.AccountRef.Hex(),
|
||||
paramNameLogin: t.Login,
|
||||
paramNameName: t.Name,
|
||||
paramNameLocale: t.Locale,
|
||||
paramNameClientID: t.ClientID,
|
||||
paramNameDeviceID: t.DeviceID,
|
||||
paramNameSessionVersion: t.SessionVersion,
|
||||
paramNameExpiration: t.Expiration.Unix(),
|
||||
paramNamePending: t.Pending,
|
||||
}
|
||||
}
|
||||
|
||||
func PendingAccount2Claims(a *model.Account, expirationMinutes int) middleware.MapClaims {
|
||||
t := createAccountToken(a, expirationMinutes/60, model.SessionIdentifier{})
|
||||
t.Expiration = time.Now().Add(time.Duration(expirationMinutes) * time.Minute)
|
||||
t.Pending = true
|
||||
return middleware.MapClaims{
|
||||
paramNameID: t.AccountRef.Hex(),
|
||||
paramNameLogin: t.Login,
|
||||
paramNameName: t.Name,
|
||||
paramNameLocale: t.Locale,
|
||||
paramNameExpiration: t.Expiration.Unix(),
|
||||
paramNamePending: t.Pending,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
basemodel "git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func TestAccountTokenPreservesSessionIdentity(t *testing.T) {
|
||||
account := &basemodel.Account{}
|
||||
account.SetID(bson.NewObjectID())
|
||||
account.Login = "security@example.com"
|
||||
account.Name = "Security"
|
||||
account.Locale = "en"
|
||||
session := basemodel.SessionIdentifier{
|
||||
ClientID: "web",
|
||||
DeviceID: "device-a",
|
||||
SessionVersion: "session-version-a",
|
||||
}
|
||||
|
||||
token, err := Claims2Token(Account2ClaimsForSession(account, 1, session))
|
||||
if err != nil {
|
||||
t.Fatalf("decode account claims: %v", err)
|
||||
}
|
||||
if token.SessionIdentifier != session {
|
||||
t.Fatalf("session identity = %#v, want %#v", token.SessionIdentifier, session)
|
||||
}
|
||||
if token.Expiration.IsZero() {
|
||||
t.Fatal("access token expiration is missing")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
)
|
||||
|
||||
const (
|
||||
MinWindow = time.Second
|
||||
MaxWindow = 24 * time.Hour
|
||||
MaxLimit = 10000
|
||||
)
|
||||
|
||||
type WindowConfig struct {
|
||||
Limit int `yaml:"limit"`
|
||||
Window string `yaml:"window"`
|
||||
}
|
||||
|
||||
type Window struct {
|
||||
Limit int
|
||||
Window time.Duration
|
||||
}
|
||||
|
||||
func ResolveWindow(raw WindowConfig, field string) (Window, error) {
|
||||
if raw.Limit < 1 || raw.Limit > MaxLimit {
|
||||
return Window{}, merrors.InvalidArgument("rate limit must be between 1 and 10000", field+".limit")
|
||||
}
|
||||
window, err := time.ParseDuration(strings.TrimSpace(raw.Window))
|
||||
if err != nil {
|
||||
return Window{}, merrors.InvalidArgument("rate limit window must be a valid duration", field+".window")
|
||||
}
|
||||
if window < MinWindow || window > MaxWindow {
|
||||
return Window{}, merrors.InvalidArgument("rate limit window must be between 1s and 24h", field+".window")
|
||||
}
|
||||
return Window{Limit: raw.Limit, Window: window}, nil
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package storage
|
||||
|
||||
import "git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
|
||||
type Driver string
|
||||
|
||||
const (
|
||||
LocalFS Driver = "local_fs"
|
||||
AWSS3 Driver = "aws_s3"
|
||||
)
|
||||
|
||||
type Config = model.DriverConfig[Driver]
|
||||
|
||||
type LocalFSConfig struct {
|
||||
RootPath string `mapstructure:"root_path" yaml:"root_path"`
|
||||
}
|
||||
|
||||
type AWSS3Config struct {
|
||||
AccessKeyIDEnv string `mapstructure:"access_key_id_env" yaml:"access_key_id_env"`
|
||||
SecretAccessKeyEnv string `mapstructure:"secret_access_key_env" yaml:"secret_access_key_env"`
|
||||
RegionEnv string `mapstructure:"region_env" yaml:"region_env"`
|
||||
BucketNameEnv string `mapstructure:"bucket_name_env" yaml:"bucket_name_env"`
|
||||
EndpointEnv string `mapstructure:"endpoint_env" yaml:"endpoint_env"`
|
||||
UsePathStyle bool `mapstructure:"use_path_style" yaml:"use_path_style"`
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api"
|
||||
httptransport "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http"
|
||||
amr "git.sendico.io/tech/sxchnge/bff/pkg/api/routers"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/domainprovider"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
)
|
||||
|
||||
type Microservices = []mservice.MicroService
|
||||
|
||||
// APIImp represents the structure of the APIImp
|
||||
type APIImp struct {
|
||||
lifecycleCtx context.Context
|
||||
logger mlogger.Logger
|
||||
db db.Factory
|
||||
domain domainprovider.DomainProvider
|
||||
config *api.Config
|
||||
profile api.ConfigProvider
|
||||
runtime api.RuntimeConfig
|
||||
components api.RuntimeComponents
|
||||
services Microservices
|
||||
health amr.Health
|
||||
mw *httptransport.Middleware
|
||||
}
|
||||
|
||||
func (a *APIImp) LifecycleContext() context.Context {
|
||||
return a.lifecycleCtx
|
||||
}
|
||||
|
||||
func (a *APIImp) Logger() mlogger.Logger {
|
||||
return a.logger
|
||||
}
|
||||
|
||||
func (a *APIImp) Config() *api.Config {
|
||||
return a.config
|
||||
}
|
||||
|
||||
func (a *APIImp) ProfileConfig() api.ConfigProvider {
|
||||
return a.profile
|
||||
}
|
||||
|
||||
func (a *APIImp) DBFactory() db.Factory {
|
||||
return a.db
|
||||
}
|
||||
|
||||
func (a *APIImp) DomainProvider() domainprovider.DomainProvider {
|
||||
return a.domain
|
||||
}
|
||||
|
||||
func (a *APIImp) Register() api.Register {
|
||||
return a.mw
|
||||
}
|
||||
|
||||
func (a *APIImp) Permissions() auth.Provider {
|
||||
return a.db.Permissions()
|
||||
}
|
||||
|
||||
func (a *APIImp) RuntimeComponent(name string) any {
|
||||
if a == nil || a.components == nil {
|
||||
return nil
|
||||
}
|
||||
return a.components[name]
|
||||
}
|
||||
|
||||
func (a *APIImp) Name() string {
|
||||
return "api"
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api"
|
||||
httptransport "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http"
|
||||
amr "git.sendico.io/tech/sxchnge/bff/pkg/api/routers"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/domainprovider"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func CreateAPI(
|
||||
lifecycleCtx context.Context,
|
||||
logger mlogger.Logger,
|
||||
config *api.Config,
|
||||
profileConfig api.ConfigProvider,
|
||||
db db.Factory,
|
||||
rootRouter *chi.Mux,
|
||||
apiRouter *chi.Mux,
|
||||
debug bool,
|
||||
runtime api.RuntimeConfig,
|
||||
) (mservice.MicroService, error) {
|
||||
p := &APIImp{
|
||||
lifecycleCtx: lifecycleCtx,
|
||||
logger: logger.Named("api"),
|
||||
config: config,
|
||||
profile: profileConfig,
|
||||
db: db,
|
||||
runtime: runtime,
|
||||
}
|
||||
|
||||
if err := p.installRuntimeBasics(logger, db, rootRouter, apiRouter, debug); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := p.bootstrapConfiguredRuntime(config, db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p.logger.Info("Installing microservices...", zap.String("runtime", runtime.RuntimeName()))
|
||||
if err := p.installServices(); err != nil {
|
||||
p.logger.Error("Failed to install a microservice", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
p.logger.Info("Microservices installation complete", zap.Int("microservices", len(p.services)))
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (a *APIImp) installRuntimeBasics(
|
||||
logger mlogger.Logger,
|
||||
db db.Factory,
|
||||
rootRouter *chi.Mux,
|
||||
apiRouter *chi.Mux,
|
||||
debug bool,
|
||||
) error {
|
||||
var err error
|
||||
if a.domain, err = domainprovider.CreateDomainProvider(
|
||||
a.logger,
|
||||
a.config.Mw.DomainEnv,
|
||||
a.config.Mw.APIProtocolEnv,
|
||||
a.config.Mw.EndPointEnv,
|
||||
); err != nil {
|
||||
a.logger.Error("Failed to initialize domain provider")
|
||||
return err
|
||||
}
|
||||
a.logger.Info("Domain provider installed")
|
||||
|
||||
apiEndpoint := os.Getenv(a.config.Mw.EndPointEnv)
|
||||
if a.health, err = amr.NewHealthRouter(a.logger, rootRouter, apiEndpoint); err != nil {
|
||||
a.logger.Error("Failed to create healthcheck router", zap.Error(err), zap.String("api_endpoint", apiEndpoint))
|
||||
return err
|
||||
}
|
||||
if a.mw, err = httptransport.CreateMiddleware(
|
||||
logger,
|
||||
db,
|
||||
a.db.Permissions().Enforcer(),
|
||||
apiRouter,
|
||||
a.config.Mw,
|
||||
a.config.AuthStorage,
|
||||
a.runtime.EnableWebSocket,
|
||||
debug,
|
||||
); err != nil {
|
||||
a.logger.Error("Failed to create middleware", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
a.logger.Info("Middleware installed", zap.Bool("debug_mode", debug))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *APIImp) bootstrapConfiguredRuntime(_ *api.Config, _ db.Factory) error {
|
||||
if a.runtime.Bootstrap != nil {
|
||||
components, err := a.runtime.Bootstrap(a)
|
||||
if err != nil {
|
||||
a.logger.Error("Failed to bootstrap runtime profile", zap.Error(err), zap.String("runtime", a.runtime.RuntimeName()))
|
||||
return err
|
||||
}
|
||||
a.components = components
|
||||
a.logger.Info("Runtime profile bootstrap complete",
|
||||
zap.String("runtime", a.runtime.RuntimeName()),
|
||||
zap.Int("components", len(components)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/api/routers/health"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *APIImp) Finish(ctx context.Context) error {
|
||||
a.health.SetStatus(health.SSTerminating)
|
||||
a.health.Finish()
|
||||
a.mw.Finish()
|
||||
var lastError error
|
||||
for i := len(a.services) - 1; i >= 0; i-- {
|
||||
if err := (a.services[i]).Finish(ctx); err != nil {
|
||||
lastError = err
|
||||
a.logger.Warn("Error occurred when finishing service",
|
||||
zap.Error(err), zap.String("service_name", (a.services[i]).Name()))
|
||||
} else {
|
||||
a.logger.Info("Microservice is down", zap.String("service_name", (a.services[i]).Name()))
|
||||
}
|
||||
}
|
||||
if a.runtime.Finish != nil {
|
||||
if err := a.runtime.Finish(a); err != nil {
|
||||
lastError = err
|
||||
a.logger.Warn("Error occurred when finishing runtime profile", zap.String("runtime", a.runtime.RuntimeName()), zap.Error(err))
|
||||
}
|
||||
}
|
||||
return lastError
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/api/routers/health"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (a *APIImp) installMicroservice(srv mservice.MicroService) {
|
||||
a.services = append(a.services, srv)
|
||||
a.logger.Info("Microservice installed", zap.String("service", srv.Name()))
|
||||
}
|
||||
|
||||
func (a *APIImp) addMicroservice(srvf api.MicroServiceFactoryT) error {
|
||||
srv, err := srvf(a)
|
||||
if err != nil {
|
||||
a.logger.Error("Failed to install a microservice", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
a.installMicroservice(srv)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *APIImp) installServices() error {
|
||||
for _, service := range a.runtime.Services {
|
||||
if service.Factory == nil {
|
||||
return merrors.InvalidArgument("microservice factory is required", "runtime.services")
|
||||
}
|
||||
if err := a.addMicroservice(service.Factory); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
a.health.SetStatus(health.SSRunning)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIncompleteConfigReturnsProjectError(t *testing.T) {
|
||||
for _, data := range []string{"api: {}", "api: {middleware: {}}\ndatabase: {driver: mongodb}", "api: {middleware: {}}\ndatabase: {driver: mongodb}\nhttp_server: {}"} {
|
||||
instance := &Imp{runtime: api.RuntimeConfig{}}
|
||||
if err := instance.decodeConfig([]byte(data)); !errors.Is(err, merrors.ErrInvalidArg) {
|
||||
t.Fatalf("expected configuration error, got %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestExampleConfigMatchesCopiedRuntime(t *testing.T) {
|
||||
data, err := os.ReadFile("../../../../config.example.yml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
instance := &Imp{runtime: api.RuntimeConfig{}}
|
||||
if err := instance.decodeConfig(data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := instance.config.API.SharedConfig().Mw.Token.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := instance.config.API.SharedConfig().Mw.CORS.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
ac "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api"
|
||||
sharedruntime "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/runtime"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
mduration "git.sendico.io/tech/sxchnge/bff/pkg/mutil/duration"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"go.uber.org/zap"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type httpServerConf struct {
|
||||
ListenAddress string `yaml:"listen_address"`
|
||||
ReadHeaderTimeout int `yaml:"read_header_timeout"`
|
||||
ShutdownTimeout int `yaml:"shutdown_timeout"`
|
||||
}
|
||||
|
||||
// Config represents the server configuration
|
||||
type Config struct {
|
||||
API ac.ConfigProvider
|
||||
DB *db.Config `yaml:"database"`
|
||||
HTTPServer *httpServerConf `yaml:"http_server"`
|
||||
}
|
||||
|
||||
// Instance represents an instance of the server
|
||||
type Imp struct {
|
||||
lifecycleCtx context.Context
|
||||
lifecycleCancel context.CancelFunc
|
||||
logger mlogger.Logger
|
||||
api mservice.MicroService
|
||||
config *Config
|
||||
db db.Factory
|
||||
httpServer *http.Server
|
||||
runtime ac.RuntimeConfig
|
||||
debug bool
|
||||
file string
|
||||
}
|
||||
|
||||
// Shutdown stops the server
|
||||
func (i *Imp) Shutdown() {
|
||||
if i.lifecycleCancel != nil {
|
||||
i.lifecycleCancel()
|
||||
}
|
||||
if i.httpServer == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Shutdown HTTP server
|
||||
ctx, cancel := context.WithTimeout(context.Background(), mduration.Param2Duration(i.config.HTTPServer.ShutdownTimeout, time.Second))
|
||||
i.logger.Info("Shutting HTTP server down...")
|
||||
if err := i.httpServer.Shutdown(ctx); err != nil {
|
||||
if !errors.Is(err, http.ErrServerClosed) {
|
||||
i.logger.Warn("Failed to shutdown HTTP server gracefully", zap.Error(err))
|
||||
cancel()
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
|
||||
func (i *Imp) Run() error {
|
||||
err := i.httpServer.ListenAndServe()
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
err = nil
|
||||
}
|
||||
i.logger.Info("HTTP Server stopped")
|
||||
|
||||
if err := i.api.Finish(context.Background()); err != nil {
|
||||
i.logger.Warn("Error when finishing service", zap.Error(err))
|
||||
}
|
||||
|
||||
i.db.CloseConnection()
|
||||
|
||||
if err != nil {
|
||||
return merrors.OpaqueWrap(merrors.ErrInternal, err, "HTTP server stopped unexpectedly")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start starts the server
|
||||
func (i *Imp) Start() error {
|
||||
if i.lifecycleCancel != nil {
|
||||
defer i.lifecycleCancel()
|
||||
}
|
||||
i.logger.Info("Starting...", zap.String("config_file", i.file), zap.Bool("debug_mode", i.debug))
|
||||
// Load configuration file
|
||||
data, err := os.ReadFile(i.file)
|
||||
if err != nil {
|
||||
i.logger.Error("Could not load configuration", zap.Error(err), zap.String("config_file", i.file))
|
||||
return err
|
||||
}
|
||||
|
||||
if err = i.decodeConfig(data); err != nil {
|
||||
i.logger.Error("Failed to parse configuration", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
if i.db, err = db.NewConnection(i.logger, i.config.DB); err != nil {
|
||||
i.logger.Error("Could not open database connection", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
rootRouter := chi.NewRouter()
|
||||
apiRouter := chi.NewRouter()
|
||||
if i.api, err = sharedruntime.CreateAPI(i.lifecycleCtx, i.logger, i.config.API.SharedConfig(), i.config.API, i.db, rootRouter, apiRouter, i.debug, i.runtime); err != nil {
|
||||
i.db.CloseConnection()
|
||||
return err
|
||||
}
|
||||
rootRouter.Mount("/", apiRouter)
|
||||
|
||||
// Startup the HTTP Server in a way that we can gracefully shut it down again
|
||||
i.httpServer = &http.Server{
|
||||
Addr: i.config.HTTPServer.ListenAddress,
|
||||
Handler: rootRouter,
|
||||
ReadHeaderTimeout: mduration.Param2Duration(i.config.HTTPServer.ReadHeaderTimeout, time.Second),
|
||||
}
|
||||
|
||||
return i.Run()
|
||||
}
|
||||
|
||||
func (i *Imp) decodeConfig(data []byte) error {
|
||||
var raw struct {
|
||||
API yaml.Node `yaml:"api"`
|
||||
DB *db.Config `yaml:"database"`
|
||||
HTTPServer *httpServerConf `yaml:"http_server"`
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
apiConfig := i.runtime.NewConfig()
|
||||
if apiConfig == nil {
|
||||
return merrors.Internal("runtime config factory returned nil api config")
|
||||
}
|
||||
if raw.API.Kind == 0 {
|
||||
return merrors.InvalidArgument("api configuration is required", "api")
|
||||
}
|
||||
if err := raw.API.Decode(apiConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if raw.DB == nil || raw.HTTPServer == nil || apiConfig.SharedConfig() == nil || apiConfig.SharedConfig().Mw == nil {
|
||||
return merrors.InvalidArgument("database, http_server and api.middleware configuration are required")
|
||||
}
|
||||
if raw.HTTPServer.ListenAddress == "" || raw.HTTPServer.ReadHeaderTimeout <= 0 || raw.HTTPServer.ShutdownTimeout <= 0 {
|
||||
return merrors.InvalidArgument("HTTP address and positive timeouts are required")
|
||||
}
|
||||
i.config = &Config{
|
||||
API: apiConfig,
|
||||
DB: raw.DB,
|
||||
HTTPServer: raw.HTTPServer,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Create(logger mlogger.Logger, file string, debug bool, runtime ac.RuntimeConfig) (*Imp, error) {
|
||||
lifecycleCtx, lifecycleCancel := context.WithCancel(context.Background())
|
||||
srv := &Imp{
|
||||
lifecycleCtx: lifecycleCtx,
|
||||
lifecycleCancel: lifecycleCancel,
|
||||
logger: logger,
|
||||
debug: debug,
|
||||
file: file,
|
||||
runtime: runtime,
|
||||
}
|
||||
return srv, nil
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestShutdownCancelsLifecycleContextBeforeStartupCompletes(t *testing.T) {
|
||||
instance, err := Create(zap.NewNop(), "config.yml", false, api.RuntimeConfig{})
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
|
||||
instance.Shutdown()
|
||||
if !errors.Is(instance.lifecycleCtx.Err(), context.Canceled) {
|
||||
t.Fatalf("lifecycle context error = %v, want context.Canceled", instance.lifecycleCtx.Err())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package httptransport
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/auth/browser"
|
||||
authstorage "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/storage"
|
||||
edgeapi "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse"
|
||||
wsh "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/ws"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
|
||||
httprouter "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/router"
|
||||
mr "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/router/metrics"
|
||||
ws "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/websocket"
|
||||
api "git.sendico.io/tech/sxchnge/bff/pkg/api/http"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"github.com/go-chi/chi/v5"
|
||||
cm "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/go-chi/metrics"
|
||||
"go.uber.org/zap"
|
||||
"moul.io/chizap"
|
||||
)
|
||||
|
||||
type Middleware struct {
|
||||
logger mlogger.Logger
|
||||
router *chi.Mux
|
||||
apiEndpoint string
|
||||
metrics mr.Metrics
|
||||
wshandler ws.Router
|
||||
epdispatcher *httprouter.Dispatcher
|
||||
}
|
||||
|
||||
func (mw *Middleware) Handler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc) {
|
||||
mw.epdispatcher.Handler(service, endpoint, method, handler)
|
||||
}
|
||||
|
||||
func (mw *Middleware) AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc) {
|
||||
mw.epdispatcher.AccountHandler(service, endpoint, method, handler)
|
||||
}
|
||||
|
||||
func (mw *Middleware) PendingAccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.PendingAccountHandlerFunc) {
|
||||
mw.epdispatcher.PendingAccountHandler(service, endpoint, method, handler)
|
||||
}
|
||||
|
||||
func (mw *Middleware) WSHandler(messageType string, handler wsh.HandlerFunc) {
|
||||
if mw.wshandler == nil {
|
||||
mw.logger.Info("WebSocket handler registration skipped because websocket router is disabled", zap.String("message_type", messageType))
|
||||
return
|
||||
}
|
||||
mw.wshandler.InstallHandler(messageType, handler)
|
||||
}
|
||||
|
||||
func (mw *Middleware) Finish() {
|
||||
mw.metrics.Finish()
|
||||
}
|
||||
|
||||
func (mw *Middleware) installMiddleware(config *middleware.Config, debug bool) {
|
||||
mw.logger.Debug("Installing middleware stack...")
|
||||
// Collect metrics for application traffic only. Scrapes and health probes are
|
||||
// operational traffic and would otherwise distort request and in-flight SLIs.
|
||||
mw.router.Use(metrics.Collector(metrics.CollectorOpts{
|
||||
Host: false, // avoid high-cardinality "host" label
|
||||
Proto: true, // include HTTP protocol label
|
||||
Skip: func(r *http.Request) bool {
|
||||
return isObservabilityRequest(mw.apiEndpoint, r.URL.Path)
|
||||
},
|
||||
}))
|
||||
mw.router.Use(cm.RequestID)
|
||||
if debug {
|
||||
mw.router.Use(chizap.New(mw.logger.Named("http_trace"), &chizap.Opts{
|
||||
WithReferer: true,
|
||||
WithUserAgent: true,
|
||||
}))
|
||||
}
|
||||
mw.router.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: config.CORS.AllowedOrigins,
|
||||
AllowedMethods: config.CORS.AllowedMethods,
|
||||
AllowedHeaders: config.CORS.AllowedHeaders,
|
||||
ExposedHeaders: config.CORS.ExposedHeaders,
|
||||
AllowCredentials: config.CORS.AllowCredentials,
|
||||
MaxAge: config.CORS.MaxAge,
|
||||
OptionsPassthrough: false,
|
||||
Debug: debug,
|
||||
}))
|
||||
mw.router.Use(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := browser.ValidateCSRF(r, config.CORS.AllowedOrigins); err != nil {
|
||||
mw.logger.Info("Rejected browser request with invalid CSRF context",
|
||||
zap.String("method", r.Method), zap.String("path", r.URL.Path))
|
||||
response.Forbidden(mw.logger, mservice.Accounts, "csrf_rejected", "browser request failed CSRF validation").ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
})
|
||||
mw.router.Use(cm.Recoverer)
|
||||
mw.router.Handle("/metrics", metrics.Handler())
|
||||
mw.logger.Info("Middleware stack installation complete")
|
||||
}
|
||||
|
||||
func isObservabilityRequest(apiEndpoint, requestPath string) bool {
|
||||
healthPath := path.Join("/", apiEndpoint, "health")
|
||||
metricsPath := path.Join("/", apiEndpoint, "metrics")
|
||||
|
||||
return requestPath == healthPath || requestPath == metricsPath || requestPath == "/metrics"
|
||||
}
|
||||
|
||||
func CreateMiddleware(
|
||||
logger mlogger.Logger,
|
||||
db db.Factory,
|
||||
enforcer auth.Enforcer,
|
||||
apiRouter *chi.Mux,
|
||||
config *middleware.Config,
|
||||
authStorage *edgeapi.AuthStorageConfig,
|
||||
installWebSocket bool,
|
||||
debug bool,
|
||||
) (*Middleware, error) {
|
||||
if config == nil {
|
||||
return nil, merrors.InvalidArgument("middleware configuration is required", "middleware")
|
||||
}
|
||||
if err := config.Token.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := config.CORS.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
loginRateLimits, err := config.ResolveLoginRateLimits()
|
||||
if err != nil {
|
||||
logger.Error("Invalid login rate limit configuration", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
p := &Middleware{
|
||||
logger: logger.Named("middleware"),
|
||||
router: apiRouter,
|
||||
apiEndpoint: os.Getenv(config.EndPointEnv),
|
||||
}
|
||||
p.logger.Info("Set endpoint", zap.String("endpoint", p.apiEndpoint))
|
||||
p.installMiddleware(config, debug)
|
||||
if p.metrics, err = mr.NewMetricsRouter(p.logger, p.router, p.apiEndpoint); err != nil {
|
||||
p.logger.Error("Failed to create metrics router", zap.Error(err), zap.String("api_endpoint", p.apiEndpoint))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stores, err := authstorage.OpenStores(p.logger, db, authStorage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p.epdispatcher = httprouter.NewDispatcher(
|
||||
p.logger,
|
||||
p.router,
|
||||
stores.Accounts,
|
||||
stores.Verification,
|
||||
stores.RefreshTokens,
|
||||
stores.LoginAttempts,
|
||||
enforcer,
|
||||
config,
|
||||
loginRateLimits,
|
||||
)
|
||||
if installWebSocket {
|
||||
p.wshandler = ws.NewRouter(p.logger, p.router, &config.WebSocket, p.apiEndpoint)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package httptransport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
amr "git.sendico.io/tech/sxchnge/bff/pkg/api/routers"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/api/routers/health"
|
||||
"github.com/go-chi/chi/v5"
|
||||
chimetrics "github.com/go-chi/metrics"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
"moul.io/chizap"
|
||||
)
|
||||
|
||||
func TestHTTPCollectorDoesNotGatherHealthOrMetricsRequests(t *testing.T) {
|
||||
router := chi.NewRouter()
|
||||
router.Use(chimetrics.Collector(chimetrics.CollectorOpts{
|
||||
Proto: true,
|
||||
Skip: func(r *http.Request) bool {
|
||||
return isObservabilityRequest("/api/v1", r.URL.Path)
|
||||
},
|
||||
}))
|
||||
router.Get("/api/v1/health", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||
router.Handle("/metrics", chimetrics.Handler())
|
||||
router.Handle("/api/v1/metrics", chimetrics.Handler())
|
||||
router.Get("/api/v1/accounts", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||
|
||||
beforeHealth := gatheredHTTPRequestCount(t, "GET /api/v1/health")
|
||||
beforeMetrics := gatheredHTTPRequestCount(t, "GET /metrics")
|
||||
beforeAPIMetrics := gatheredHTTPRequestCount(t, "GET /api/v1/metrics")
|
||||
beforeApplication := gatheredHTTPRequestCount(t, "GET /api/v1/accounts")
|
||||
|
||||
for _, requestPath := range []string{"/api/v1/health", "/metrics", "/api/v1/metrics", "/api/v1/accounts"} {
|
||||
request := httptest.NewRequestWithContext(context.Background(), http.MethodGet, requestPath, nil)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("GET %s returned %d", requestPath, response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
if got := gatheredHTTPRequestCount(t, "GET /api/v1/health") - beforeHealth; got != 0 {
|
||||
t.Fatalf("health probe changed http_requests_total by %v", got)
|
||||
}
|
||||
if got := gatheredHTTPRequestCount(t, "GET /metrics") - beforeMetrics; got != 0 {
|
||||
t.Fatalf("metrics scrape changed http_requests_total by %v", got)
|
||||
}
|
||||
if got := gatheredHTTPRequestCount(t, "GET /api/v1/metrics") - beforeAPIMetrics; got != 0 {
|
||||
t.Fatalf("API metrics scrape changed http_requests_total by %v", got)
|
||||
}
|
||||
if got := gatheredHTTPRequestCount(t, "GET /api/v1/accounts") - beforeApplication; got != 1 {
|
||||
t.Fatalf("application request changed http_requests_total by %v, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func gatheredHTTPRequestCount(t *testing.T, endpoint string) float64 {
|
||||
t.Helper()
|
||||
|
||||
families, err := prometheus.DefaultGatherer.Gather()
|
||||
if err != nil {
|
||||
t.Fatalf("gather metrics: %v", err)
|
||||
}
|
||||
for _, family := range families {
|
||||
if family.GetName() != "http_requests_total" {
|
||||
continue
|
||||
}
|
||||
|
||||
var total float64
|
||||
for _, metric := range family.GetMetric() {
|
||||
for _, label := range metric.GetLabel() {
|
||||
if label.GetName() == "endpoint" && label.GetValue() == endpoint {
|
||||
total += metric.GetCounter().GetValue()
|
||||
}
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func TestObservabilityRequestsAreExcludedFromHTTPMetrics(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
apiEndpoint string
|
||||
requestPath string
|
||||
want bool
|
||||
}{
|
||||
{name: "health under API endpoint", apiEndpoint: "/api/v1", requestPath: "/api/v1/health", want: true},
|
||||
{name: "metrics at root", apiEndpoint: "/api/v1", requestPath: "/metrics", want: true},
|
||||
{name: "metrics under API endpoint", apiEndpoint: "/api/v1", requestPath: "/api/v1/metrics", want: true},
|
||||
{name: "trailing endpoint slash", apiEndpoint: "/api/v1/", requestPath: "/api/v1/health", want: true},
|
||||
{name: "application endpoint", apiEndpoint: "/api/v1", requestPath: "/api/v1/accounts", want: false},
|
||||
{name: "health suffix is not a probe", apiEndpoint: "/api/v1", requestPath: "/api/v1/health/history", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isObservabilityRequest(tt.apiEndpoint, tt.requestPath); got != tt.want {
|
||||
t.Fatalf("isObservabilityRequest(%q, %q) = %t, want %t", tt.apiEndpoint, tt.requestPath, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthMountedOutsideTraceMiddleware(t *testing.T) {
|
||||
core, observed := observer.New(zapcore.InfoLevel)
|
||||
logger := zap.New(core)
|
||||
|
||||
rootRouter := chi.NewRouter()
|
||||
apiRouter := chi.NewRouter()
|
||||
|
||||
apiRouter.Use(chizap.New(logger.Named("middleware").Named("http_trace"), &chizap.Opts{}))
|
||||
apiRouter.Get("/api/v1/accounts", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
healthRouter, err := amr.NewHealthRouter(logger.Named("middleware"), rootRouter, "/api/v1")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create health router: %v", err)
|
||||
}
|
||||
healthRouter.SetStatus(health.SSRunning)
|
||||
|
||||
rootRouter.Mount("/", apiRouter)
|
||||
|
||||
healthReq := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/v1/health", nil)
|
||||
healthRes := httptest.NewRecorder()
|
||||
rootRouter.ServeHTTP(healthRes, healthReq)
|
||||
if healthRes.Code != http.StatusOK {
|
||||
t.Fatalf("expected health status %d, got %d", http.StatusOK, healthRes.Code)
|
||||
}
|
||||
if got := observed.FilterMessage("Served").Len(); got != 0 {
|
||||
t.Fatalf("expected no trace logs for /health, got %d", got)
|
||||
}
|
||||
|
||||
apiReq := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/v1/accounts", nil)
|
||||
apiRes := httptest.NewRecorder()
|
||||
rootRouter.ServeHTTP(apiRes, apiReq)
|
||||
if apiRes.Code != http.StatusOK {
|
||||
t.Fatalf("expected api status %d, got %d", http.StatusOK, apiRes.Code)
|
||||
}
|
||||
|
||||
entries := observed.FilterMessage("Served").All()
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("expected one trace log for non-health request, got %d", len(entries))
|
||||
}
|
||||
if got := entries[0].ContextMap()["path"]; got != "/api/v1/accounts" {
|
||||
t.Fatalf("expected logged path %q, got %#v", "/api/v1/accounts", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package authorized
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/auth/sessionmetrics"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/http/ipguard"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse"
|
||||
emodel "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/model"
|
||||
api "git.sendico.io/tech/sxchnge/bff/pkg/api/http"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"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 model.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)
|
||||
}
|
||||
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 model.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)
|
||||
}
|
||||
return handler(r, &a, t)
|
||||
}
|
||||
ar.tokenHandler(service, endpoint, method, hndlr)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package authorized
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/auth/sessionmetrics"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
|
||||
emodel "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/model"
|
||||
re "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/router/endpoint"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens"
|
||||
api "git.sendico.io/tech/sxchnge/bff/pkg/api/http"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/account"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"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, enforcer auth.Enforcer, config *middleware.TokenConfig, signature *middleware.Signature) *AuthorizedRouter {
|
||||
ja := jwtauth.New(signature.Algorithm, signature.PrivateKey, signature.PublicKey)
|
||||
router.Use(jwtauth.Verifier(ja))
|
||||
router.Use(jwtauth.Authenticator(ja))
|
||||
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"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
emodel "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type sessionDB struct {
|
||||
refreshtokens.DB
|
||||
token *model.RefreshToken
|
||||
err error
|
||||
revokeAllID string
|
||||
}
|
||||
|
||||
func (db *sessionDB) GetSession(context.Context, bson.ObjectID, *model.SessionIdentifier) (*model.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: model.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: &model.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: model.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,66 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
|
||||
rauthorized "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/router/authorized"
|
||||
rpublic "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/router/public"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/ratelimits"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/verification"
|
||||
api "git.sendico.io/tech/sxchnge/bff/pkg/api/http"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/account"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
)
|
||||
|
||||
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 NewDispatcher(
|
||||
logger mlogger.Logger,
|
||||
router chi.Router,
|
||||
db account.DB,
|
||||
vdb verification.DB,
|
||||
rtdb refreshtokens.DB,
|
||||
attempts ratelimits.Store,
|
||||
enforcer auth.Enforcer,
|
||||
config *middleware.Config,
|
||||
loginRateLimits middleware.LoginRateLimits,
|
||||
) *Dispatcher {
|
||||
d := &Dispatcher{
|
||||
logger: logger.Named("api_dispatcher"),
|
||||
}
|
||||
|
||||
d.logger.Debug("Installing endpoints middleware...")
|
||||
endpoint := os.Getenv(config.EndPointEnv)
|
||||
signature := middleware.SignatureConf(config)
|
||||
router.Group(func(r chi.Router) {
|
||||
d.public = rpublic.NewRouter(d.logger, endpoint, db, vdb, rtdb, attempts, r, &config.Token, &signature, loginRateLimits)
|
||||
})
|
||||
router.Group(func(r chi.Router) {
|
||||
d.protected = rauthorized.NewRouter(d.logger, endpoint, r, db, rtdb, enforcer, &config.Token, &signature)
|
||||
})
|
||||
|
||||
return d
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package endpoint
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type (
|
||||
RegistratorT = func(chi.Router, string, http.HandlerFunc)
|
||||
ResponderFunc = func(ctx context.Context, r *http.Request, session *model.SessionIdentifier, account *model.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/bff/edge/shared/interface/api/sresponse"
|
||||
api "git.sendico.io/tech/sxchnge/bff/pkg/api/http"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"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,30 @@
|
||||
package endpoint
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse"
|
||||
emodel "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
)
|
||||
|
||||
func (er *HttpEndpointRouter) CreateAccessTokenForSession(user *model.Account, session model.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 *model.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,40 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/metrics"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type metricsRouter struct {
|
||||
logger mlogger.Logger
|
||||
handler http.Handler
|
||||
}
|
||||
|
||||
func (mr *metricsRouter) Finish() {
|
||||
mr.logger.Debug("Stopped")
|
||||
}
|
||||
|
||||
func (mr *metricsRouter) handle(w http.ResponseWriter, r *http.Request) {
|
||||
mr.logger.Debug("Serving metrics request...")
|
||||
mr.handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func newMetricsRouter(logger mlogger.Logger, router chi.Router, endpoint string) *metricsRouter {
|
||||
mr := metricsRouter{
|
||||
logger: logger.Named("metrics"),
|
||||
handler: metrics.Handler(),
|
||||
}
|
||||
|
||||
logger.Debug("Installing Prometheus middleware...")
|
||||
router.Group(func(r chi.Router) {
|
||||
ep := endpoint + "/metrics"
|
||||
r.Get(ep, mr.handle)
|
||||
logger.Info("Prometheus handler installed", zap.String("endpoint", ep))
|
||||
})
|
||||
|
||||
return &mr
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type Metrics interface {
|
||||
Finish()
|
||||
}
|
||||
|
||||
func NewMetricsRouter(logger mlogger.Logger, router chi.Router, endpoint string) (Metrics, error) {
|
||||
return newMetricsRouter(logger, router, endpoint), nil
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
clientauth "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/client"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/http/ipguard"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/srequest"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mask"
|
||||
"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) (*model.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) {
|
||||
(&model.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() {
|
||||
return nil, false, response.Forbidden(pr.logger, pr.service, "account_not_verified", "Account verification required")
|
||||
}
|
||||
|
||||
return account, false, nil
|
||||
}
|
||||
|
||||
func (pr *PublicRouter) respondPendingLogin(account *model.Account) http.HandlerFunc {
|
||||
pendingToken, err := pr.imp.CreatePendingToken(account, pendingLoginTTLMinutes)
|
||||
if err != nil {
|
||||
pr.logger.Warn("Failed to generate pending token", zap.Error(err))
|
||||
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 *model.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) (*model.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 *model.Client, account *model.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 *model.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 *model.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) (*model.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 *model.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)
|
||||
req.Password = strings.TrimSpace(req.Password)
|
||||
req.ClientID = strings.TrimSpace(req.ClientID)
|
||||
req.DeviceID = strings.TrimSpace(req.DeviceID)
|
||||
req.ClientSecret = strings.TrimSpace(req.ClientSecret)
|
||||
|
||||
if req.Login == "" {
|
||||
return 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
|
||||
}
|
||||
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"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/http/ipguard"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/srequest"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/ratelimits"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"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,
|
||||
) (*model.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"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/srequest"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"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: model.LoginData{
|
||||
UserDataBase: model.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: model.LoginData{
|
||||
UserDataBase: model.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 !(&model.Account{Password: dummyPasswordHash}).MatchPassword("sendico-login-dummy-password") {
|
||||
t.Fatal("dummy password hash is not a valid bcrypt hash")
|
||||
}
|
||||
if (&model.Account{Password: dummyPasswordHash}).MatchPassword("different") {
|
||||
t.Fatal("dummy password hash unexpectedly matched another password")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/srequest"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"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: model.LoginData{
|
||||
UserDataBase: model.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, model.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, model.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: &model.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 := NewRouter(
|
||||
zap.NewNop(),
|
||||
"/",
|
||||
&testAccountDB{result: account},
|
||||
nil,
|
||||
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(), &model.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(), &model.ClientRefreshToken{
|
||||
SessionIdentifier: model.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(), &model.ClientRefreshToken{
|
||||
SessionIdentifier: model.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,288 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"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) *model.Account {
|
||||
t.Helper()
|
||||
return testAccountWithStatus(t, model.AccountActive)
|
||||
}
|
||||
|
||||
func testAccountWithStatus(t *testing.T, status model.AccountStatus) *model.Account {
|
||||
t.Helper()
|
||||
|
||||
account := &model.Account{
|
||||
AccountPublic: model.AccountPublic{
|
||||
UserDataBase: model.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 *model.Account
|
||||
err error
|
||||
getByEmailCalls int
|
||||
}
|
||||
|
||||
func (db *testAccountDB) GetByEmail(ctx context.Context, email string) (*model.Account, error) {
|
||||
db.mu.Lock()
|
||||
defer db.mu.Unlock()
|
||||
db.getByEmailCalls++
|
||||
return db.result, db.err
|
||||
}
|
||||
|
||||
func (db *testAccountDB) GetAccountsByRefs(ctx context.Context, orgRef bson.ObjectID, refs []bson.ObjectID) ([]model.Account, error) {
|
||||
return nil, merrors.NotImplemented("stub")
|
||||
}
|
||||
|
||||
func (db *testAccountDB) Create(ctx context.Context, object *model.Account) error {
|
||||
return merrors.NotImplemented("stub")
|
||||
}
|
||||
|
||||
func (db *testAccountDB) InsertMany(ctx context.Context, objects []*model.Account) error {
|
||||
return merrors.NotImplemented("stub")
|
||||
}
|
||||
|
||||
func (db *testAccountDB) Get(ctx context.Context, objectRef bson.ObjectID, result *model.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 *model.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 *model.Account) error {
|
||||
return merrors.NotImplemented("stub")
|
||||
}
|
||||
|
||||
type testRefreshTokenDB struct {
|
||||
client *model.Client
|
||||
clientErr error
|
||||
tokens []*model.RefreshToken
|
||||
}
|
||||
|
||||
func (db *testRefreshTokenDB) GetClient(ctx context.Context, clientID string) (*model.Client, error) {
|
||||
return db.client, db.clientErr
|
||||
}
|
||||
|
||||
func (db *testRefreshTokenDB) Revoke(ctx context.Context, accountRef bson.ObjectID, session *model.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 *model.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 *model.SessionIdentifier) (*model.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 *model.ClientRefreshToken) (*model.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 *model.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 []*model.RefreshToken) error {
|
||||
return merrors.NotImplemented("stub")
|
||||
}
|
||||
|
||||
func (db *testRefreshTokenDB) Get(ctx context.Context, objectRef bson.ObjectID, result *model.RefreshToken) error {
|
||||
return merrors.NotImplemented("stub")
|
||||
}
|
||||
|
||||
func (db *testRefreshTokenDB) Update(ctx context.Context, object *model.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 *model.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 *model.RefreshToken) *model.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/bff/edge/shared/auth/browser"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/auth/sessionmetrics"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/srequest"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"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"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/auth/browser"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"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]*model.RefreshToken
|
||||
lookupErr error
|
||||
revokeErr error
|
||||
}
|
||||
|
||||
func (db *logoutDB) RevokeToken(_ context.Context, request *model.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) *model.RefreshToken {
|
||||
return &model.RefreshToken{
|
||||
ClientRefreshToken: model.ClientRefreshToken{
|
||||
SessionIdentifier: model.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]*model.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]*model.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]*model.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]*model.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]*model.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]*model.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]*model.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,53 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/auth/browser"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/srequest"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"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,137 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/auth/browser"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"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: &model.Client{ClientID: clientID},
|
||||
tokens: []*model.RefreshToken{
|
||||
{
|
||||
ClientRefreshToken: model.ClientRefreshToken{
|
||||
SessionIdentifier: model.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 NewRouter(
|
||||
zap.NewNop(),
|
||||
"/api/v1",
|
||||
&testAccountDB{result: account},
|
||||
nil,
|
||||
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"))
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
rtokens "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/tokens"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (pr *PublicRouter) refreshAndRespondLogin(
|
||||
ctx context.Context,
|
||||
r *http.Request,
|
||||
session *model.SessionIdentifier,
|
||||
account *model.Account,
|
||||
) http.HandlerFunc {
|
||||
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 response.Internal(pr.logger, pr.service, 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 response.Internal(pr.logger, pr.service, err)
|
||||
}
|
||||
|
||||
token := sresponse.TokenData{
|
||||
Token: refreshToken.RefreshToken,
|
||||
Expiration: refreshToken.ExpiresAt,
|
||||
}
|
||||
return sresponse.Login(pr.logger, account, &accessToken, &token)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/srequest"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"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,69 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
|
||||
re "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/router/endpoint"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/ratelimits"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/verification"
|
||||
api "git.sendico.io/tech/sxchnge/bff/pkg/api/http"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/account"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type PublicRouter struct {
|
||||
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 NewRouter(
|
||||
logger mlogger.Logger,
|
||||
apiEndpoint string,
|
||||
db account.DB,
|
||||
vdb verification.DB,
|
||||
rtdb refreshtokens.DB,
|
||||
attempts ratelimits.Store,
|
||||
router chi.Router,
|
||||
config *middleware.TokenConfig,
|
||||
signature *middleware.Signature,
|
||||
limits middleware.LoginRateLimits,
|
||||
) *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,
|
||||
}
|
||||
|
||||
hr.InstallHandler(hr.service, "/login", api.Post, hr.login)
|
||||
hr.InstallHandler(hr.service, "/login/api", api.Post, hr.apiLogin)
|
||||
hr.InstallHandler(hr.service, "/rotate", api.Post, hr.rotateRefreshToken)
|
||||
hr.InstallHandler(hr.service, "/refresh", api.Post, hr.refreshAccessToken)
|
||||
hr.InstallHandler(hr.service, "/logout", api.Post, hr.logout)
|
||||
|
||||
return &hr
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/auth/browser"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/srequest"
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"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 *model.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) (*model.Account, *sresponse.TokenData, *model.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 model.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
|
||||
}
|
||||
|
||||
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,16 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse"
|
||||
api "git.sendico.io/tech/sxchnge/bff/pkg/api/http"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/ws"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/net/websocket"
|
||||
)
|
||||
|
||||
type DispatcherImpl struct {
|
||||
logger mlogger.Logger
|
||||
handlers map[string]ws.HandlerFunc
|
||||
timeout int
|
||||
}
|
||||
|
||||
func (d *DispatcherImpl) InstallHandler(messageType string, handler ws.HandlerFunc) {
|
||||
d.handlers[messageType] = handler
|
||||
d.logger.Info("Handler installed", zap.String("message_type", messageType))
|
||||
}
|
||||
|
||||
func (d *DispatcherImpl) dispatchMessage(ctx context.Context, conn *websocket.Conn) {
|
||||
var msg ws.Message
|
||||
err := websocket.JSON.Receive(conn, &msg)
|
||||
if err != nil {
|
||||
d.logger.Warn("Failed to read websocket message", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
if handler, exists := d.handlers[msg.MessageType]; exists {
|
||||
responseHandler := handler(ctx, msg)
|
||||
responseHandler(msg.MessageType, conn)
|
||||
} else {
|
||||
d.logger.Warn("Unknown websocket message type", zap.String("message_type", msg.MessageType), zap.Any("message", &msg))
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DispatcherImpl) handle(w http.ResponseWriter, r *http.Request) {
|
||||
//nolint:contextcheck // websocket.Handler callback signature does not carry request context.
|
||||
websocket.Handler(func(conn *websocket.Conn) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(d.timeout)*time.Second)
|
||||
defer cancel()
|
||||
d.dispatchMessage(ctx, conn)
|
||||
}).ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func NewDispatcher(logger mlogger.Logger, router chi.Router, config *ws.Config, apiEndpoint string) *DispatcherImpl {
|
||||
d := &DispatcherImpl{
|
||||
logger: logger.Named("websocket"),
|
||||
handlers: make(map[string]ws.HandlerFunc),
|
||||
timeout: config.Timeout,
|
||||
}
|
||||
|
||||
d.logger.Debug("Installing websocket middleware...")
|
||||
router.Group(func(r chi.Router) {
|
||||
ep := fmt.Sprintf("%s%s", apiEndpoint, os.Getenv(config.EndpointEnv))
|
||||
d.logger.Info("Installing websockets handler", zap.String("endpoint", ep))
|
||||
r.Get(ep, d.handle)
|
||||
})
|
||||
|
||||
return d
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/ws"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type Router interface {
|
||||
InstallHandler(messageType string, handler ws.HandlerFunc)
|
||||
}
|
||||
|
||||
func NewRouter(logger mlogger.Logger, router chi.Router, config *ws.Config, apiEndpoint string) Router {
|
||||
return NewDispatcher(logger, router, config, apiEndpoint)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
edgeapi "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api"
|
||||
internalserver "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/server"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/server"
|
||||
)
|
||||
|
||||
func Create(logger mlogger.Logger, file string, debug bool, runtime edgeapi.RuntimeConfig) (server.Application, error) {
|
||||
return internalserver.Create(logger, file, debug, runtime)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package mongo
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/policy"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/transaction"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
// Dependencies resolves shared DB/auth/transaction primitives for edge-local store wiring only.
|
||||
type Dependencies struct {
|
||||
Database *mongo.Database
|
||||
Enforcer auth.Enforcer
|
||||
Policies policy.DB
|
||||
Transactions transaction.Factory
|
||||
}
|
||||
|
||||
type DatabaseProvider interface {
|
||||
MongoDatabase() *mongo.Database
|
||||
}
|
||||
|
||||
func ResolveDependencies(dbf db.Factory) (*Dependencies, error) {
|
||||
if dbf == nil {
|
||||
return nil, merrors.InvalidArgument("database factory is required", "dbFactory")
|
||||
}
|
||||
|
||||
provider, ok := dbf.(DatabaseProvider)
|
||||
if !ok || provider.MongoDatabase() == nil {
|
||||
return nil, merrors.Internal("mongo database provider is not available for edge-local storage wiring")
|
||||
}
|
||||
|
||||
permissions := dbf.Permissions()
|
||||
if permissions == nil || permissions.Enforcer() == nil {
|
||||
return nil, merrors.Internal("permissions provider is not available for edge-local storage wiring")
|
||||
}
|
||||
|
||||
pdb, err := dbf.NewPoliciesDB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Dependencies{
|
||||
Database: provider.MongoDatabase(),
|
||||
Enforcer: permissions.Enforcer(),
|
||||
Policies: pdb,
|
||||
Transactions: dbf.TransactionFactory(),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package organization
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// SetArchived sets the archived status of an organization and optionally cascades to projects, tasks, comments, and reactions
|
||||
func (db *OrganizationDB) SetArchived(ctx context.Context, accountRef, organizationRef bson.ObjectID, archived, cascade bool) error {
|
||||
db.DBImp.Logger.Debug("Setting organization archived status", mzap.OrgRef(organizationRef), zap.Bool("archived", archived), zap.Bool("cascade", cascade))
|
||||
|
||||
// Get the organization first
|
||||
var organization model.Organization
|
||||
if err := db.Get(ctx, accountRef, organizationRef, &organization); err != nil {
|
||||
db.DBImp.Logger.Warn("Error retrieving organization for archival", zap.Error(err), mzap.OrgRef(organizationRef))
|
||||
return err
|
||||
}
|
||||
|
||||
// Update the organization's archived status
|
||||
organization.SetArchived(archived)
|
||||
if err := db.Update(ctx, accountRef, &organization); err != nil {
|
||||
db.DBImp.Logger.Warn("Error updating organization archived status", zap.Error(err), mzap.OrgRef(organizationRef))
|
||||
return err
|
||||
}
|
||||
|
||||
db.DBImp.Logger.Debug("Successfully set organization archived status", mzap.OrgRef(organizationRef), zap.Bool("archived", archived))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package organization
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// DeleteCascade deletes an organization and all its related data (projects, tasks, comments, reactions, statuses)
|
||||
func (db *OrganizationDB) DeleteCascade(ctx context.Context, organizationRef bson.ObjectID) error {
|
||||
db.DBImp.Logger.Debug("Starting organization deletion with projects", mzap.OrgRef(organizationRef))
|
||||
|
||||
// Delete the organization itself
|
||||
if err := db.Unprotected().Delete(ctx, organizationRef); err != nil {
|
||||
db.DBImp.Logger.Warn("Error deleting organization", zap.Error(err), mzap.OrgRef(organizationRef))
|
||||
return err
|
||||
}
|
||||
|
||||
db.DBImp.Logger.Debug("Successfully deleted organization with projects", mzap.OrgRef(organizationRef))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package organization
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func (db *OrganizationDB) Create(ctx context.Context, _, _ bson.ObjectID, org *model.Organization) error {
|
||||
if org == nil {
|
||||
return merrors.InvalidArgument("Organization object is nil", "organization")
|
||||
}
|
||||
org.SetID(bson.NewObjectID())
|
||||
// Organizaiton reference must be set to the same value as own organization reference
|
||||
org.SetOrganizationRef(*org.GetID())
|
||||
return db.DBImp.Create(ctx, org)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package organization
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/policy"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type OrganizationDB struct {
|
||||
auth.ProtectedDBImp[*model.Organization]
|
||||
}
|
||||
|
||||
func Create(ctx context.Context,
|
||||
logger mlogger.Logger,
|
||||
enforcer auth.Enforcer,
|
||||
pdb policy.DB,
|
||||
db *mongo.Database,
|
||||
) (*OrganizationDB, error) {
|
||||
p, err := auth.CreateDBImp[*model.Organization](ctx, logger, pdb, enforcer, mservice.Organizations, db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res := &OrganizationDB{
|
||||
ProtectedDBImp: *p,
|
||||
}
|
||||
p.DBImp.SetDeleter(res.DeleteCascade)
|
||||
if err := ensureProvisioningIndexes(ctx, res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// ensureProvisioningIndexes guards concurrent operator-driven provisioning:
|
||||
// two attempts creating an organization for the same owner and name race the
|
||||
// find-then-create in the provisioner, and the unique index makes the loser
|
||||
// fail over to the winner's organization instead of duplicating it. The index
|
||||
// spans every provisioned state, not only pending: activation keeps the
|
||||
// provisioning provenance, so a retry racing a concurrent finalization is
|
||||
// stopped here rather than by the pre-create lookups alone. It is keyed by the
|
||||
// immutable provisioning name snapshot, not the mutable display name, so a
|
||||
// rename can neither free the key for a duplicate nor collide with a neighbour.
|
||||
func ensureProvisioningIndexes(ctx context.Context, db *OrganizationDB) error {
|
||||
definition := &ri.Definition{
|
||||
Name: "uq_organization_provisioning_owner_name",
|
||||
Keys: []ri.Key{
|
||||
{Field: model.OrganizationProvisioningOwnerRefField, Sort: ri.Asc},
|
||||
{Field: model.OrganizationProvisioningNameField, Sort: ri.Asc},
|
||||
},
|
||||
Unique: true,
|
||||
// Archived organizations leave the index: archiving is how an operator
|
||||
// retires a name, and a retired name must be reusable. Documents from
|
||||
// builds that predate the name snapshot stay out as well; sharing the
|
||||
// missing-name key would otherwise make them collide with each other.
|
||||
PartialFilter: repository.Query().
|
||||
Comparison(repository.Field(model.OrganizationProvisioningOwnerRefField), builder.Exists, true).
|
||||
Comparison(repository.Field(model.OrganizationProvisioningNameField), builder.Exists, true).
|
||||
Filter(repository.Field(storable.IsArchivedField), false),
|
||||
}
|
||||
if err := db.DBImp.Repository.CreateIndex(definition); err != nil {
|
||||
db.DBImp.Logger.Error("Failed to create provisioned organization index",
|
||||
zap.String("index", definition.Name), zap.Error(err))
|
||||
return merrors.InternalWrap(err, "failed to create provisioned organization index")
|
||||
}
|
||||
// Predecessors of this index on environments that ran earlier builds of
|
||||
// this feature: the pending-only variant ignored isArchived, the display
|
||||
// name variant let a rename detach the organization from its dedup key.
|
||||
if err := dropSupersededProvisioningIndexes(ctx, db.DBImp.Repository); err != nil {
|
||||
db.DBImp.Logger.Error("Failed to remove superseded organization provisioning indexes", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var supersededProvisioningIndexes = [...]string{
|
||||
"uq_organization_pending_provisioning_owner_name",
|
||||
"uq_organization_provisioned_owner_name",
|
||||
}
|
||||
|
||||
func dropSupersededProvisioningIndexes(ctx context.Context, repo repository.Repository) error {
|
||||
for _, name := range supersededProvisioningIndexes {
|
||||
if err := repo.DropIndex(ctx, name); err != nil {
|
||||
if provisioningIndexNotFound(err) {
|
||||
continue
|
||||
}
|
||||
return merrors.InternalWrap(err, "failed to drop superseded organization provisioning index "+name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
mongoNamespaceNotFoundCode = 26
|
||||
mongoIndexNotFoundCode = 27
|
||||
)
|
||||
|
||||
func provisioningIndexNotFound(err error) bool {
|
||||
var serverErr mongo.ServerError
|
||||
return errors.As(err, &serverErr) &&
|
||||
(serverErr.HasErrorCode(mongoNamespaceNotFoundCode) || serverErr.HasErrorCode(mongoIndexNotFoundCode))
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package organization
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func (db *OrganizationDB) GetByRef(ctx context.Context, organizationRef bson.ObjectID, org *model.Organization) error {
|
||||
return db.Unprotected().Get(ctx, organizationRef, org)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package organization
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
func TestDropSupersededProvisioningIndexesIgnoresMissingIndexes(t *testing.T) {
|
||||
repo := &dropIndexRepository{dropErrors: []error{
|
||||
mongo.CommandError{Code: mongoIndexNotFoundCode},
|
||||
mongo.CommandError{Code: mongoNamespaceNotFoundCode},
|
||||
}}
|
||||
|
||||
if err := dropSupersededProvisioningIndexes(context.Background(), repo); err != nil {
|
||||
t.Fatalf("missing superseded indexes must be ignored: %v", err)
|
||||
}
|
||||
if len(repo.dropped) != len(supersededProvisioningIndexes) {
|
||||
t.Fatalf("drop count mismatch: got=%d want=%d", len(repo.dropped), len(supersededProvisioningIndexes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropSupersededProvisioningIndexesPreservesUnexpectedFailure(t *testing.T) {
|
||||
wantErr := merrors.ServiceUnavailable("MongoDB is unavailable")
|
||||
repo := &dropIndexRepository{dropErrors: []error{wantErr}}
|
||||
|
||||
err := dropSupersededProvisioningIndexes(context.Background(), repo)
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("unexpected drop failure must be preserved: got=%v want=%v", err, wantErr)
|
||||
}
|
||||
if len(repo.dropped) != 1 {
|
||||
t.Fatalf("cleanup must stop after an unexpected failure: dropped=%v", repo.dropped)
|
||||
}
|
||||
}
|
||||
|
||||
type dropIndexRepository struct {
|
||||
repository.Repository
|
||||
dropErrors []error
|
||||
dropped []string
|
||||
}
|
||||
|
||||
func (r *dropIndexRepository) DropIndex(_ context.Context, name string) error {
|
||||
r.dropped = append(r.dropped, name)
|
||||
if len(r.dropErrors) == 0 {
|
||||
return nil
|
||||
}
|
||||
err := r.dropErrors[0]
|
||||
r.dropErrors = r.dropErrors[1:]
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package organization
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func (db *OrganizationDB) List(ctx context.Context, accountRef bson.ObjectID, cursor *model.ViewCursor) ([]model.Organization, error) {
|
||||
filter := repository.Query().Comparison(repository.Field("members"), builder.Eq, accountRef)
|
||||
return mutil.GetObjects[model.Organization](ctx, db.DBImp.Logger, filter, cursor, db.DBImp.Repository)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package organization
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (db *OrganizationDB) IsMember(ctx context.Context, orgRef, callerRef bson.ObjectID) (bool, error) {
|
||||
db.DBImp.Logger.Debug("Checking organization membership", mzap.OrgRef(orgRef), mzap.AccRef(callerRef))
|
||||
|
||||
filter := repository.Query().
|
||||
Filter(repository.IDField(), orgRef).
|
||||
Comparison(repository.Field("members"), builder.Eq, callerRef)
|
||||
var org model.Organization
|
||||
if err := db.Unprotected().FindOne(ctx, filter, &org); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return false, nil
|
||||
}
|
||||
db.DBImp.Logger.Warn("Membership check failed", zap.Error(err), mzap.OrgRef(orgRef), mzap.AccRef(callerRef))
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// AddMember atomically appends the account to the members list and reports
|
||||
// false when it already was one. A point patch on purpose: replacing the whole
|
||||
// document would clobber concurrent writes to fields the caller never read,
|
||||
// most notably the provisioning lease.
|
||||
func (db *OrganizationDB) AddMember(ctx context.Context, orgRef, accountRef bson.ObjectID) (bool, error) {
|
||||
filter := repository.Query().
|
||||
Filter(repository.IDField(), orgRef).
|
||||
Comparison(repository.Field("members"), builder.Ne, accountRef)
|
||||
patch := repository.Patch().AddToSet(repository.Field("members"), accountRef)
|
||||
|
||||
matched, err := db.DBImp.PatchMany(ctx, filter, patch)
|
||||
if err != nil {
|
||||
db.DBImp.Logger.Warn("Failed to add organization member", zap.Error(err), mzap.OrgRef(orgRef), mzap.AccRef(accountRef))
|
||||
return false, err
|
||||
}
|
||||
return matched > 0, nil
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package organization
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/template"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// membershipTestRepository shadows FindOneByFilter with a members-aware implementation.
|
||||
// When findErr is set, FindOneByFilter returns it immediately.
|
||||
type membershipTestRepository struct {
|
||||
*memoryOrganizationRepository
|
||||
findErr error
|
||||
}
|
||||
|
||||
func (r *membershipTestRepository) FindOneByFilter(_ context.Context, query builder.Query, result storable.Storable) error {
|
||||
if r.findErr != nil {
|
||||
return r.findErr
|
||||
}
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
for _, id := range r.order {
|
||||
if org, ok := r.data[id]; ok && memberMatchesQuery(query, org) {
|
||||
dst, ok := result.(*model.Organization)
|
||||
if !ok {
|
||||
return merrors.InvalidDataType("expected organization result")
|
||||
}
|
||||
*dst = *cloneOrganization(org)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return merrors.ErrNoData
|
||||
}
|
||||
|
||||
func memberMatchesQuery(query builder.Query, org *model.Organization) bool {
|
||||
if query == nil {
|
||||
return true
|
||||
}
|
||||
for _, elem := range query.BuildQuery() {
|
||||
switch elem.Key {
|
||||
case storable.IDField:
|
||||
id, ok := elem.Value.(bson.ObjectID)
|
||||
if !ok || *org.GetID() != id {
|
||||
return false
|
||||
}
|
||||
case "members":
|
||||
cond, ok := elem.Value.(bson.M)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
ref, ok := cond[string(builder.Eq)].(bson.ObjectID)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
found := false
|
||||
for _, m := range org.Members {
|
||||
if m == ref {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func newMemberTestDB(t *testing.T, findErr error) *OrganizationDB {
|
||||
t.Helper()
|
||||
repo := &membershipTestRepository{
|
||||
memoryOrganizationRepository: newMemoryOrganizationRepository(),
|
||||
findErr: findErr,
|
||||
}
|
||||
dbImp := &template.DBImp[*model.Organization]{
|
||||
Logger: zap.NewNop(),
|
||||
Repository: repo,
|
||||
}
|
||||
dbImp.SetDeleter(func(ctx context.Context, objectRef bson.ObjectID) error {
|
||||
return repo.Delete(ctx, objectRef)
|
||||
})
|
||||
return &OrganizationDB{
|
||||
ProtectedDBImp: auth.ProtectedDBImp[*model.Organization]{
|
||||
DBImp: dbImp,
|
||||
Enforcer: allowAllEnforcer{},
|
||||
PermissionRef: bson.NewObjectID(),
|
||||
Collection: mservice.Organizations,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrganizationDB_IsMember_Member(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := newMemberTestDB(t, nil)
|
||||
callerRef := bson.NewObjectID()
|
||||
|
||||
org := &model.Organization{}
|
||||
org.SetID(bson.NewObjectID())
|
||||
org.Members = []bson.ObjectID{callerRef}
|
||||
require.NoError(t, db.Create(ctx, bson.NewObjectID(), *org.GetID(), org))
|
||||
|
||||
ok, err := db.IsMember(ctx, *org.GetID(), callerRef)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, ok)
|
||||
}
|
||||
|
||||
func TestOrganizationDB_IsMember_NonMember(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := newMemberTestDB(t, nil)
|
||||
callerRef := bson.NewObjectID()
|
||||
otherRef := bson.NewObjectID()
|
||||
|
||||
org := &model.Organization{}
|
||||
org.SetID(bson.NewObjectID())
|
||||
org.Members = []bson.ObjectID{otherRef}
|
||||
require.NoError(t, db.Create(ctx, bson.NewObjectID(), *org.GetID(), org))
|
||||
|
||||
ok, err := db.IsMember(ctx, *org.GetID(), callerRef)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestOrganizationDB_IsMember_OrgNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := newMemberTestDB(t, nil)
|
||||
|
||||
ok, err := db.IsMember(ctx, bson.NewObjectID(), bson.NewObjectID())
|
||||
require.NoError(t, err)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestOrganizationDB_IsMember_DBError(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
boom := merrors.Internal("backend down")
|
||||
db := newMemberTestDB(t, boom)
|
||||
|
||||
ok, err := db.IsMember(ctx, bson.NewObjectID(), bson.NewObjectID())
|
||||
assert.False(t, ok)
|
||||
assert.True(t, errors.Is(err, boom))
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
package organization
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
rd "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/decoder"
|
||||
ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func (m *memoryOrganizationRepository) Aggregate(context.Context, builder.Pipeline, rd.DecodingFunc) error {
|
||||
return merrors.NotImplemented("aggregate is not supported in memory repository")
|
||||
}
|
||||
|
||||
func (m *memoryOrganizationRepository) FindOneByFilter(_ context.Context, query builder.Query, result storable.Storable) error {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
for _, id := range m.order {
|
||||
if org, ok := m.data[id]; ok && m.matchesQuery(query, org) {
|
||||
dst, okCast := result.(*model.Organization)
|
||||
if !okCast {
|
||||
return merrors.InvalidDataType("expected organization result")
|
||||
}
|
||||
*dst = *cloneOrganization(org)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return merrors.ErrNoData
|
||||
}
|
||||
|
||||
func (m *memoryOrganizationRepository) FindFirstByFilter(ctx context.Context, query builder.Query, result storable.Storable) error {
|
||||
return m.FindOneByFilter(ctx, query, result)
|
||||
}
|
||||
|
||||
func (m *memoryOrganizationRepository) FindManyByFilter(context.Context, builder.Query, rd.DecodingFunc) error {
|
||||
return merrors.NotImplemented("FindManyByFilter is not supported in memory repository")
|
||||
}
|
||||
|
||||
func (m *memoryOrganizationRepository) FindAllByFilter(_ context.Context, query builder.Query, result any) error {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
dst, ok := result.(*[]model.Organization)
|
||||
if !ok {
|
||||
return merrors.InvalidDataType("expected organization slice result")
|
||||
}
|
||||
*dst = (*dst)[:0]
|
||||
for _, id := range m.order {
|
||||
if org, ok := m.data[id]; ok && m.matchesQuery(query, org) {
|
||||
*dst = append(*dst, *cloneOrganization(org))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryOrganizationRepository) CountByFilter(_ context.Context, query builder.Query) (int64, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
var count int64
|
||||
for _, id := range m.order {
|
||||
if org, ok := m.data[id]; ok && m.matchesQuery(query, org) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (m *memoryOrganizationRepository) Patch(context.Context, bson.ObjectID, builder.Patch) error {
|
||||
return merrors.NotImplemented("Patch is not supported in memory repository")
|
||||
}
|
||||
|
||||
func (m *memoryOrganizationRepository) PatchOneByFilter(context.Context, builder.Query, builder.Patch, storable.Storable) (bool, error) {
|
||||
return false, merrors.NotImplemented("PatchOneByFilter is not supported in memory repository")
|
||||
}
|
||||
|
||||
func (m *memoryOrganizationRepository) PatchMany(_ context.Context, query builder.Query, patch builder.Patch) (int, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
matched := 0
|
||||
for _, id := range m.order {
|
||||
org, ok := m.data[id]
|
||||
if !ok || !m.matchesQuery(query, org) {
|
||||
continue
|
||||
}
|
||||
if err := applyOrganizationPatch(org, patch); err != nil {
|
||||
return matched, err
|
||||
}
|
||||
matched++
|
||||
}
|
||||
return matched, nil
|
||||
}
|
||||
|
||||
// applyOrganizationPatch supports the operations the provisioning storage
|
||||
// issues; anything else fails loudly so a test cannot pass vacuously.
|
||||
func applyOrganizationPatch(org *model.Organization, patch builder.Patch) error {
|
||||
for _, op := range patch.Build() {
|
||||
fields, ok := op.Value.(bson.D)
|
||||
if !ok {
|
||||
return merrors.NotImplemented("unsupported patch value shape: " + op.Key)
|
||||
}
|
||||
for _, field := range fields {
|
||||
switch op.Key {
|
||||
case string(builder.Set):
|
||||
if err := setOrganizationField(org, field.Key, field.Value); err != nil {
|
||||
return err
|
||||
}
|
||||
case string(builder.Unset):
|
||||
if err := unsetOrganizationField(org, field.Key); err != nil {
|
||||
return err
|
||||
}
|
||||
case string(builder.AddToSet):
|
||||
if field.Key != "members" {
|
||||
return merrors.NotImplemented("unsupported $addToSet field: " + field.Key)
|
||||
}
|
||||
member, ok := field.Value.(bson.ObjectID)
|
||||
if !ok {
|
||||
return merrors.InvalidDataType("expected member object id")
|
||||
}
|
||||
if !containsObjectID(org.Members, member) {
|
||||
org.Members = append(org.Members, member)
|
||||
}
|
||||
default:
|
||||
return merrors.NotImplemented("unsupported patch operation: " + op.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setOrganizationField(org *model.Organization, field string, value any) error {
|
||||
switch field {
|
||||
case storable.UpdatedAtField:
|
||||
if updatedAt, ok := value.(time.Time); ok {
|
||||
org.UpdatedAt = updatedAt
|
||||
}
|
||||
case model.OrganizationSignupStateField:
|
||||
switch state := value.(type) {
|
||||
case model.OrganizationSignupState:
|
||||
org.SignupState = state
|
||||
case string:
|
||||
org.SignupState = model.OrganizationSignupState(state)
|
||||
default:
|
||||
return merrors.InvalidDataType("expected signup state value")
|
||||
}
|
||||
case model.OrganizationProvisioningLeaseOwnerField:
|
||||
owner, ok := value.(string)
|
||||
if !ok || org.Provisioning == nil {
|
||||
return merrors.InvalidDataType("expected lease owner on a provisioned organization")
|
||||
}
|
||||
org.Provisioning.LeaseOwner = owner
|
||||
case model.OrganizationProvisioningLeaseField:
|
||||
expiresAt, ok := value.(time.Time)
|
||||
if !ok || org.Provisioning == nil {
|
||||
return merrors.InvalidDataType("expected lease expiry on a provisioned organization")
|
||||
}
|
||||
org.Provisioning.LeaseExpiresAt = expiresAt
|
||||
default:
|
||||
return merrors.NotImplemented("unsupported $set field: " + field)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unsetOrganizationField(org *model.Organization, field string) error {
|
||||
switch field {
|
||||
case model.OrganizationProvisioningField:
|
||||
org.Provisioning = nil
|
||||
case model.OrganizationProvisioningLeaseOwnerField:
|
||||
if org.Provisioning != nil {
|
||||
org.Provisioning.LeaseOwner = ""
|
||||
}
|
||||
case model.OrganizationProvisioningLeaseField:
|
||||
if org.Provisioning != nil {
|
||||
org.Provisioning.LeaseExpiresAt = time.Time{}
|
||||
}
|
||||
default:
|
||||
return merrors.NotImplemented("unsupported $unset field: " + field)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func containsObjectID(ids []bson.ObjectID, id bson.ObjectID) bool {
|
||||
for _, item := range ids {
|
||||
if item == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *memoryOrganizationRepository) DeleteMany(context.Context, builder.Query) error {
|
||||
return merrors.NotImplemented("DeleteMany is not supported in memory repository")
|
||||
}
|
||||
|
||||
func (m *memoryOrganizationRepository) CreateIndex(*ri.Definition) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryOrganizationRepository) DropIndex(context.Context, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryOrganizationRepository) ListIndexDefinitions(context.Context) ([]*ri.Definition, error) {
|
||||
return nil, merrors.NotImplemented("ListIndexDefinitions is not supported in memory repository")
|
||||
}
|
||||
|
||||
func (m *memoryOrganizationRepository) ListIDs(_ context.Context, query builder.Query) ([]bson.ObjectID, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
var ids []bson.ObjectID
|
||||
for _, id := range m.order {
|
||||
if org, ok := m.data[id]; ok && m.matchesQuery(query, org) {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, merrors.ErrNoData
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (m *memoryOrganizationRepository) ListPermissionBound(_ context.Context, query builder.Query) ([]model.PermissionBoundStorable, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
var res []model.PermissionBoundStorable
|
||||
for _, org := range m.data {
|
||||
if m.matchesQuery(query, org) {
|
||||
res = append(res, cloneOrganization(org))
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (m *memoryOrganizationRepository) ListAccountBound(context.Context, builder.Query) ([]model.AccountBoundStorable, error) {
|
||||
return nil, merrors.NotImplemented("Account bound list not supported")
|
||||
}
|
||||
|
||||
func (m *memoryOrganizationRepository) matchesQuery(query builder.Query, org *model.Organization) bool {
|
||||
if query == nil {
|
||||
return true
|
||||
}
|
||||
for _, elem := range query.BuildQuery() {
|
||||
switch elem.Key {
|
||||
case storable.IDField:
|
||||
if !matchesObjectIDConstraint(*org.GetID(), elem.Value) {
|
||||
return false
|
||||
}
|
||||
case storable.IsArchivedField:
|
||||
value, ok := elem.Value.(bool)
|
||||
if ok && org.IsArchived() != value {
|
||||
return false
|
||||
}
|
||||
case model.OrganizationSignupStateField:
|
||||
if !matchesStringConstraint(string(org.SignupState), elem.Value) {
|
||||
return false
|
||||
}
|
||||
case model.NameField:
|
||||
if !matchesStringConstraint(org.Name, elem.Value) {
|
||||
return false
|
||||
}
|
||||
case model.OrganizationProvisioningOwnerRefField:
|
||||
if org.Provisioning == nil {
|
||||
return false
|
||||
}
|
||||
if exists, ok := existsConstraint(elem.Value); ok {
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !matchesObjectIDConstraint(org.Provisioning.OwnerRef, elem.Value) {
|
||||
return false
|
||||
}
|
||||
case model.OrganizationProvisioningNameField:
|
||||
if org.Provisioning == nil || !matchesStringConstraint(org.Provisioning.Name, elem.Value) {
|
||||
return false
|
||||
}
|
||||
case model.OrganizationProvisioningLeaseOwnerField:
|
||||
if org.Provisioning == nil || !matchesStringConstraint(org.Provisioning.LeaseOwner, elem.Value) {
|
||||
return false
|
||||
}
|
||||
case "members":
|
||||
if !matchesMembersConstraint(org.Members, elem.Value) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func matchesStringConstraint(current string, value any) bool {
|
||||
switch expected := value.(type) {
|
||||
case string:
|
||||
return current == expected
|
||||
case model.OrganizationSignupState:
|
||||
return current == string(expected)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func existsConstraint(value any) (bool, bool) {
|
||||
ops, ok := value.(bson.M)
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
exists, ok := ops[string(builder.Exists)].(bool)
|
||||
return exists, ok
|
||||
}
|
||||
|
||||
func matchesMembersConstraint(members []bson.ObjectID, value any) bool {
|
||||
ops, ok := value.(bson.M)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for op, raw := range ops {
|
||||
member, ok := raw.(bson.ObjectID)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch op {
|
||||
case string(builder.Eq):
|
||||
if !containsObjectID(members, member) {
|
||||
return false
|
||||
}
|
||||
case string(builder.Ne):
|
||||
if containsObjectID(members, member) {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func matchesObjectIDConstraint(id bson.ObjectID, value any) bool {
|
||||
if direct, ok := value.(bson.ObjectID); ok {
|
||||
return id == direct
|
||||
}
|
||||
ops, ok := value.(bson.M)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for op, raw := range ops {
|
||||
ids, ok := raw.([]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
contains := false
|
||||
for _, item := range ids {
|
||||
if itemID, ok := item.(bson.ObjectID); ok && itemID == id {
|
||||
contains = true
|
||||
break
|
||||
}
|
||||
}
|
||||
switch op {
|
||||
case string(builder.In):
|
||||
if !contains {
|
||||
return false
|
||||
}
|
||||
case string(builder.NotIn):
|
||||
if contains {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
Loaded 100 of 556 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user