154 lines
5.6 KiB
Go
154 lines
5.6 KiB
Go
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
|
|
}
|