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