Separate second-factor verification and delivery from auth (#40)

This commit is contained in:
Stephan D committed 2026-09-12 20:36:35 +02:00
1 parent 12a38cd654
commit ecb63b61c6
33 files changed
+1772 -123

No files matched your search

+138
View File
@@ -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}
}