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,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
|
||||
}
|
||||
Reference in new issue
Block a user