package twofactor import ( "context" "crypto/rand" "errors" 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" merrors "git.sendico.io/tech/sxchnge/shared/errors" "go.mongodb.org/mongo-driver/v2/bson" "time" ) func isRejection(err error) bool { return errors.Is(err, verification.ErrTokenNotFound) || errors.Is(err, verification.ErrTokenAlreadyUsed) || errors.Is(err, verification.ErrTokenExpired) || errors.Is(err, verification.ErrTokenAttemptsExceeded) } func (s *Service) saveProfile(ctx context.Context, p *model.Profile) error { _, err := s.store.Profile(ctx, p.ID) if errors.Is(err, merrors.ErrNoData) { return s.store.CreateProfile(ctx, p) } if err != nil { return err } return s.store.UpdateProfile(ctx, p) } func (s *Service) Settings(ctx context.Context, subject bson.ObjectID) (model.Settings, error) { _, p, _, err := s.profile(ctx, subject) if err != nil { return model.Settings{}, err } result := model.Settings{Required: true, Preferred: p.Preferred} for _, f := range p.Factors { if f.Verified { if driver := s.drivers[f.Channel]; driver != nil { result.Factors = append(result.Factors, model.Choice{ID: f.ID, Channel: f.Channel, Target: driver.Mask(f.Address)}) } } } return result, nil } func (s *Service) Enroll(ctx context.Context, subject bson.ObjectID, channel model.Channel, address, key, ip string) (model.Challenge, error) { driver := s.drivers[channel] if driver == nil { return model.Challenge{}, merrors.NotImplemented("second-factor channel is not supported") } normalized, err := driver.Normalize(address) if err != nil { return model.Challenge{}, err } _, p, _, err := s.profile(ctx, subject) if err != nil { return model.Challenge{}, err } if len(p.Factors) >= 5 { return model.Challenge{}, merrors.InvalidArgument("maximum enrolled factors reached") } for _, f := range p.Factors { if f.Channel == channel && f.Address == normalized { return model.Challenge{}, merrors.DataConflict("factor already enrolled") } } f := model.Factor{ID: s.key("factor", subject.Hex(), string(channel), normalized)[:24], Channel: channel, Address: normalized} scope := model.Scope{Purpose: vmodel.PurposeFactorEnrollment, ContextRef: "enroll:" + f.ID} return s.begin(ctx, model.Request{Subject: subject, Scope: scope, IdempotencyKey: key, IP: ip}, &f) } func (s *Service) ConfirmEnrollment(ctx context.Context, subject bson.ObjectID, id, code string) (model.Settings, error) { cid, err := bson.ObjectIDFromHex(id) if err != nil { return model.Settings{}, verification.ErorrTokenNotFound() } r, err := s.store.Challenge(ctx, cid) if err != nil { return model.Settings{}, err } if r.Subject != subject || r.Scope.Purpose != vmodel.PurposeFactorEnrollment { return model.Settings{}, verification.ErorrTokenNotFound() } err = s.complete(ctx, subject, r.Scope, id, code, func(tx context.Context) error { _, p, _, err := s.profile(tx, subject) if err != nil { return err } if len(p.Factors) >= 5 { return merrors.InvalidArgument("maximum enrolled factors reached") } for _, f := range p.Factors { if f.ID == r.Factor.ID { return merrors.DataConflict("factor already enrolled") } } factor := r.Factor factor.Verified = true p.Factors = append(p.Factors, factor) p.Revision = rand.Text() return s.saveProfile(tx, p) }) if err != nil { return model.Settings{}, err } return s.Settings(ctx, subject) } func (s *Service) BeginPreference(ctx context.Context, subject bson.ObjectID, factorID, key, ip string) (model.Challenge, error) { _, p, _, err := s.profile(ctx, subject) if err != nil { return model.Challenge{}, err } if !s.available(p, factorID) { return model.Challenge{}, merrors.InvalidArgument("verified supported factor required") } return s.Begin(ctx, model.Request{Subject: subject, Scope: preferenceScope(factorID), IdempotencyKey: key, IP: ip, Deadline: time.Now().Add(TTL)}) } func (s *Service) ConfirmPreference(ctx context.Context, subject bson.ObjectID, factorID, id, code string) (model.Settings, error) { scope := preferenceScope(factorID) err := s.Complete(ctx, subject, scope, id, code, func(tx context.Context) error { _, p, _, err := s.profile(tx, subject) if err != nil { return err } if !s.available(p, factorID) { return merrors.InvalidArgument("verified supported factor required") } p.Preferred = factorID p.Revision = rand.Text() return s.saveProfile(tx, p) }) if err != nil { return model.Settings{}, err } return s.Settings(ctx, subject) } func (s *Service) available(p *model.Profile, id string) bool { for _, f := range p.Factors { if f.ID == id && f.Verified && s.drivers[f.Channel] != nil { return true } } return false } // ResendSettings cannot be used to send or authorize a pending login challenge. func (s *Service) ResendSettings(ctx context.Context, subject bson.ObjectID, id, key, ip string) (model.Challenge, error) { cid, err := bson.ObjectIDFromHex(id) if err != nil { return model.Challenge{}, verification.ErorrTokenNotFound() } r, err := s.store.Challenge(ctx, cid) if err != nil { return model.Challenge{}, err } if r.Subject != subject || (r.Scope.Purpose != vmodel.PurposeFactorEnrollment && r.Scope.Purpose != vmodel.PurposeFactorPreference) { return model.Challenge{}, verification.ErorrTokenNotFound() } return s.Resend(ctx, model.Request{Subject: subject, Scope: r.Scope, IdempotencyKey: key, IP: ip}, id) }