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,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}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"context"
|
||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
||||
model "git.sendico.io/tech/sxchnge/product/twofactor/model"
|
||||
vmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging/mask"
|
||||
)
|
||||
|
||||
type Email struct{ Sender notifications.Sender }
|
||||
|
||||
func (Email) Channel() model.Channel { return model.Email }
|
||||
func (Email) Normalize(address string) (string, error) { return customer.NormalizeEmail(address) }
|
||||
func (Email) Mask(address string) string { return mask.Email(address) }
|
||||
func (d Email) Send(ctx context.Context, m model.Delivery) error {
|
||||
template := "twofactor.code"
|
||||
if m.Scope.Purpose == vmodel.PurposeLogin {
|
||||
template = "auth.login"
|
||||
}
|
||||
return d.Sender.Send(ctx, notifications.Message{Key: m.Key, UserID: m.Subject.Hex(), Email: m.Factor.Address, Locale: m.Locale, Template: template, Parameters: map[string]string{"code": m.Code}, ExpiresAt: m.ExpiresAt})
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// Package factorhttp exposes authenticated factor settings. Operation scope and
|
||||
// account ownership are resolved on the server; raw proofs never cross HTTP.
|
||||
package factorhttp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
|
||||
sresponse "git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
"git.sendico.io/tech/sxchnge/product/twofactor"
|
||||
model "git.sendico.io/tech/sxchnge/product/twofactor/model"
|
||||
verification "git.sendico.io/tech/sxchnge/product/verification/store"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
|
||||
mservice "git.sendico.io/tech/sxchnge/shared/service"
|
||||
)
|
||||
|
||||
type Registrar interface {
|
||||
AccountHandler(mservice.Type, string, api.HTTPMethod, sresponse.AccountHandlerFunc)
|
||||
}
|
||||
type router struct {
|
||||
service *twofactor.Service
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
func Install(reg Registrar, service *twofactor.Service, logger mlogger.Logger) {
|
||||
h := &router{service: service, logger: logger.Named("twofactor_http")}
|
||||
reg.AccountHandler(mservice.Accounts, "/two-factor", api.Get, h.settings)
|
||||
reg.AccountHandler(mservice.Accounts, "/two-factor/enroll", api.Post, h.enroll)
|
||||
reg.AccountHandler(mservice.Accounts, "/two-factor/enroll/verify", api.Post, h.confirmEnrollment)
|
||||
reg.AccountHandler(mservice.Accounts, "/two-factor/preference", api.Post, h.preference)
|
||||
reg.AccountHandler(mservice.Accounts, "/two-factor/preference/verify", api.Post, h.confirmPreference)
|
||||
reg.AccountHandler(mservice.Accounts, "/two-factor/resend", api.Post, h.resend)
|
||||
}
|
||||
func decode(r *http.Request, dst any) error {
|
||||
d := json.NewDecoder(io.LimitReader(r.Body, 8193))
|
||||
d.DisallowUnknownFields()
|
||||
if err := d.Decode(dst); err != nil {
|
||||
return merrors.InvalidArgument("invalid request")
|
||||
}
|
||||
var extra any
|
||||
if err := d.Decode(&extra); err != io.EOF {
|
||||
return merrors.InvalidArgument("invalid request")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func requestIP(r *http.Request) string {
|
||||
if ip := ipguard.ClientIP(r); ip != nil {
|
||||
return ip.String()
|
||||
}
|
||||
return "unresolved"
|
||||
}
|
||||
func (h *router) result(data any, err error) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if err == nil {
|
||||
response.Accepted(h.logger, data).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
var handler http.HandlerFunc
|
||||
if throttle, ok := verification.Throttle(err); ok {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(max(1, int(math.Ceil(throttle.RetryAfter.Seconds())))))
|
||||
handler = response.TooManyRequests(h.logger, mservice.Accounts, "Wait before requesting another code")
|
||||
} else {
|
||||
switch {
|
||||
case errors.Is(err, verification.ErrTokenExpired):
|
||||
handler = response.BadRequest(h.logger, mservice.Accounts, "expired", "Verification expired; restart the operation")
|
||||
case errors.Is(err, verification.ErrTokenAttemptsExceeded):
|
||||
handler = response.BadRequest(h.logger, mservice.Accounts, "locked", "Too many attempts; restart the operation")
|
||||
case errors.Is(err, verification.ErrTokenNotFound), errors.Is(err, verification.ErrTokenAlreadyUsed), errors.Is(err, merrors.ErrNoData):
|
||||
handler = response.BadRequest(h.logger, mservice.Accounts, "invalid", "Verification is invalid or already used")
|
||||
case errors.Is(err, merrors.ErrInvalidArg):
|
||||
handler = response.BadRequest(h.logger, mservice.Accounts, "invalid_request", "Check the request fields")
|
||||
case errors.Is(err, merrors.ErrUnauthorized):
|
||||
handler = response.Unauthorized(h.logger, mservice.Accounts, "Verification settings changed or account is inactive; restart the operation")
|
||||
case errors.Is(err, merrors.ErrDataConflict):
|
||||
handler = response.DataConflict(h.logger, mservice.Accounts, "Factor or request already exists")
|
||||
case errors.Is(err, merrors.ErrNotImplemented):
|
||||
handler = response.NotImplemented(h.logger, mservice.Accounts, "Second-factor channel is unavailable")
|
||||
default:
|
||||
h.logger.Warn("Second-factor settings dependency failed")
|
||||
handler = response.Error(h.logger, mservice.Accounts, http.StatusServiceUnavailable, "unavailable", "Unable to complete verification; retry")
|
||||
}
|
||||
}
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
type challengeDTO struct {
|
||||
ID string `json:"challengeId"`
|
||||
Channel model.Channel `json:"channel"`
|
||||
Target string `json:"target"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
ResendAt time.Time `json:"resendAt"`
|
||||
}
|
||||
|
||||
func mapChallenge(c model.Challenge) challengeDTO {
|
||||
return challengeDTO{c.ID, c.Channel, c.Target, c.ExpiresAt, c.ResendAt}
|
||||
}
|
||||
|
||||
type choiceDTO struct {
|
||||
ID string `json:"id"`
|
||||
Channel model.Channel `json:"channel"`
|
||||
Target string `json:"target"`
|
||||
}
|
||||
type settingsDTO struct {
|
||||
Required bool `json:"required"`
|
||||
Preferred string `json:"preferred"`
|
||||
Factors []choiceDTO `json:"factors"`
|
||||
}
|
||||
|
||||
func mapSettings(s model.Settings) settingsDTO {
|
||||
result := settingsDTO{Required: s.Required, Preferred: s.Preferred, Factors: []choiceDTO{}}
|
||||
for _, f := range s.Factors {
|
||||
result.Factors = append(result.Factors, choiceDTO{f.ID, f.Channel, f.Target})
|
||||
}
|
||||
return result
|
||||
}
|
||||
func (h *router) settings(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
s, err := h.service.Settings(r.Context(), a.ID)
|
||||
return h.result(mapSettings(s), err)
|
||||
}
|
||||
func (h *router) enroll(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
var req struct {
|
||||
Channel model.Channel `json:"channel"`
|
||||
Address string `json:"address"`
|
||||
Key string `json:"idempotencyKey"`
|
||||
}
|
||||
if err := decode(r, &req); err != nil {
|
||||
return h.result(nil, err)
|
||||
}
|
||||
c, err := h.service.Enroll(r.Context(), a.ID, req.Channel, req.Address, req.Key, requestIP(r))
|
||||
return h.result(mapChallenge(c), err)
|
||||
}
|
||||
func (h *router) confirmEnrollment(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
var req struct {
|
||||
ID string `json:"challengeId"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := decode(r, &req); err != nil {
|
||||
return h.result(nil, err)
|
||||
}
|
||||
s, err := h.service.ConfirmEnrollment(r.Context(), a.ID, req.ID, req.Code)
|
||||
return h.result(mapSettings(s), err)
|
||||
}
|
||||
func (h *router) preference(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
var req struct {
|
||||
ID string `json:"factorId"`
|
||||
Key string `json:"idempotencyKey"`
|
||||
}
|
||||
if err := decode(r, &req); err != nil {
|
||||
return h.result(nil, err)
|
||||
}
|
||||
c, err := h.service.BeginPreference(r.Context(), a.ID, req.ID, req.Key, requestIP(r))
|
||||
return h.result(mapChallenge(c), err)
|
||||
}
|
||||
func (h *router) confirmPreference(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
var req struct {
|
||||
Factor string `json:"factorId"`
|
||||
ID string `json:"challengeId"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := decode(r, &req); err != nil {
|
||||
return h.result(nil, err)
|
||||
}
|
||||
s, err := h.service.ConfirmPreference(r.Context(), a.ID, req.Factor, req.ID, req.Code)
|
||||
return h.result(mapSettings(s), err)
|
||||
}
|
||||
func (h *router) resend(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc {
|
||||
var req struct {
|
||||
ID string `json:"challengeId"`
|
||||
Key string `json:"idempotencyKey"`
|
||||
}
|
||||
if err := decode(r, &req); err != nil {
|
||||
return h.result(nil, err)
|
||||
}
|
||||
c, err := h.service.ResendSettings(r.Context(), a.ID, req.ID, req.Key, requestIP(r))
|
||||
return h.result(mapChallenge(c), err)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package factorhttp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
sresponse "git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
api "git.sendico.io/tech/sxchnge/shared/http"
|
||||
mservice "git.sendico.io/tech/sxchnge/shared/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type accountRoutes map[string]sresponse.AccountHandlerFunc
|
||||
|
||||
func (r accountRoutes) AccountHandler(_ mservice.Type, path string, _ api.HTTPMethod, h sresponse.AccountHandlerFunc) {
|
||||
r[path] = h
|
||||
}
|
||||
|
||||
func TestSettingsTransportRejectsCallerOwnedScopeAndDestinationOverrides(t *testing.T) {
|
||||
routes := accountRoutes{}
|
||||
Install(routes, nil, zap.NewNop())
|
||||
// Every route is registered through the authenticated-account contract.
|
||||
require.Len(t, routes, 6)
|
||||
for _, tc := range []struct{ path, body string }{
|
||||
{"/two-factor/enroll", `{"channel":"email","address":"a@example.com","subject":"other-account"}`},
|
||||
{"/two-factor/enroll/verify", `{"challengeId":"id","code":"123456","purpose":"login"}`},
|
||||
{"/two-factor/preference", `{"factorId":"id","address":"arbitrary@example.com"}`},
|
||||
{"/two-factor/preference/verify", `{"factorId":"id","code":"123456","verified":true}`},
|
||||
{"/two-factor/resend", `{"challengeId":"id","target":"arbitrary@example.com"}`},
|
||||
{"/two-factor/enroll", `{"channel":"email"} {"channel":"sms"}`},
|
||||
} {
|
||||
t.Run(tc.path+tc.body, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, tc.path, strings.NewReader(tc.body))
|
||||
w := httptest.NewRecorder()
|
||||
routes[tc.path](req, &customer.Account{}, nil).ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String())
|
||||
require.Equal(t, "no-store", w.Header().Get("Cache-Control"))
|
||||
require.NotContains(t, w.Body.String(), "arbitrary@example.com")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
verification "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
storable "git.sendico.io/tech/sxchnge/shared/database/record"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Channel string
|
||||
|
||||
const Email Channel = "email"
|
||||
|
||||
// Scope is supplied by the owning operation, never accepted as an authorization
|
||||
// decision from a caller's HTTP body. ContextRef identifies the exact operation.
|
||||
type Scope struct {
|
||||
Purpose verification.VerificationPurpose `bson:"purpose"`
|
||||
ContextRef string `bson:"contextRef"`
|
||||
}
|
||||
type Request struct {
|
||||
Subject bson.ObjectID
|
||||
Scope Scope
|
||||
Deadline time.Time
|
||||
IdempotencyKey string
|
||||
IP string
|
||||
}
|
||||
type Factor struct {
|
||||
ID string `bson:"id"`
|
||||
Channel Channel `bson:"channel"`
|
||||
Address string `bson:"address"`
|
||||
Verified bool `bson:"verified"`
|
||||
}
|
||||
type Profile struct {
|
||||
storable.Base `bson:",inline"`
|
||||
Preferred string `bson:"preferred"`
|
||||
Factors []Factor `bson:"factors"`
|
||||
Revision string `bson:"revision"`
|
||||
Fence string `bson:"fence"`
|
||||
}
|
||||
|
||||
func (*Profile) Collection() string { return "two_factor_profiles" }
|
||||
|
||||
type Record struct {
|
||||
storable.Base `bson:",inline"`
|
||||
Subject bson.ObjectID `bson:"subject"`
|
||||
Scope Scope `bson:"scope"`
|
||||
Factor Factor `bson:"factor"`
|
||||
ProfileVersion string `bson:"profileVersion"`
|
||||
Locale string `bson:"locale"`
|
||||
ExpiresAt time.Time `bson:"expiresAt"`
|
||||
VerifiedAt *time.Time `bson:"verifiedAt,omitempty"`
|
||||
}
|
||||
|
||||
func (*Record) Collection() string { return "two_factor_challenges" }
|
||||
|
||||
type Challenge struct {
|
||||
ID string
|
||||
Channel Channel
|
||||
Target string
|
||||
ExpiresAt, ResendAt time.Time
|
||||
}
|
||||
type Proof struct {
|
||||
Token string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
type Delivery struct {
|
||||
Key string
|
||||
Subject bson.ObjectID
|
||||
Scope Scope
|
||||
Factor Factor
|
||||
Locale, Code string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
type Choice struct {
|
||||
ID string
|
||||
Channel Channel
|
||||
Target string
|
||||
}
|
||||
type Settings struct {
|
||||
Required bool
|
||||
Preferred string
|
||||
Factors []Choice
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package twofactor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"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"
|
||||
"time"
|
||||
)
|
||||
|
||||
func isRejection(err error) bool {
|
||||
return errors.Is(err, verification.ErrTokenNotFound) || errors.Is(err, verification.ErrTokenAlreadyUsed) || errors.Is(err, verification.ErrTokenExpired) || errors.Is(err, verification.ErrTokenAttemptsExceeded)
|
||||
}
|
||||
func (s *Service) saveProfile(ctx context.Context, p *model.Profile) error {
|
||||
_, err := s.store.Profile(ctx, p.ID)
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return s.store.CreateProfile(ctx, p)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.store.UpdateProfile(ctx, p)
|
||||
}
|
||||
func (s *Service) Settings(ctx context.Context, subject bson.ObjectID) (model.Settings, error) {
|
||||
_, p, _, err := s.profile(ctx, subject)
|
||||
if err != nil {
|
||||
return model.Settings{}, err
|
||||
}
|
||||
result := model.Settings{Required: true, Preferred: p.Preferred}
|
||||
for _, f := range p.Factors {
|
||||
if f.Verified {
|
||||
if driver := s.drivers[f.Channel]; driver != nil {
|
||||
result.Factors = append(result.Factors, model.Choice{ID: f.ID, Channel: f.Channel, Target: driver.Mask(f.Address)})
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func (s *Service) Enroll(ctx context.Context, subject bson.ObjectID, channel model.Channel, address, key, ip string) (model.Challenge, error) {
|
||||
driver := s.drivers[channel]
|
||||
if driver == nil {
|
||||
return model.Challenge{}, merrors.NotImplemented("second-factor channel is not supported")
|
||||
}
|
||||
normalized, err := driver.Normalize(address)
|
||||
if err != nil {
|
||||
return model.Challenge{}, err
|
||||
}
|
||||
_, p, _, err := s.profile(ctx, subject)
|
||||
if err != nil {
|
||||
return model.Challenge{}, err
|
||||
}
|
||||
if len(p.Factors) >= 5 {
|
||||
return model.Challenge{}, merrors.InvalidArgument("maximum enrolled factors reached")
|
||||
}
|
||||
for _, f := range p.Factors {
|
||||
if f.Channel == channel && f.Address == normalized {
|
||||
return model.Challenge{}, merrors.DataConflict("factor already enrolled")
|
||||
}
|
||||
}
|
||||
f := model.Factor{ID: s.key("factor", subject.Hex(), string(channel), normalized)[:24], Channel: channel, Address: normalized}
|
||||
scope := model.Scope{Purpose: vmodel.PurposeFactorEnrollment, ContextRef: "enroll:" + f.ID}
|
||||
return s.begin(ctx, model.Request{Subject: subject, Scope: scope, IdempotencyKey: key, IP: ip}, &f)
|
||||
}
|
||||
func (s *Service) ConfirmEnrollment(ctx context.Context, subject bson.ObjectID, id, code string) (model.Settings, error) {
|
||||
cid, err := bson.ObjectIDFromHex(id)
|
||||
if err != nil {
|
||||
return model.Settings{}, verification.ErorrTokenNotFound()
|
||||
}
|
||||
r, err := s.store.Challenge(ctx, cid)
|
||||
if err != nil {
|
||||
return model.Settings{}, err
|
||||
}
|
||||
if r.Subject != subject || r.Scope.Purpose != vmodel.PurposeFactorEnrollment {
|
||||
return model.Settings{}, verification.ErorrTokenNotFound()
|
||||
}
|
||||
err = s.complete(ctx, subject, r.Scope, id, code, func(tx context.Context) error {
|
||||
_, p, _, err := s.profile(tx, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(p.Factors) >= 5 {
|
||||
return merrors.InvalidArgument("maximum enrolled factors reached")
|
||||
}
|
||||
for _, f := range p.Factors {
|
||||
if f.ID == r.Factor.ID {
|
||||
return merrors.DataConflict("factor already enrolled")
|
||||
}
|
||||
}
|
||||
factor := r.Factor
|
||||
factor.Verified = true
|
||||
p.Factors = append(p.Factors, factor)
|
||||
p.Revision = rand.Text()
|
||||
return s.saveProfile(tx, p)
|
||||
})
|
||||
if err != nil {
|
||||
return model.Settings{}, err
|
||||
}
|
||||
return s.Settings(ctx, subject)
|
||||
}
|
||||
func (s *Service) BeginPreference(ctx context.Context, subject bson.ObjectID, factorID, key, ip string) (model.Challenge, error) {
|
||||
_, p, _, err := s.profile(ctx, subject)
|
||||
if err != nil {
|
||||
return model.Challenge{}, err
|
||||
}
|
||||
if !s.available(p, factorID) {
|
||||
return model.Challenge{}, merrors.InvalidArgument("verified supported factor required")
|
||||
}
|
||||
return s.Begin(ctx, model.Request{Subject: subject, Scope: preferenceScope(factorID), IdempotencyKey: key, IP: ip, Deadline: time.Now().Add(TTL)})
|
||||
}
|
||||
func (s *Service) ConfirmPreference(ctx context.Context, subject bson.ObjectID, factorID, id, code string) (model.Settings, error) {
|
||||
scope := preferenceScope(factorID)
|
||||
err := s.Complete(ctx, subject, scope, id, code, func(tx context.Context) error {
|
||||
_, p, _, err := s.profile(tx, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !s.available(p, factorID) {
|
||||
return merrors.InvalidArgument("verified supported factor required")
|
||||
}
|
||||
p.Preferred = factorID
|
||||
p.Revision = rand.Text()
|
||||
return s.saveProfile(tx, p)
|
||||
})
|
||||
if err != nil {
|
||||
return model.Settings{}, err
|
||||
}
|
||||
return s.Settings(ctx, subject)
|
||||
}
|
||||
func (s *Service) available(p *model.Profile, id string) bool {
|
||||
for _, f := range p.Factors {
|
||||
if f.ID == id && f.Verified && s.drivers[f.Channel] != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ResendSettings cannot be used to send or authorize a pending login challenge.
|
||||
func (s *Service) ResendSettings(ctx context.Context, subject bson.ObjectID, id, key, ip string) (model.Challenge, error) {
|
||||
cid, err := bson.ObjectIDFromHex(id)
|
||||
if err != nil {
|
||||
return model.Challenge{}, verification.ErorrTokenNotFound()
|
||||
}
|
||||
r, err := s.store.Challenge(ctx, cid)
|
||||
if err != nil {
|
||||
return model.Challenge{}, err
|
||||
}
|
||||
if r.Subject != subject || (r.Scope.Purpose != vmodel.PurposeFactorEnrollment && r.Scope.Purpose != vmodel.PurposeFactorPreference) {
|
||||
return model.Challenge{}, verification.ErorrTokenNotFound()
|
||||
}
|
||||
return s.Resend(ctx, model.Request{Subject: subject, Scope: r.Scope, IdempotencyKey: key, IP: ip}, id)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// Package twofactor owns second-factor policy, challenges and one-use proofs.
|
||||
// It deliberately has no dependency on auth HTTP, JWTs or session creation.
|
||||
package twofactor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
account "git.sendico.io/tech/sxchnge/product/customers/store"
|
||||
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"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/transaction"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
const TTL = 10 * time.Minute
|
||||
const Cooldown = time.Minute
|
||||
const proofTTL = 2 * time.Minute
|
||||
const primary = "primary-email"
|
||||
|
||||
// API is consumed by auth and other operation owners. Complete joins code
|
||||
// verification, proof consumption and the owner's local database action.
|
||||
type API interface {
|
||||
Begin(context.Context, model.Request) (model.Challenge, error)
|
||||
Resend(context.Context, model.Request, string) (model.Challenge, error)
|
||||
Complete(context.Context, bson.ObjectID, model.Scope, string, string, func(context.Context) error) error
|
||||
}
|
||||
type Store interface {
|
||||
Profile(context.Context, bson.ObjectID) (*model.Profile, error)
|
||||
CreateProfile(context.Context, *model.Profile) error
|
||||
UpdateProfile(context.Context, *model.Profile) error
|
||||
Challenge(context.Context, bson.ObjectID) (*model.Record, error)
|
||||
CreateChallenge(context.Context, *model.Record) error
|
||||
UpdateChallenge(context.Context, *model.Record) error
|
||||
}
|
||||
type Driver interface {
|
||||
Channel() model.Channel
|
||||
Normalize(string) (string, error)
|
||||
Mask(string) string
|
||||
Send(context.Context, model.Delivery) error
|
||||
}
|
||||
type Service struct {
|
||||
accounts account.DB
|
||||
tokens verification.DB
|
||||
store Store
|
||||
tx transaction.Factory
|
||||
secret []byte
|
||||
drivers map[model.Channel]Driver
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
func New(accounts account.DB, tokens verification.DB, store Store, tx transaction.Factory, secret []byte, logger mlogger.Logger, drivers ...Driver) (*Service, error) {
|
||||
if accounts == nil || tokens == nil || store == nil || tx == nil || len(secret) < 32 || logger == nil {
|
||||
return nil, merrors.InvalidArgument("2FA dependencies and private key material are required")
|
||||
}
|
||||
s := &Service{accounts: accounts, tokens: tokens, store: store, tx: tx, secret: append([]byte(nil), secret...), drivers: map[model.Channel]Driver{}, logger: logger.Named("twofactor")}
|
||||
for _, d := range drivers {
|
||||
if d == nil || s.drivers[d.Channel()] != nil {
|
||||
return nil, merrors.InvalidArgument("unique second-factor channel drivers required")
|
||||
}
|
||||
s.drivers[d.Channel()] = d
|
||||
}
|
||||
if s.drivers[model.Email] == nil {
|
||||
return nil, merrors.InvalidArgument("verified account email channel is required")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
func (s *Service) key(parts ...string) string {
|
||||
h := hmac.New(sha256.New, s.secret)
|
||||
for _, p := range parts {
|
||||
h.Write([]byte(p))
|
||||
h.Write([]byte{0})
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
func validScope(scope model.Scope) bool {
|
||||
if strings.TrimSpace(scope.ContextRef) == "" || len(scope.ContextRef) > 256 || strings.ContainsRune(scope.ContextRef, 0) {
|
||||
return false
|
||||
}
|
||||
switch scope.Purpose {
|
||||
case vmodel.PurposeLogin, vmodel.PurposePayout, vmodel.PurposeFactorPreference:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
func scopeKey(scope model.Scope) string { return string(scope.Purpose) + ":" + scope.ContextRef }
|
||||
func (s *Service) profile(ctx context.Context, id bson.ObjectID) (*customer.Account, *model.Profile, string, error) {
|
||||
a := &customer.Account{}
|
||||
if err := s.accounts.Get(ctx, id, a); err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
if !a.IsActive() || a.IsArchived() {
|
||||
return nil, nil, "", merrors.Unauthorized("active verified account required")
|
||||
}
|
||||
p, err := s.store.Profile(ctx, id)
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
p = &model.Profile{Preferred: primary, Revision: "initial"}
|
||||
p.ID = id
|
||||
} else if err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
// The primary address is authorized by account activation, never by request data.
|
||||
factors := []model.Factor{{ID: primary, Channel: model.Email, Address: a.Login, Verified: true}}
|
||||
for _, f := range p.Factors {
|
||||
if f.ID != primary {
|
||||
factors = append(factors, f)
|
||||
}
|
||||
}
|
||||
p.Factors = factors
|
||||
version := s.key("profile", id.Hex(), a.Login, p.Revision)
|
||||
return a, p, version, nil
|
||||
}
|
||||
func (s *Service) preferred(p *model.Profile) (model.Factor, error) {
|
||||
for _, f := range p.Factors {
|
||||
if f.ID == p.Preferred && f.Verified && s.drivers[f.Channel] != nil {
|
||||
return f, nil
|
||||
}
|
||||
}
|
||||
return model.Factor{}, merrors.Unauthorized("configured second factor is unavailable")
|
||||
}
|
||||
func (s *Service) record(ctx context.Context, idText string, subject bson.ObjectID, scope model.Scope) (*model.Record, error) {
|
||||
id, err := bson.ObjectIDFromHex(idText)
|
||||
if err != nil {
|
||||
return nil, verification.ErorrTokenNotFound()
|
||||
}
|
||||
r, err := s.store.Challenge(ctx, id)
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return nil, verification.ErorrTokenNotFound()
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.Subject != subject || r.Scope != scope {
|
||||
return nil, verification.ErorrTokenNotFound()
|
||||
}
|
||||
if !r.ExpiresAt.After(time.Now()) {
|
||||
return nil, verification.ErorrTokenExpired()
|
||||
}
|
||||
_, _, version, err := s.profile(ctx, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if version != r.ProfileVersion {
|
||||
return nil, merrors.Unauthorized("second-factor settings changed; restart verification")
|
||||
}
|
||||
if s.drivers[r.Factor.Channel] == nil {
|
||||
return nil, merrors.NotImplemented("second-factor channel unavailable")
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package twofactor_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
authstorage "git.sendico.io/tech/sxchnge/product/auth/storage"
|
||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
"git.sendico.io/tech/sxchnge/product/twofactor"
|
||||
model "git.sendico.io/tech/sxchnge/product/twofactor/model"
|
||||
factorstore "git.sendico.io/tech/sxchnge/product/twofactor/store"
|
||||
vmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
verification "git.sendico.io/tech/sxchnge/product/verification/store"
|
||||
db "git.sendico.io/tech/sxchnge/shared/database"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type capturedDriver struct {
|
||||
deliveries []model.Delivery
|
||||
fail bool
|
||||
}
|
||||
|
||||
func (*capturedDriver) Channel() model.Channel { return model.Email }
|
||||
func (*capturedDriver) Normalize(s string) (string, error) { return customer.NormalizeEmail(s) }
|
||||
func (*capturedDriver) Mask(string) string { return "masked" }
|
||||
func (d *capturedDriver) Send(_ context.Context, m model.Delivery) error {
|
||||
d.deliveries = append(d.deliveries, m)
|
||||
if d.fail {
|
||||
return merrors.ServiceUnavailable("queue unavailable")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func TestServiceTransactionsAndFactors(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
container, err := mongodb.Run(ctx, "mongo:8", mongodb.WithReplicaSet("rs"))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
|
||||
uri, err := container.ConnectionString(ctx)
|
||||
require.NoError(t, err)
|
||||
u, err := url.Parse(uri)
|
||||
require.NoError(t, err)
|
||||
q := u.Query()
|
||||
q.Set("directConnection", "true")
|
||||
u.RawQuery = q.Encode()
|
||||
factory, err := db.NewConnection(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: sharedmodel.SettingsT{"uri": u.String(), "database": "twofactor"}})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(factory.CloseConnection)
|
||||
stores, err := authstorage.OpenStores(zap.NewNop(), factory, nil)
|
||||
require.NoError(t, err)
|
||||
records, err := factorstore.Open(zap.NewNop(), factory)
|
||||
require.NoError(t, err)
|
||||
driver := &capturedDriver{}
|
||||
service, err := twofactor.New(stores.Accounts, stores.Verification, records, factory.TransactionFactory(), []byte(strings.Repeat("s", 32)), zap.NewNop(), driver)
|
||||
require.NoError(t, err)
|
||||
account := func() *customer.Account {
|
||||
a := &customer.Account{Status: customer.AccountActive}
|
||||
a.ID = bson.NewObjectID()
|
||||
a.Login = a.ID.Hex() + "@example.com"
|
||||
a.Locale = "ru"
|
||||
require.NoError(t, stores.Accounts.Create(ctx, a))
|
||||
return a
|
||||
}
|
||||
request := func(a *customer.Account) model.Request {
|
||||
return model.Request{Subject: a.ID, Scope: model.Scope{Purpose: vmodel.PurposeLogin, ContextRef: bson.NewObjectID().Hex()}, IdempotencyKey: "begin", IP: a.ID.Hex()}
|
||||
}
|
||||
begin := func(r model.Request) (model.Challenge, string) {
|
||||
c, err := service.Begin(ctx, r)
|
||||
require.NoError(t, err)
|
||||
return c, driver.deliveries[len(driver.deliveries)-1].Code
|
||||
}
|
||||
action := func(context.Context) error { return nil }
|
||||
t.Run("delivery retry and immutable deadline", func(t *testing.T) {
|
||||
a := account()
|
||||
r := request(a)
|
||||
r.Deadline = time.Now().Add(3 * time.Minute)
|
||||
driver.fail = true
|
||||
_, err := service.Begin(ctx, r)
|
||||
require.ErrorIs(t, err, merrors.ErrServiceUnavailable)
|
||||
first := driver.deliveries[len(driver.deliveries)-1]
|
||||
driver.fail = false
|
||||
c, code := begin(r)
|
||||
require.Equal(t, first.Code, code)
|
||||
require.Equal(t, first.Key, driver.deliveries[len(driver.deliveries)-1].Key)
|
||||
require.WithinDuration(t, first.ExpiresAt, c.ExpiresAt, time.Millisecond)
|
||||
require.False(t, c.ExpiresAt.After(r.Deadline))
|
||||
require.Equal(t, a.Login, first.Factor.Address)
|
||||
require.Equal(t, "ru", first.Locale)
|
||||
require.Equal(t, "masked", c.Target)
|
||||
r.IdempotencyKey = "resend"
|
||||
_, err = service.Resend(ctx, r, c.ID)
|
||||
_, throttled := verification.Throttle(err)
|
||||
require.True(t, throttled)
|
||||
_, err = service.ResendSettings(ctx, a.ID, c.ID, "settings", r.IP)
|
||||
require.ErrorIs(t, err, verification.ErrTokenNotFound)
|
||||
})
|
||||
t.Run("scope and one use proof", func(t *testing.T) {
|
||||
a := account()
|
||||
r := request(a)
|
||||
c, code := begin(r)
|
||||
_, err := service.Verify(ctx, account().ID, r.Scope, c.ID, code)
|
||||
require.ErrorIs(t, err, verification.ErrTokenNotFound)
|
||||
other := r.Scope
|
||||
other.ContextRef = "another-operation"
|
||||
_, err = service.Verify(ctx, a.ID, other, c.ID, code)
|
||||
require.ErrorIs(t, err, verification.ErrTokenNotFound)
|
||||
other = r.Scope
|
||||
other.Purpose = vmodel.PurposePayout
|
||||
_, err = service.Verify(ctx, a.ID, other, c.ID, code)
|
||||
require.ErrorIs(t, err, verification.ErrTokenNotFound)
|
||||
proof, err := service.Verify(ctx, a.ID, r.Scope, c.ID, code)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, proof.Token, 43)
|
||||
require.Error(t, service.Consume(ctx, a.ID, other, proof, action))
|
||||
require.ErrorIs(t, service.Consume(ctx, a.ID, r.Scope, proof, func(context.Context) error { return merrors.ServiceUnavailable("operation failed") }), merrors.ErrServiceUnavailable)
|
||||
require.NoError(t, service.Consume(ctx, a.ID, r.Scope, proof, action))
|
||||
require.Error(t, service.Consume(ctx, a.ID, r.Scope, proof, action))
|
||||
_, err = service.Verify(ctx, a.ID, r.Scope, c.ID, code)
|
||||
require.ErrorIs(t, err, verification.ErrTokenAlreadyUsed)
|
||||
})
|
||||
t.Run("atomic operation rollback and concurrent completion", func(t *testing.T) {
|
||||
a := account()
|
||||
r := request(a)
|
||||
c, code := begin(r)
|
||||
err := service.Complete(ctx, a.ID, r.Scope, c.ID, code, func(tx context.Context) error {
|
||||
updated := &customer.Account{}
|
||||
if err := stores.Accounts.Get(tx, a.ID, updated); err != nil {
|
||||
return err
|
||||
}
|
||||
updated.Name = "should rollback"
|
||||
if err := stores.Accounts.Update(tx, updated); err != nil {
|
||||
return err
|
||||
}
|
||||
return merrors.ServiceUnavailable("session write failed")
|
||||
})
|
||||
require.ErrorIs(t, err, merrors.ErrServiceUnavailable)
|
||||
saved := &customer.Account{}
|
||||
require.NoError(t, stores.Accounts.Get(ctx, a.ID, saved))
|
||||
require.Empty(t, saved.Name)
|
||||
id, err := bson.ObjectIDFromHex(c.ID)
|
||||
require.NoError(t, err)
|
||||
record, err := records.Challenge(ctx, id)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, record.VerifiedAt)
|
||||
var successes atomic.Int32
|
||||
var wg sync.WaitGroup
|
||||
for range 4 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if service.Complete(ctx, a.ID, r.Scope, c.ID, code, action) == nil {
|
||||
successes.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
require.EqualValues(t, 1, successes.Load())
|
||||
})
|
||||
t.Run("bad attempts are committed even with an outer operation", func(t *testing.T) {
|
||||
a := account()
|
||||
r := request(a)
|
||||
c, code := begin(r)
|
||||
wrong := "000000"
|
||||
if code == wrong {
|
||||
wrong = "111111"
|
||||
}
|
||||
for range 5 {
|
||||
require.Error(t, service.Complete(ctx, a.ID, r.Scope, c.ID, wrong, action))
|
||||
}
|
||||
require.ErrorIs(t, service.Complete(ctx, a.ID, r.Scope, c.ID, code, action), verification.ErrTokenAttemptsExceeded)
|
||||
})
|
||||
t.Run("expired and inactive challenges fail closed", func(t *testing.T) {
|
||||
a := account()
|
||||
r := request(a)
|
||||
c, code := begin(r)
|
||||
id, err := bson.ObjectIDFromHex(c.ID)
|
||||
require.NoError(t, err)
|
||||
record, err := records.Challenge(ctx, id)
|
||||
require.NoError(t, err)
|
||||
record.ExpiresAt = time.Now().Add(-time.Second)
|
||||
require.NoError(t, records.UpdateChallenge(ctx, record))
|
||||
_, err = service.Verify(ctx, a.ID, r.Scope, c.ID, code)
|
||||
require.ErrorIs(t, err, verification.ErrTokenExpired)
|
||||
a = account()
|
||||
r = request(a)
|
||||
c, code = begin(r)
|
||||
a.Status = customer.AccountBlocked
|
||||
require.NoError(t, stores.Accounts.Update(ctx, a))
|
||||
require.ErrorIs(t, service.Complete(ctx, a.ID, r.Scope, c.ID, code, action), merrors.ErrUnauthorized)
|
||||
})
|
||||
t.Run("verified preference controls delivery and invalidates old proofs", func(t *testing.T) {
|
||||
a := account()
|
||||
r := request(a)
|
||||
old, oldCode := begin(r)
|
||||
proof, err := service.Verify(ctx, a.ID, r.Scope, old.ID, oldCode)
|
||||
require.NoError(t, err)
|
||||
_, err = service.Enroll(ctx, a.ID, "sms", "+10000000000", "sms", r.IP)
|
||||
require.ErrorIs(t, err, merrors.ErrNotImplemented)
|
||||
enroll, err := service.Enroll(ctx, a.ID, model.Email, "SECOND@example.com", "enroll", r.IP)
|
||||
require.NoError(t, err)
|
||||
code := driver.deliveries[len(driver.deliveries)-1].Code
|
||||
require.Equal(t, "second@example.com", driver.deliveries[len(driver.deliveries)-1].Factor.Address)
|
||||
settings, err := service.Settings(ctx, a.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, settings.Factors, 1)
|
||||
settings, err = service.ConfirmEnrollment(ctx, a.ID, enroll.ID, code)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, settings.Factors, 2)
|
||||
require.Equal(t, "primary-email", settings.Preferred)
|
||||
require.ErrorIs(t, service.Consume(ctx, a.ID, r.Scope, proof, action), merrors.ErrUnauthorized)
|
||||
selected := settings.Factors[1].ID
|
||||
change, err := service.BeginPreference(ctx, a.ID, selected, "preference", r.IP)
|
||||
require.NoError(t, err)
|
||||
d := driver.deliveries[len(driver.deliveries)-1]
|
||||
require.Equal(t, a.Login, d.Factor.Address)
|
||||
_, err = service.ConfirmPreference(ctx, a.ID, "primary-email", change.ID, d.Code)
|
||||
require.ErrorIs(t, err, verification.ErrTokenNotFound)
|
||||
settings, err = service.ConfirmPreference(ctx, a.ID, selected, change.ID, d.Code)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, selected, settings.Preferred)
|
||||
next := request(a)
|
||||
_, _ = begin(next)
|
||||
require.Equal(t, "second@example.com", driver.deliveries[len(driver.deliveries)-1].Factor.Address)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
model "git.sendico.io/tech/sxchnge/product/twofactor/model"
|
||||
db "git.sendico.io/tech/sxchnge/shared/database"
|
||||
deps "git.sendico.io/tech/sxchnge/shared/database/dependencies"
|
||||
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/template"
|
||||
mlogger "git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
profiles *template.DBImp[*model.Profile]
|
||||
challenges *template.DBImp[*model.Record]
|
||||
}
|
||||
|
||||
func Open(logger mlogger.Logger, factory db.Factory) (*Store, error) {
|
||||
d, err := deps.ResolveDependencies(factory)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Store{profiles: template.Create[*model.Profile](logger, "two_factor_profiles", d.Database), challenges: template.Create[*model.Record](logger, "two_factor_challenges", d.Database)}
|
||||
retention := int32(86400)
|
||||
err = s.challenges.Repository.CreateIndex(&ri.Definition{Name: "two_factor_expiry", Keys: []ri.Key{{Field: "expiresAt", Sort: ri.Asc}}, TTL: &retention})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
func (s *Store) Profile(ctx context.Context, id bson.ObjectID) (*model.Profile, error) {
|
||||
p := &model.Profile{}
|
||||
err := s.profiles.Get(ctx, id, p)
|
||||
return p, err
|
||||
}
|
||||
func (s *Store) CreateProfile(ctx context.Context, p *model.Profile) error {
|
||||
return s.profiles.Create(ctx, p)
|
||||
}
|
||||
func (s *Store) UpdateProfile(ctx context.Context, p *model.Profile) error {
|
||||
return s.profiles.Update(ctx, p)
|
||||
}
|
||||
func (s *Store) Challenge(ctx context.Context, id bson.ObjectID) (*model.Record, error) {
|
||||
r := &model.Record{}
|
||||
err := s.challenges.Get(ctx, id, r)
|
||||
return r, err
|
||||
}
|
||||
func (s *Store) CreateChallenge(ctx context.Context, r *model.Record) error {
|
||||
return s.challenges.Create(ctx, r)
|
||||
}
|
||||
func (s *Store) UpdateChallenge(ctx context.Context, r *model.Record) error {
|
||||
return s.challenges.Update(ctx, r)
|
||||
}
|
||||
Reference in new issue
Block a user