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,138 @@
|
||||
package twofactor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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"
|
||||
"go.uber.org/zap"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Service) Begin(ctx context.Context, req model.Request) (model.Challenge, error) {
|
||||
if !validScope(req.Scope) {
|
||||
return model.Challenge{}, merrors.InvalidArgument("unsupported second-factor operation scope")
|
||||
}
|
||||
return s.begin(ctx, req, nil)
|
||||
}
|
||||
func (s *Service) begin(ctx context.Context, req model.Request, enrollment *model.Factor) (model.Challenge, error) {
|
||||
if req.Subject.IsZero() || strings.TrimSpace(req.IdempotencyKey) == "" || len(req.IdempotencyKey) > 128 || strings.ContainsRune(req.IdempotencyKey, 0) {
|
||||
return model.Challenge{}, merrors.InvalidArgument("subject and idempotency key required")
|
||||
}
|
||||
a, p, version, err := s.profile(ctx, req.Subject)
|
||||
if err != nil {
|
||||
return model.Challenge{}, err
|
||||
}
|
||||
factor, err := s.preferred(p)
|
||||
if err != nil {
|
||||
return model.Challenge{}, err
|
||||
}
|
||||
if enrollment != nil {
|
||||
factor = *enrollment
|
||||
}
|
||||
// Repeating an operation key resolves to the same immutable challenge snapshot.
|
||||
id, _ := bson.ObjectIDFromHex(s.key("challenge", req.Subject.Hex(), scopeKey(req.Scope), req.IdempotencyKey)[:24])
|
||||
expires := time.Now().UTC().Add(TTL).Truncate(time.Millisecond)
|
||||
if !req.Deadline.IsZero() && req.Deadline.Before(expires) {
|
||||
expires = req.Deadline.UTC().Truncate(time.Millisecond)
|
||||
}
|
||||
if !expires.After(time.Now()) {
|
||||
return model.Challenge{}, verification.ErorrTokenExpired()
|
||||
}
|
||||
locale := "en"
|
||||
if strings.HasPrefix(strings.ToLower(a.Locale), "ru") {
|
||||
locale = "ru"
|
||||
}
|
||||
candidate := &model.Record{Subject: req.Subject, Scope: req.Scope, Factor: factor, ProfileVersion: version, Locale: locale, ExpiresAt: expires}
|
||||
candidate.ID = id
|
||||
result, err := s.tx.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
|
||||
r, err := s.store.Challenge(tx, id)
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
r = candidate
|
||||
if err = s.store.CreateChallenge(tx, r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.Subject != req.Subject || r.Scope != req.Scope || r.Factor != factor || r.ProfileVersion != version {
|
||||
return nil, merrors.DataConflict("second-factor request changed")
|
||||
}
|
||||
return s.issue(tx, r, req.IdempotencyKey, req.IP)
|
||||
})
|
||||
if err != nil {
|
||||
return model.Challenge{}, err
|
||||
}
|
||||
return s.deliver(ctx, result)
|
||||
}
|
||||
func (s *Service) Resend(ctx context.Context, req model.Request, id string) (model.Challenge, error) {
|
||||
result, err := s.tx.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
|
||||
r, err := s.record(tx, id, req.Subject, req.Scope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.issue(tx, r, req.IdempotencyKey, req.IP)
|
||||
})
|
||||
if err != nil {
|
||||
return model.Challenge{}, err
|
||||
}
|
||||
return s.deliver(ctx, result)
|
||||
}
|
||||
|
||||
type issued struct {
|
||||
record *model.Record
|
||||
delivery model.Delivery
|
||||
resendAt time.Time
|
||||
}
|
||||
|
||||
func (s *Service) issue(ctx context.Context, r *model.Record, key, ip string) (issued, error) {
|
||||
if r.VerifiedAt != nil {
|
||||
return issued{}, verification.ErorrTokenAlreadyUsed()
|
||||
}
|
||||
if !r.ExpiresAt.After(time.Now()) {
|
||||
return issued{}, verification.ErorrTokenExpired()
|
||||
}
|
||||
if strings.TrimSpace(key) == "" || len(key) > 128 || strings.ContainsRune(key, 0) {
|
||||
return issued{}, merrors.InvalidArgument("idempotency key required")
|
||||
}
|
||||
if ip == "" {
|
||||
ip = "unresolved"
|
||||
}
|
||||
secretKey := s.key("code", r.ID.Hex(), key)
|
||||
now := time.Now().UTC()
|
||||
req := verification.NewOTPRequest(r.Subject, r.Scope.Purpose, r.Factor.ID).
|
||||
WithContextRef(r.ID.Hex()).WithTTL(time.Until(r.ExpiresAt)).WithCooldown(Cooldown).WithMaxRetries(5).WithIdempotencyKey(secretKey).
|
||||
WithIssuanceLimits(verification.NewIssuanceLimit("twofactor_subject", r.Subject.Hex(), 5, time.Hour, now), verification.NewIssuanceLimit("twofactor_ip", ip, 20, time.Hour, now))
|
||||
token, err := s.tokens.CreateWithResult(ctx, req)
|
||||
if err != nil {
|
||||
return issued{}, err
|
||||
}
|
||||
if !token.ExpiresAt.After(now) {
|
||||
return issued{}, verification.ErorrTokenExpired()
|
||||
}
|
||||
return issued{record: r, delivery: model.Delivery{Key: secretKey, Subject: r.Subject, Scope: r.Scope, Factor: r.Factor, Locale: r.Locale, Code: token.VerificationToken, ExpiresAt: token.ExpiresAt}, resendAt: now.Add(Cooldown)}, nil
|
||||
}
|
||||
func (s *Service) deliver(ctx context.Context, result any) (model.Challenge, error) {
|
||||
issued, ok := result.(issued)
|
||||
if !ok {
|
||||
return model.Challenge{}, merrors.Internal("invalid second-factor issuance result")
|
||||
}
|
||||
driver := s.drivers[issued.record.Factor.Channel]
|
||||
if driver == nil {
|
||||
return model.Challenge{}, merrors.NotImplemented("second-factor channel unavailable")
|
||||
}
|
||||
if err := driver.Send(ctx, issued.delivery); err != nil {
|
||||
s.logger.Warn("Second-factor delivery unavailable", zap.String("challenge", issued.record.ID.Hex()), zap.String("channel", string(driver.Channel())))
|
||||
return model.Challenge{}, merrors.ServiceUnavailableWrap(err, "second-factor delivery unavailable")
|
||||
}
|
||||
s.logger.Info("Second-factor challenge queued", zap.String("challenge", issued.record.ID.Hex()), zap.String("purpose", string(issued.record.Scope.Purpose)), zap.String("channel", string(driver.Channel())))
|
||||
return model.Challenge{ID: issued.record.ID.Hex(), Channel: driver.Channel(), Target: driver.Mask(issued.record.Factor.Address), ExpiresAt: issued.delivery.ExpiresAt, ResendAt: issued.resendAt}, nil
|
||||
}
|
||||
|
||||
func preferenceScope(factorID string) model.Scope {
|
||||
return model.Scope{Purpose: vmodel.PurposeFactorPreference, ContextRef: "preferred:" + factorID}
|
||||
}
|
||||
Reference in new issue
Block a user