// Package twofactor owns second-factor policy, challenges and one-use proofs. // It deliberately has no dependency on auth HTTP, JWTs or session creation. package twofactor import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "errors" "strings" "time" customer "git.sendico.io/tech/sxchnge/product/customers/model" account "git.sendico.io/tech/sxchnge/product/customers/store" model "git.sendico.io/tech/sxchnge/product/twofactor/model" vmodel "git.sendico.io/tech/sxchnge/product/verification/model" verification "git.sendico.io/tech/sxchnge/product/verification/store" "git.sendico.io/tech/sxchnge/shared/database/transaction" merrors "git.sendico.io/tech/sxchnge/shared/errors" mlogger "git.sendico.io/tech/sxchnge/shared/logging" "go.mongodb.org/mongo-driver/v2/bson" ) const TTL = 10 * time.Minute const Cooldown = time.Minute const proofTTL = 2 * time.Minute const primary = "primary-email" // API is consumed by auth and other operation owners. Complete joins code // verification, proof consumption and the owner's local database action. type API interface { Begin(context.Context, model.Request) (model.Challenge, error) Resend(context.Context, model.Request, string) (model.Challenge, error) Complete(context.Context, bson.ObjectID, model.Scope, string, string, func(context.Context) error) error } type Store interface { Profile(context.Context, bson.ObjectID) (*model.Profile, error) CreateProfile(context.Context, *model.Profile) error UpdateProfile(context.Context, *model.Profile) error Challenge(context.Context, bson.ObjectID) (*model.Record, error) CreateChallenge(context.Context, *model.Record) error UpdateChallenge(context.Context, *model.Record) error } type Driver interface { Channel() model.Channel Normalize(string) (string, error) Mask(string) string Send(context.Context, model.Delivery) error } type Service struct { accounts account.DB tokens verification.DB store Store tx transaction.Factory secret []byte drivers map[model.Channel]Driver logger mlogger.Logger } func New(accounts account.DB, tokens verification.DB, store Store, tx transaction.Factory, secret []byte, logger mlogger.Logger, drivers ...Driver) (*Service, error) { if accounts == nil || tokens == nil || store == nil || tx == nil || len(secret) < 32 || logger == nil { return nil, merrors.InvalidArgument("2FA dependencies and private key material are required") } s := &Service{accounts: accounts, tokens: tokens, store: store, tx: tx, secret: append([]byte(nil), secret...), drivers: map[model.Channel]Driver{}, logger: logger.Named("twofactor")} for _, d := range drivers { if d == nil || s.drivers[d.Channel()] != nil { return nil, merrors.InvalidArgument("unique second-factor channel drivers required") } s.drivers[d.Channel()] = d } if s.drivers[model.Email] == nil { return nil, merrors.InvalidArgument("verified account email channel is required") } return s, nil } func (s *Service) key(parts ...string) string { h := hmac.New(sha256.New, s.secret) for _, p := range parts { h.Write([]byte(p)) h.Write([]byte{0}) } return hex.EncodeToString(h.Sum(nil)) } func validScope(scope model.Scope) bool { if strings.TrimSpace(scope.ContextRef) == "" || len(scope.ContextRef) > 256 || strings.ContainsRune(scope.ContextRef, 0) { return false } switch scope.Purpose { case vmodel.PurposeLogin, vmodel.PurposePayout, vmodel.PurposeFactorPreference: return true } return false } func scopeKey(scope model.Scope) string { return string(scope.Purpose) + ":" + scope.ContextRef } func (s *Service) profile(ctx context.Context, id bson.ObjectID) (*customer.Account, *model.Profile, string, error) { a := &customer.Account{} if err := s.accounts.Get(ctx, id, a); err != nil { return nil, nil, "", err } if !a.IsActive() || a.IsArchived() { return nil, nil, "", merrors.Unauthorized("active verified account required") } p, err := s.store.Profile(ctx, id) if errors.Is(err, merrors.ErrNoData) { p = &model.Profile{Preferred: primary, Revision: "initial"} p.ID = id } else if err != nil { return nil, nil, "", err } // The primary address is authorized by account activation, never by request data. factors := []model.Factor{{ID: primary, Channel: model.Email, Address: a.Login, Verified: true}} for _, f := range p.Factors { if f.ID != primary { factors = append(factors, f) } } p.Factors = factors version := s.key("profile", id.Hex(), a.Login, p.Revision) return a, p, version, nil } func (s *Service) preferred(p *model.Profile) (model.Factor, error) { for _, f := range p.Factors { if f.ID == p.Preferred && f.Verified && s.drivers[f.Channel] != nil { return f, nil } } return model.Factor{}, merrors.Unauthorized("configured second factor is unavailable") } func (s *Service) record(ctx context.Context, idText string, subject bson.ObjectID, scope model.Scope) (*model.Record, error) { id, err := bson.ObjectIDFromHex(idText) if err != nil { return nil, verification.ErorrTokenNotFound() } r, err := s.store.Challenge(ctx, id) if errors.Is(err, merrors.ErrNoData) { return nil, verification.ErorrTokenNotFound() } if err != nil { return nil, err } if r.Subject != subject || r.Scope != scope { return nil, verification.ErorrTokenNotFound() } if !r.ExpiresAt.After(time.Now()) { return nil, verification.ErorrTokenExpired() } _, _, version, err := s.profile(ctx, subject) if err != nil { return nil, err } if version != r.ProfileVersion { return nil, merrors.Unauthorized("second-factor settings changed; restart verification") } if s.drivers[r.Factor.Channel] == nil { return nil, merrors.NotImplemented("second-factor channel unavailable") } return r, nil }