Separate second-factor verification and delivery from auth (#40)
This commit is contained in:
1 parent
12a38cd654
commit
ecb63b61c6
33 files changed
+1772
-123
No files matched your search
@@ -0,0 +1,156 @@
|
||||
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)
|
||||
}
|
||||
Reference in new issue
Block a user