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

+153
View File
@@ -0,0 +1,153 @@
package twofactor
import (
"context"
"crypto/rand"
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"
)
type verificationOutcome struct {
proof model.Proof
rejected error
}
func (s *Service) Verify(ctx context.Context, subject bson.ObjectID, scope model.Scope, id, code string) (model.Proof, error) {
if !validScope(scope) {
return model.Proof{}, merrors.InvalidArgument("unsupported second-factor operation scope")
}
return s.verify(ctx, subject, scope, id, code)
}
func (s *Service) verify(ctx context.Context, subject bson.ObjectID, scope model.Scope, id, code string) (model.Proof, error) {
code = strings.TrimSpace(code)
if len(code) != 6 || strings.IndexFunc(code, func(r rune) bool { return r < '0' || r > '9' }) >= 0 {
return model.Proof{}, verification.ErorrTokenNotFound()
}
result, err := s.tx.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
r, err := s.record(tx, id, subject, scope)
if err != nil {
return nil, err
}
if r.VerifiedAt != nil {
return nil, verification.ErorrTokenAlreadyUsed()
}
// Commit rejected verification attempts while rolling back infrastructure
// failures. Sendico's Consume records misses in the enclosing transaction.
_, err = s.tokens.Consume(tx, subject, scope.Purpose, r.ID.Hex(), code)
if err != nil {
if isRejection(err) {
return verificationOutcome{rejected: err}, nil
}
return nil, err
}
ttl := min(proofTTL, time.Until(r.ExpiresAt))
if ttl <= 0 {
return nil, verification.ErorrTokenExpired()
}
token, err := s.tokens.CreateWithResult(tx, verification.NewLinkRequest(subject, vmodel.PurposeFactorProof, r.ID.Hex()).WithContextRef(scopeKey(scope)).WithTTL(ttl).WithIdempotencyKey(s.key("proof", r.ID.Hex())))
if err != nil {
return nil, err
}
now := time.Now().UTC()
r.VerifiedAt = &now
if err = s.store.UpdateChallenge(tx, r); err != nil {
return nil, err
}
return verificationOutcome{proof: model.Proof{Token: token.VerificationToken, ExpiresAt: token.ExpiresAt}}, nil
})
if err != nil {
return model.Proof{}, err
}
outcome, ok := result.(verificationOutcome)
if !ok {
return model.Proof{}, merrors.Internal("invalid second-factor verification result")
}
if outcome.rejected != nil {
return model.Proof{}, outcome.rejected
}
s.logger.Debug("Second-factor proof prepared", zap.String("challenge", id), zap.String("purpose", string(scope.Purpose)))
return outcome.proof, nil
}
func (s *Service) Consume(ctx context.Context, subject bson.ObjectID, scope model.Scope, proof model.Proof, action func(context.Context) error) error {
if !validScope(scope) {
return merrors.InvalidArgument("unsupported second-factor operation scope")
}
return s.consume(ctx, subject, scope, proof, action)
}
func (s *Service) consume(ctx context.Context, subject bson.ObjectID, scope model.Scope, proof model.Proof, action func(context.Context) error) error {
if action == nil || len(proof.Token) != 43 {
return merrors.InvalidArgument("proof and transactional action required")
}
_, err := s.tx.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
token, err := s.tokens.Consume(tx, subject, vmodel.PurposeFactorProof, scopeKey(scope), proof.Token)
if err != nil {
return nil, err
}
r, err := s.record(tx, token.Target, subject, scope)
if err != nil {
return nil, err
}
if r.VerifiedAt == nil {
return nil, verification.ErorrTokenNotFound()
}
// Touch the profile in this transaction so a concurrent preference update
// conflicts and cannot let an old-factor proof authorize a later action.
_, p, _, err := s.profile(tx, subject)
if err != nil {
return nil, err
}
p.Fence = rand.Text()
if err = s.saveProfile(tx, p); err != nil {
return nil, err
}
return nil, action(tx)
})
if err == nil {
s.logger.Debug("Second-factor proof consumption prepared", zap.String("purpose", string(scope.Purpose)))
}
return err
}
// Complete atomically verifies the code, consumes its proof and commits the
// owning operation. A failed operation leaves the code retryable; a rejected
// code still commits its attempt counter.
func (s *Service) Complete(ctx context.Context, subject bson.ObjectID, scope model.Scope, id, code string, action func(context.Context) error) error {
if !validScope(scope) {
return merrors.InvalidArgument("unsupported second-factor operation scope")
}
return s.complete(ctx, subject, scope, id, code, action)
}
func (s *Service) complete(ctx context.Context, subject bson.ObjectID, scope model.Scope, id, code string, action func(context.Context) error) error {
result, err := s.tx.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
proof, err := s.verify(tx, subject, scope, id, code)
if err != nil {
if isRejection(err) {
return verificationOutcome{rejected: err}, nil
}
return nil, err
}
if err = s.consume(tx, subject, scope, proof, action); err != nil {
return nil, err
}
return verificationOutcome{}, nil
})
if err != nil {
return err
}
outcome, ok := result.(verificationOutcome)
if !ok {
return merrors.Internal("invalid second-factor completion result")
}
if outcome.rejected == nil {
s.logger.Info("Second-factor operation completed", zap.String("challenge", id), zap.String("purpose", string(scope.Purpose)))
} else {
s.logger.Info("Second-factor code rejected", zap.String("challenge", id), zap.String("purpose", string(scope.Purpose)))
}
return outcome.rejected
}