Integrate email notifications into signup and login (#38)
This commit is contained in:
1 parent
326d58f705
commit
ad5a061164
55 files changed
+1859
-3464
No files matched your search
@@ -40,7 +40,8 @@ Multiple service workers can safely compete for jobs through atomic claims.
|
||||
|
||||
## Templates and parameters
|
||||
|
||||
See `templates.example.json`. Parameters use Go template map syntax such as
|
||||
See `templates.example.json`, including the `auth.activation` and `auth.login`
|
||||
English/Russian templates used by [product authentication](../../docs/auth-notifications.md). Parameters use Go template map syntax such as
|
||||
`{{.first_name}}`. Each revision declares the exact allowed parameter names and
|
||||
locale-specific content. Missing/extra parameters, unknown revisions/locales and
|
||||
invalid content are rejected before acceptance. Empty locale uses the default.
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
"id": "exchange.completed",
|
||||
"version": "1",
|
||||
"default_locale": "en",
|
||||
"parameters": ["first_name", "amount", "currency"],
|
||||
"parameters": [
|
||||
"first_name",
|
||||
"amount",
|
||||
"currency"
|
||||
],
|
||||
"locales": {
|
||||
"en": {
|
||||
"subject": "Your exchange is complete",
|
||||
@@ -11,5 +15,45 @@
|
||||
"text": "Hi {{.first_name}}, your exchange of {{.amount}} {{.currency}} is complete."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "auth.activation",
|
||||
"version": "1",
|
||||
"default_locale": "en",
|
||||
"parameters": [
|
||||
"url"
|
||||
],
|
||||
"locales": {
|
||||
"en": {
|
||||
"subject": "Verify your SXCHNGE email",
|
||||
"text": "Confirm your email: {{.url}}\nThis link expires in 24 hours. If you did not sign up, ignore this email.",
|
||||
"html": "<p>Confirm your email to finish signing up.</p><p><a href=\"{{.url}}\">Verify email</a></p><p>This link expires in 24 hours. If you did not sign up, ignore this email.</p>"
|
||||
},
|
||||
"ru": {
|
||||
"subject": "Подтвердите почту SXCHNGE",
|
||||
"text": "Подтвердите почту: {{.url}}\nСсылка действует 24 часа. Если вы не регистрировались, проигнорируйте письмо.",
|
||||
"html": "<p><a href=\"{{.url}}\">Подтвердить почту</a></p><p>Ссылка действует 24 часа. Если вы не регистрировались, проигнорируйте письмо.</p>"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "auth.login",
|
||||
"version": "1",
|
||||
"default_locale": "en",
|
||||
"parameters": [
|
||||
"code"
|
||||
],
|
||||
"locales": {
|
||||
"en": {
|
||||
"subject": "Your SXCHNGE login code",
|
||||
"text": "Your login code is {{.code}}. It expires in 10 minutes. Never share this code.",
|
||||
"html": "<p>Your login code is <strong>{{.code}}</strong>.</p><p>It expires in 10 minutes. Never share this code.</p>"
|
||||
},
|
||||
"ru": {
|
||||
"subject": "Код входа SXCHNGE",
|
||||
"text": "Ваш код входа: {{.code}}. Он действует 10 минут. Никому не сообщайте код.",
|
||||
"html": "<p>Ваш код входа: <strong>{{.code}}</strong>.</p><p>Он действует 10 минут. Никому не сообщайте код.</p>"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -24,8 +24,11 @@ organization, role, policy or seed; the runtime creates its indexes.
|
||||
The runtime mounts health, login, API login, refresh, rotation and logout routes.
|
||||
Customer/application-client records must exist for login. The customer service
|
||||
supports atomic account/activation creation and deletion with session revocation.
|
||||
Signup/profile HTTP wiring and verification delivery/completion remain follow-up
|
||||
work. The frontend still uses dummy services.
|
||||
Signup and email verification delivery/completion are available when
|
||||
`api.notifications` is configured. Flutter selects the backend using
|
||||
`SXCHNGE_API_URL` and `SXCHNGE_CLIENT_ID`. See
|
||||
[auth notification setup and contracts](../../docs/auth-notifications.md).
|
||||
Profile HTTP wiring remains follow-up work.
|
||||
|
||||
Sendico integration is not wired. Its organization context belongs only to the
|
||||
future public HTTP adapter. There is no unused public API snapshot in this module;
|
||||
|
||||
@@ -2,13 +2,15 @@ package api
|
||||
|
||||
import (
|
||||
mwa "git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
||||
verificationconfig "git.sendico.io/tech/sxchnge/product/verification/config"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Mw *mwa.Config `yaml:"middleware"`
|
||||
AuthStorage *mwa.AuthStorageConfig `yaml:"auth_storage"`
|
||||
Verification *verificationconfig.Config `yaml:"verification"`
|
||||
Notifications *notifications.Config `yaml:"notifications"`
|
||||
Mw *mwa.Config `yaml:"middleware"`
|
||||
AuthStorage *mwa.AuthStorageConfig `yaml:"auth_storage"`
|
||||
Verification *verificationconfig.Config `yaml:"verification"`
|
||||
}
|
||||
|
||||
func (c *Config) SharedConfig() *Config {
|
||||
|
||||
@@ -7,9 +7,12 @@ import (
|
||||
|
||||
mr "git.sendico.io/tech/sxchnge/product/app/metrics"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
||||
httprouter "git.sendico.io/tech/sxchnge/product/auth/http"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
authstorage "git.sendico.io/tech/sxchnge/product/auth/storage"
|
||||
accountservice "git.sendico.io/tech/sxchnge/product/customers"
|
||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
||||
"git.sendico.io/tech/sxchnge/product/sessions/browser"
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
@@ -26,11 +29,12 @@ import (
|
||||
)
|
||||
|
||||
type Middleware struct {
|
||||
logger mlogger.Logger
|
||||
router *chi.Mux
|
||||
apiEndpoint string
|
||||
metrics mr.Metrics
|
||||
epdispatcher *httprouter.Dispatcher
|
||||
notifications *notifications.Client
|
||||
logger mlogger.Logger
|
||||
router *chi.Mux
|
||||
apiEndpoint string
|
||||
metrics mr.Metrics
|
||||
epdispatcher *httprouter.Dispatcher
|
||||
}
|
||||
|
||||
func (mw *Middleware) Handler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc) {
|
||||
@@ -47,6 +51,9 @@ func (mw *Middleware) PendingAccountHandler(service mservice.Type, endpoint stri
|
||||
|
||||
func (mw *Middleware) Finish() {
|
||||
mw.metrics.Finish()
|
||||
if mw.notifications != nil {
|
||||
_ = mw.notifications.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (mw *Middleware) installMiddleware(config *middleware.Config, debug bool) {
|
||||
@@ -107,6 +114,7 @@ func CreateMiddleware(
|
||||
config *middleware.Config,
|
||||
authStorage *middleware.AuthStorageConfig,
|
||||
debug bool,
|
||||
notificationConfig ...*notifications.Config,
|
||||
) (*Middleware, error) {
|
||||
if config == nil {
|
||||
return nil, merrors.InvalidArgument("middleware configuration is required", "middleware")
|
||||
@@ -139,6 +147,23 @@ func CreateMiddleware(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var authFlow *flow.Service
|
||||
if len(notificationConfig) > 0 && notificationConfig[0] != nil {
|
||||
c := notificationConfig[0]
|
||||
customers, err := accountservice.NewAccountService(p.logger, db, &config.Password, authStorage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authFlow = &flow.Service{Accounts: stores.Accounts, Customers: customers, Tokens: stores.Verification, Transactions: db.TransactionFactory(), Attempts: stores.LoginAttempts, Secret: middleware.SignatureConf(config).PrivateKey, ActivationURL: c.ActivationURL}
|
||||
if err = authFlow.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.notifications, err = notifications.Open(*c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authFlow.Sender = p.notifications
|
||||
}
|
||||
p.epdispatcher = httprouter.NewDispatcher(
|
||||
p.logger,
|
||||
p.router,
|
||||
@@ -148,6 +173,7 @@ func CreateMiddleware(
|
||||
stores.LoginAttempts,
|
||||
config,
|
||||
loginRateLimits,
|
||||
authFlow,
|
||||
)
|
||||
return p, nil
|
||||
}
|
||||
@@ -83,6 +83,7 @@ func (a *APIImp) installRuntimeBasics(
|
||||
a.config.Mw,
|
||||
a.config.AuthStorage,
|
||||
debug,
|
||||
a.config.Notifications,
|
||||
); err != nil {
|
||||
a.logger.Error("Failed to create middleware", zap.Error(err))
|
||||
return err
|
||||
|
||||
@@ -2,7 +2,6 @@ package runtime_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -14,6 +13,7 @@ import (
|
||||
"git.sendico.io/tech/sxchnge/product/app/profile"
|
||||
sharedruntime "git.sendico.io/tech/sxchnge/product/app/runtime"
|
||||
"git.sendico.io/tech/sxchnge/shared/database"
|
||||
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
||||
@@ -48,6 +48,8 @@ func TestProfileStartsOnEmptyDatabase(t *testing.T) {
|
||||
t.Setenv("SXCHNGE_API_PROTOCOL", "http")
|
||||
t.Setenv("SXCHNGE_API_ENDPOINT", "/api/v1")
|
||||
t.Setenv("SXCHNGE_JWT_SECRET", "test-only-signing-key-at-least-32-bytes")
|
||||
t.Setenv("SXCHNGE_NOTIFICATIONS_TOKEN", "test-notification-token-at-least-32-bytes")
|
||||
example.API.Notifications.CAFile = "" // This startup test makes no notification RPCs.
|
||||
root, routes := chi.NewRouter(), chi.NewRouter()
|
||||
runtime, err := sharedruntime.CreateAPI(ctx, zap.NewNop(), &example.API, &example.API, factory, root, routes, false, profile.Runtime())
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
// Package flow owns signup and email challenge workflows.
|
||||
package flow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
accountservice "git.sendico.io/tech/sxchnge/product/customers"
|
||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
account "git.sendico.io/tech/sxchnge/product/customers/store"
|
||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
verification "git.sendico.io/tech/sxchnge/product/verification/store"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/transaction"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
ratelimits "git.sendico.io/tech/sxchnge/shared/ratelimit/store"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
const Cooldown = time.Minute
|
||||
const LoginTTL = 10 * time.Minute
|
||||
|
||||
type Service struct {
|
||||
Accounts account.DB
|
||||
Customers accountservice.AccountService
|
||||
Tokens verification.DB
|
||||
Transactions transaction.Factory
|
||||
Attempts ratelimits.Store
|
||||
Sender notifications.Sender
|
||||
Secret []byte
|
||||
ActivationURL string
|
||||
}
|
||||
|
||||
func (s *Service) Validate() error {
|
||||
u, err := url.Parse(s.ActivationURL)
|
||||
if err != nil || u.Host == "" || (u.Scheme != "https" && !(u.Scheme == "http" && (u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1"))) || u.User != nil || u.Fragment != "" {
|
||||
return merrors.InvalidArgument("activation URL must be an absolute HTTPS URL (HTTP allowed on localhost)")
|
||||
}
|
||||
if len(s.Secret) < 32 {
|
||||
return merrors.InvalidArgument("authentication signing secret must be at least 32 bytes")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func NormalizeEmail(email string) (string, error) {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
a, err := mail.ParseAddress(email)
|
||||
if err != nil || a.Address != email || len(email) > 254 {
|
||||
return "", merrors.InvalidArgument("valid email required")
|
||||
}
|
||||
return email, 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 (s *Service) Limit(ctx context.Context, ip, email string) error {
|
||||
now := time.Now().UTC()
|
||||
start := now.Truncate(time.Hour)
|
||||
for _, scope := range []struct {
|
||||
kind, value string
|
||||
limit int
|
||||
}{{"auth_email", email, 10}, {"auth_ip", ip, 30}} {
|
||||
count, err := s.Attempts.Increment(ctx, scope.kind, ratelimits.HashScope(scope.kind, scope.value), start, start.Add(time.Hour))
|
||||
if err != nil {
|
||||
return merrors.ServiceUnavailableWrap(err, "authentication rate limit unavailable")
|
||||
}
|
||||
if count > scope.limit {
|
||||
return verification.ErrorIssuanceRateLimited(scope.kind, start.Add(time.Hour).Sub(now))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *Service) Signup(ctx context.Context, a *customer.Account, ip, key string) (*customer.Account, error) {
|
||||
email, err := NormalizeEmail(a.Login)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.Login = email
|
||||
a.Name = strings.TrimSpace(a.Name)
|
||||
if len(a.Name) > 200 || len(a.Password) > 72 {
|
||||
return nil, merrors.InvalidArgument("name or password too long")
|
||||
}
|
||||
if err = s.Limit(ctx, ip, email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := s.Customers.CreateAccount(ctx, a)
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
existing, lookup := s.Accounts.GetByEmail(ctx, email)
|
||||
if lookup != nil {
|
||||
return nil, lookup
|
||||
}
|
||||
// A retry after a queue failure may resume only with the original password.
|
||||
if existing.Status != customer.AccountPendingVerification || !existing.MatchPassword(a.Password) {
|
||||
return nil, merrors.DataConflict("email already registered")
|
||||
}
|
||||
a = existing
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if raw != "" {
|
||||
u, _ := url.Parse(s.ActivationURL)
|
||||
q := u.Query()
|
||||
q.Set("token", raw)
|
||||
u.RawQuery = q.Encode()
|
||||
locale := "en"
|
||||
if strings.HasPrefix(a.Locale, "ru") {
|
||||
locale = "ru"
|
||||
}
|
||||
err = s.Sender.Send(ctx, notifications.Message{Key: s.key("signup", raw), UserID: a.ID.Hex(), Email: a.Login, Locale: locale, Template: "auth.activation", Parameters: map[string]string{"url": u.String()}, ExpiresAt: time.Now().UTC().Add(23 * time.Hour)})
|
||||
} else {
|
||||
_, err = s.Issue(ctx, a, verificationmodel.PurposeAccountActivation, "", key)
|
||||
}
|
||||
return a, err
|
||||
}
|
||||
func (s *Service) ResendActivation(ctx context.Context, email, ip, key string) error {
|
||||
email, err := NormalizeEmail(email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = s.Limit(ctx, ip, email); err != nil {
|
||||
return err
|
||||
}
|
||||
a, err := s.Accounts.GetByEmail(ctx, email)
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if a.Status != customer.AccountPendingVerification || a.IsArchived() {
|
||||
return nil
|
||||
}
|
||||
_, err = s.Issue(ctx, a, verificationmodel.PurposeAccountActivation, "", key)
|
||||
return err
|
||||
}
|
||||
func (s *Service) Issue(ctx context.Context, a *customer.Account, purpose verificationmodel.VerificationPurpose, scope, key string, deadlines ...time.Time) (time.Time, error) {
|
||||
if strings.TrimSpace(key) == "" || len(key) > 128 {
|
||||
return time.Time{}, merrors.InvalidArgument("idempotency key required (maximum 128 characters)")
|
||||
}
|
||||
secretKey := s.key(a.ID.Hex(), string(purpose), scope, key)
|
||||
req := verification.NewOTPRequest(a.ID, purpose, "").WithTTL(LoginTTL).WithMaxRetries(5)
|
||||
if purpose == verificationmodel.PurposeAccountActivation {
|
||||
req = verification.NewLinkRequest(a.ID, purpose, "").WithTTL(24 * time.Hour)
|
||||
}
|
||||
if len(deadlines) > 0 {
|
||||
remaining := time.Until(deadlines[0])
|
||||
if remaining <= 0 {
|
||||
return time.Time{}, verification.ErorrTokenExpired()
|
||||
}
|
||||
if remaining < req.Ttl {
|
||||
req.WithTTL(remaining)
|
||||
}
|
||||
}
|
||||
req.WithContextRef(scope).WithIdempotencyKey(secretKey).WithCooldown(Cooldown)
|
||||
now := time.Now().UTC()
|
||||
// Destination limits span every challenge/context, including password retries.
|
||||
req.WithIssuanceLimits(verification.NewIssuanceLimit("auth_delivery", a.Login, 5, time.Hour, now))
|
||||
result, err := s.Tokens.CreateWithResult(ctx, req)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
if !result.ExpiresAt.After(now) {
|
||||
return time.Time{}, verification.ErorrTokenExpired()
|
||||
}
|
||||
locale := "en"
|
||||
if strings.HasPrefix(strings.ToLower(a.Locale), "ru") {
|
||||
locale = "ru"
|
||||
}
|
||||
message := notifications.Message{Key: secretKey, UserID: a.ID.Hex(), Email: a.Login, Locale: locale, Template: "auth.login", ExpiresAt: result.ExpiresAt, Parameters: map[string]string{"code": result.VerificationToken}}
|
||||
if purpose == verificationmodel.PurposeAccountActivation {
|
||||
u, _ := url.Parse(s.ActivationURL)
|
||||
q := u.Query()
|
||||
q.Set("token", result.VerificationToken)
|
||||
u.RawQuery = q.Encode()
|
||||
message.Template = "auth.activation"
|
||||
message.Parameters = map[string]string{"url": u.String()}
|
||||
}
|
||||
// Replay recovers ambiguous RPC failures using the same rendered payload and expiry.
|
||||
if err = s.Sender.Send(ctx, message); err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return result.ExpiresAt, nil
|
||||
}
|
||||
func (s *Service) Activate(ctx context.Context, raw string) error {
|
||||
if len(raw) != 43 {
|
||||
return verification.ErorrTokenNotFound()
|
||||
}
|
||||
_, err := s.Transactions.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
|
||||
token, err := s.Tokens.Consume(tx, bson.NilObjectID, verificationmodel.PurposeAccountActivation, "", raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := &customer.Account{}
|
||||
if err = s.Accounts.Get(tx, token.AccountRef, a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.Status != customer.AccountPendingVerification || a.IsArchived() {
|
||||
return nil, merrors.Unauthorized("account cannot be activated")
|
||||
}
|
||||
return nil, s.Accounts.Patch(tx, a.ID, repository.Patch().Set(repository.Field("status"), customer.AccountActive))
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package flow
|
||||
|
||||
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/verification/model"
|
||||
verification "git.sendico.io/tech/sxchnge/product/verification/store"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type tokenStub struct {
|
||||
verification.DB
|
||||
request *verification.Request
|
||||
result verification.CreateResult
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *tokenStub) CreateWithResult(_ context.Context, r *verification.Request) (verification.CreateResult, error) {
|
||||
s.request = r
|
||||
return s.result, s.err
|
||||
}
|
||||
|
||||
type senderStub struct {
|
||||
messages []notifications.Message
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *senderStub) Send(_ context.Context, m notifications.Message) error {
|
||||
s.messages = append(s.messages, m)
|
||||
return s.err
|
||||
}
|
||||
func TestIssueRetriesSamePayloadAndBindsServerSecret(t *testing.T) {
|
||||
expiry := time.Now().UTC().Add(time.Minute)
|
||||
tokens := &tokenStub{result: verification.CreateResult{VerificationToken: "123456", ExpiresAt: expiry}}
|
||||
sender := &senderStub{err: merrors.ServiceUnavailable("queue")}
|
||||
service := &Service{Tokens: tokens, Sender: sender, Secret: []byte(strings.Repeat("s", 32))}
|
||||
a := &customer.Account{}
|
||||
a.ID = bson.NewObjectID()
|
||||
a.Login = "user@example.com"
|
||||
a.Locale = "ru-RU"
|
||||
_, err := service.Issue(context.Background(), a, model.PurposeLogin, "pending-scope", "client-key", expiry)
|
||||
require.Error(t, err)
|
||||
key := *tokens.request.IdempotencyKey
|
||||
require.NotEqual(t, "client-key", key)
|
||||
require.Equal(t, "pending-scope", tokens.request.ContextRef)
|
||||
require.LessOrEqual(t, tokens.request.Ttl, time.Minute)
|
||||
sender.err = nil
|
||||
_, err = service.Issue(context.Background(), a, model.PurposeLogin, "pending-scope", "client-key", expiry)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, sender.messages[0], sender.messages[1])
|
||||
require.Equal(t, "ru", sender.messages[0].Locale)
|
||||
service.Secret = []byte(strings.Repeat("b", 32))
|
||||
require.NotEqual(t, key, service.key(a.ID.Hex(), string(model.PurposeLogin), "pending-scope", "client-key"))
|
||||
tokens.result.ExpiresAt = time.Now().Add(-time.Minute)
|
||||
_, err = service.Issue(context.Background(), a, model.PurposeLogin, "pending-scope", "expired")
|
||||
require.ErrorIs(t, err, verification.ErrTokenExpired)
|
||||
require.Len(t, sender.messages, 2)
|
||||
}
|
||||
func TestValidateActivationDestination(t *testing.T) {
|
||||
for _, destination := range []string{"https://app.example/verify-email", "http://localhost:5126/verify-email"} {
|
||||
s := &Service{Secret: []byte(strings.Repeat("s", 32)), ActivationURL: destination}
|
||||
require.NoError(t, s.Validate())
|
||||
}
|
||||
for _, destination := range []string{"http://app.example/verify-email", "//app.example", "https://user:pass@app.example/verify-email", "https://app.example/#/verify"} {
|
||||
s := &Service{Secret: []byte(strings.Repeat("s", 32)), ActivationURL: destination}
|
||||
require.Error(t, s.Validate())
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,8 @@ package router
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
||||
rauthorized "git.sendico.io/tech/sxchnge/product/auth/http/authorized"
|
||||
rpublic "git.sendico.io/tech/sxchnge/product/auth/http/public"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
@@ -16,6 +15,7 @@ import (
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
"git.sendico.io/tech/sxchnge/shared/ratelimit/store"
|
||||
"git.sendico.io/tech/sxchnge/shared/service"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type Dispatcher struct {
|
||||
@@ -45,6 +45,7 @@ func NewDispatcher(
|
||||
attempts ratelimits.Store,
|
||||
config *middleware.Config,
|
||||
loginRateLimits middleware.LoginRateLimits,
|
||||
flows ...*flow.Service,
|
||||
) *Dispatcher {
|
||||
d := &Dispatcher{
|
||||
logger: logger.Named("api_dispatcher"),
|
||||
@@ -54,7 +55,7 @@ func NewDispatcher(
|
||||
endpoint := os.Getenv(config.EndPointEnv)
|
||||
signature := middleware.SignatureConf(config)
|
||||
router.Group(func(r chi.Router) {
|
||||
d.public = rpublic.NewRouter(d.logger, endpoint, db, vdb, rtdb, attempts, r, &config.Token, &signature, loginRateLimits)
|
||||
d.public = rpublic.NewRouter(d.logger, endpoint, db, vdb, rtdb, attempts, r, &config.Token, &signature, loginRateLimits, flows...)
|
||||
})
|
||||
router.Group(func(r chi.Router) {
|
||||
d.protected = rauthorized.NewRouter(d.logger, endpoint, r, db, rtdb, &config.Token, &signature)
|
||||
|
||||
@@ -5,8 +5,6 @@ import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -14,6 +12,8 @@ import (
|
||||
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/request"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging"
|
||||
@@ -195,7 +195,7 @@ func decodeLogin(r *http.Request, logger mlogger.Logger) (*srequest.Login, http.
|
||||
return nil, response.BadPayload(logger, mservice.Accounts, err)
|
||||
}
|
||||
req.Login = strings.TrimSpace(req.Login)
|
||||
req.Password = strings.TrimSpace(req.Password)
|
||||
// Preserve passwords exactly as entered.
|
||||
req.ClientID = strings.TrimSpace(req.ClientID)
|
||||
req.DeviceID = strings.TrimSpace(req.DeviceID)
|
||||
req.ClientSecret = strings.TrimSpace(req.ClientSecret)
|
||||
@@ -225,6 +225,9 @@ func (a *PublicRouter) login(r *http.Request) http.HandlerFunc {
|
||||
if h = a.validateClientAccountBinding(client, account); h != nil {
|
||||
return h
|
||||
}
|
||||
if a.flow != nil {
|
||||
return a.beginLogin(r, account, req.SessionIdentifier, randomKey())
|
||||
}
|
||||
return a.respondPendingLogin(account)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
sresponse "git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
var flowRequests = promauto.NewCounterVec(prometheus.CounterOpts{Name: "sxchnge_auth_flow_requests_total", Help: "Authentication flow outcomes; success means this HTTP step completed, not email delivery."}, []string{"operation", "outcome"})
|
||||
var flowDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{Name: "sxchnge_auth_flow_duration_seconds", Help: "Authentication HTTP step latency."}, []string{"operation"})
|
||||
|
||||
func measured(operation string, handler sresponse.HandlerFunc) sresponse.HandlerFunc {
|
||||
return func(r *http.Request) http.HandlerFunc {
|
||||
start := time.Now()
|
||||
response := handler(r)
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
wrapped := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||
response(wrapped, r)
|
||||
outcome := "accepted"
|
||||
if wrapped.Status() >= 500 {
|
||||
outcome = "unavailable"
|
||||
} else if wrapped.Status() == 429 {
|
||||
outcome = "limited"
|
||||
} else if wrapped.Status() >= 400 {
|
||||
outcome = "rejected"
|
||||
}
|
||||
flowRequests.WithLabelValues(operation, outcome).Inc()
|
||||
flowDuration.WithLabelValues(operation).Observe(time.Since(start).Seconds())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
||||
re "git.sendico.io/tech/sxchnge/product/auth/http/endpoint"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
"git.sendico.io/tech/sxchnge/product/customers/store"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
)
|
||||
|
||||
type PublicRouter struct {
|
||||
flow *flow.Service
|
||||
logger mlogger.Logger
|
||||
db account.DB
|
||||
imp *re.HttpEndpointRouter
|
||||
@@ -44,6 +46,7 @@ func NewRouter(
|
||||
config *middleware.TokenConfig,
|
||||
signature *middleware.Signature,
|
||||
limits middleware.LoginRateLimits,
|
||||
flows ...*flow.Service,
|
||||
) *PublicRouter {
|
||||
l := logger.Named("public")
|
||||
hr := PublicRouter{
|
||||
@@ -59,7 +62,15 @@ func NewRouter(
|
||||
now: time.Now,
|
||||
}
|
||||
|
||||
hr.InstallHandler(hr.service, "/login", api.Post, hr.login)
|
||||
if len(flows) > 0 && flows[0] != nil {
|
||||
hr.flow = flows[0]
|
||||
hr.InstallHandler(hr.service, "/signup", api.Post, measured("signup", hr.signup))
|
||||
hr.InstallHandler(hr.service, "/verification/send", api.Post, measured("activation_send", hr.resendActivation))
|
||||
hr.InstallHandler(hr.service, "/verification/confirm", api.Post, measured("activation", hr.activate))
|
||||
hr.InstallHandler(hr.service, "/login/resend", api.Post, measured("login_send", hr.resendLogin))
|
||||
hr.InstallHandler(hr.service, "/login/verify", api.Post, measured("login_verify", hr.verifyLogin))
|
||||
}
|
||||
hr.InstallHandler(hr.service, "/login", api.Post, measured("login", hr.login))
|
||||
hr.InstallHandler(hr.service, "/login/api", api.Post, hr.apiLogin)
|
||||
hr.InstallHandler(hr.service, "/rotate", api.Post, hr.rotateRefreshToken)
|
||||
hr.InstallHandler(hr.service, "/refresh", api.Post, hr.refreshAccessToken)
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
|
||||
authmodel "git.sendico.io/tech/sxchnge/product/auth/model"
|
||||
sresponse "git.sendico.io/tech/sxchnge/product/auth/response"
|
||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
session "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
verificationmodel "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"
|
||||
"git.sendico.io/tech/sxchnge/shared/http/response"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging/mask"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type signupRequest struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Locale string `json:"locale"`
|
||||
Key string `json:"idempotencyKey"`
|
||||
}
|
||||
type challengeRequest struct {
|
||||
PendingToken string `json:"pendingToken"`
|
||||
Code string `json:"code"`
|
||||
Key string `json:"idempotencyKey"`
|
||||
Session session.SessionIdentifier `json:"sessionIdentifier"`
|
||||
}
|
||||
type challengeResponse struct {
|
||||
UserID string `json:"userId"`
|
||||
PendingToken sresponse.TokenData `json:"pendingToken"`
|
||||
Target string `json:"target"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
ResendAt time.Time `json:"resendAt"`
|
||||
}
|
||||
|
||||
func decodeFlow(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 (pr *PublicRouter) flowError(err error) http.HandlerFunc {
|
||||
if details, ok := verification.Throttle(err); ok {
|
||||
return pr.loginRateLimited(details.RetryAfter)
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, verification.ErrTokenExpired):
|
||||
return response.BadRequest(pr.logger, pr.service, "expired", "Verification expired; request a new email")
|
||||
case errors.Is(err, verification.ErrTokenAttemptsExceeded):
|
||||
return response.BadRequest(pr.logger, pr.service, "locked", "Too many attempts; request a new code")
|
||||
case errors.Is(err, verification.ErrTokenNotFound), errors.Is(err, verification.ErrTokenAlreadyUsed):
|
||||
return response.BadRequest(pr.logger, pr.service, "invalid", "Verification is invalid or already used")
|
||||
case errors.Is(err, merrors.ErrDataConflict):
|
||||
return response.BadRequest(pr.logger, pr.service, "email_in_use", "Email is already registered")
|
||||
case errors.Is(err, merrors.ErrInvalidArg):
|
||||
return response.BadRequest(pr.logger, pr.service, "invalid_request", "Check the request fields")
|
||||
case errors.Is(err, merrors.ErrUnauthorized):
|
||||
return response.Unauthorized(pr.logger, pr.service, "credentials required")
|
||||
default:
|
||||
// Do not include downstream errors: they may contain transport/request details.
|
||||
pr.logger.Warn("Authentication flow dependency failed")
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = w.Write([]byte(`{"data":{"code":"unavailable","details":"Unable to complete verification. Please retry."}}`))
|
||||
}
|
||||
}
|
||||
}
|
||||
func (pr *PublicRouter) signup(r *http.Request) http.HandlerFunc {
|
||||
var req signupRequest
|
||||
if err := decodeFlow(r, &req); err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
if req.Key == "" || len(req.Key) > 128 {
|
||||
return pr.flowError(merrors.InvalidArgument("idempotency key required"))
|
||||
}
|
||||
a := &customer.Account{Password: req.Password}
|
||||
a.Name = req.Name
|
||||
a.Login = req.Email
|
||||
a.Locale = req.Locale
|
||||
a, err := pr.flow.Signup(r.Context(), a, requestIP(r), req.Key)
|
||||
if err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
pr.logger.Info("Signup verification queued", zap.String("account_ref", a.ID.Hex()))
|
||||
return response.Accepted(pr.logger, map[string]string{"userId": a.ID.Hex()})
|
||||
}
|
||||
func (pr *PublicRouter) resendActivation(r *http.Request) http.HandlerFunc {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
Key string `json:"idempotencyKey"`
|
||||
}
|
||||
if err := decodeFlow(r, &req); err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
if req.Key == "" || len(req.Key) > 128 {
|
||||
return pr.flowError(merrors.InvalidArgument("idempotency key required"))
|
||||
}
|
||||
if err := pr.flow.ResendActivation(r.Context(), req.Email, requestIP(r), req.Key); err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
return response.Accepted(pr.logger, map[string]bool{"accepted": true})
|
||||
}
|
||||
func (pr *PublicRouter) activate(r *http.Request) http.HandlerFunc {
|
||||
var req struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := decodeFlow(r, &req); err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
if err := pr.flow.Activate(r.Context(), req.Token); err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
pr.logger.Info("Account activation completed")
|
||||
return response.Accepted(pr.logger, map[string]bool{"verified": true})
|
||||
}
|
||||
func challengeScope(raw string) string {
|
||||
h := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
func (pr *PublicRouter) beginLogin(r *http.Request, a *customer.Account, sessionID session.SessionIdentifier, key string) http.HandlerFunc {
|
||||
if sessionID.ClientID == "" || sessionID.DeviceID == "" || len(sessionID.DeviceID) > 128 {
|
||||
return pr.flowError(merrors.InvalidArgument("session identifier required"))
|
||||
}
|
||||
claims := authmodel.PendingAccount2Claims(a, pendingLoginTTLMinutes)
|
||||
claims["clientId"] = sessionID.ClientID
|
||||
claims["deviceId"] = sessionID.DeviceID
|
||||
// Independent challenges, even when password requests arrive within one second.
|
||||
claims["nonce"] = randomKey()
|
||||
ja := jwtauth.New(pr.signature.Algorithm, pr.signature.PrivateKey, pr.signature.PublicKey)
|
||||
_, raw, err := ja.Encode(claims)
|
||||
if err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
token := sresponse.TokenData{Token: raw, Expiration: time.Unix(claims["exp"].(int64), 0)}
|
||||
return pr.queueLogin(r, a, token, key)
|
||||
}
|
||||
func randomKey() string { return rand.Text() }
|
||||
func (pr *PublicRouter) queueLogin(r *http.Request, a *customer.Account, token sresponse.TokenData, key string) http.HandlerFunc {
|
||||
expires, err := pr.flow.Issue(r.Context(), a, verificationmodel.PurposeLogin, challengeScope(token.Token), key, token.Expiration)
|
||||
if err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
if token.Expiration.Before(expires) {
|
||||
expires = token.Expiration
|
||||
}
|
||||
pr.logger.Info("Login verification queued", zap.String("account_ref", a.ID.Hex()))
|
||||
return response.Accepted(pr.logger, challengeResponse{UserID: a.ID.Hex(), PendingToken: token, Target: mask.Email(a.Login), ExpiresAt: expires, ResendAt: time.Now().Add(flow.Cooldown)})
|
||||
}
|
||||
func (pr *PublicRouter) pending(ctx context.Context, raw string) (*customer.Account, *authmodel.AccountToken, error) {
|
||||
if len(raw) > 8192 {
|
||||
return nil, nil, merrors.Unauthorized("invalid pending token")
|
||||
}
|
||||
ja := jwtauth.New(pr.signature.Algorithm, pr.signature.PrivateKey, pr.signature.PublicKey)
|
||||
t, err := jwtauth.VerifyToken(ja, raw)
|
||||
if err != nil {
|
||||
return nil, nil, merrors.Unauthorized("invalid pending token")
|
||||
}
|
||||
_, claims, err := jwtauth.FromContext(jwtauth.NewContext(ctx, t, nil))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
token, err := authmodel.Claims2Token(claims)
|
||||
if err != nil || !token.Pending || token.ClientID == "" || token.DeviceID == "" {
|
||||
return nil, nil, merrors.Unauthorized("pending login required")
|
||||
}
|
||||
a := &customer.Account{}
|
||||
if err = pr.db.Get(ctx, token.AccountRef, a); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !a.IsActive() || a.IsArchived() {
|
||||
return nil, nil, merrors.Unauthorized("inactive account")
|
||||
}
|
||||
return a, token, nil
|
||||
}
|
||||
func (pr *PublicRouter) resendLogin(r *http.Request) http.HandlerFunc {
|
||||
var req challengeRequest
|
||||
if err := decodeFlow(r, &req); err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
a, t, err := pr.pending(r.Context(), req.PendingToken)
|
||||
if err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
return pr.queueLogin(r, a, sresponse.TokenData{Token: req.PendingToken, Expiration: t.Expiration}, req.Key)
|
||||
}
|
||||
func (pr *PublicRouter) verifyLogin(r *http.Request) http.HandlerFunc {
|
||||
var req challengeRequest
|
||||
if err := decodeFlow(r, &req); err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
a, t, err := pr.pending(r.Context(), req.PendingToken)
|
||||
if err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
client, h := pr.resolveLoginClient(r.Context(), r, t.ClientID)
|
||||
if h != nil {
|
||||
return h
|
||||
}
|
||||
if h = pr.validateClientAccountBinding(client, a); h != nil {
|
||||
return h
|
||||
}
|
||||
code := strings.TrimSpace(req.Code)
|
||||
if len(code) != 6 {
|
||||
return pr.flowError(verification.ErorrTokenNotFound())
|
||||
}
|
||||
if _, err = pr.flow.Tokens.Consume(r.Context(), a.ID, verificationmodel.PurposeLogin, challengeScope(req.PendingToken), code); err != nil {
|
||||
return pr.flowError(err)
|
||||
}
|
||||
pr.logger.Info("Login verification completed", zap.String("account_ref", a.ID.Hex()))
|
||||
return pr.refreshAndRespondLogin(r.Context(), r, &t.SessionIdentifier, a)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package public
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
middleware "git.sendico.io/tech/sxchnge/product/auth/config"
|
||||
"git.sendico.io/tech/sxchnge/product/auth/flow"
|
||||
authstorage "git.sendico.io/tech/sxchnge/product/auth/storage"
|
||||
accountservice "git.sendico.io/tech/sxchnge/product/customers"
|
||||
customer "git.sendico.io/tech/sxchnge/product/customers/model"
|
||||
"git.sendico.io/tech/sxchnge/product/notifications"
|
||||
session "git.sendico.io/tech/sxchnge/product/sessions/model"
|
||||
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/go-chi/chi/v5"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type capturedMail struct {
|
||||
messages []notifications.Message
|
||||
fail bool
|
||||
}
|
||||
|
||||
func (m *capturedMail) Send(_ context.Context, r notifications.Message) error {
|
||||
m.messages = append(m.messages, r)
|
||||
if m.fail {
|
||||
return merrors.ServiceUnavailable("test queue unavailable")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 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": "auth_flow"}})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(factory.CloseConnection)
|
||||
stores, err := authstorage.OpenStores(zap.NewNop(), factory, nil)
|
||||
require.NoError(t, err)
|
||||
customers, err := accountservice.NewAccountService(zap.NewNop(), factory, &middleware.PasswordConfig{Check: middleware.PasswordChecks{MinLength: 12}}, nil)
|
||||
require.NoError(t, err)
|
||||
mail := &capturedMail{}
|
||||
service := &flow.Service{Accounts: stores.Accounts, Customers: customers, Tokens: stores.Verification, Transactions: factory.TransactionFactory(), Attempts: stores.LoginAttempts, Sender: mail, Secret: []byte(strings.Repeat("s", 32)), ActivationURL: "https://app.example/verify-email"}
|
||||
require.NoError(t, service.Validate())
|
||||
// Keep the real verification/customer stores and capture only session writes.
|
||||
sessions := &testRefreshTokenDB{client: &session.Client{ClientID: "web"}}
|
||||
routes := chi.NewRouter()
|
||||
NewRouter(zap.NewNop(), "/api/v1", stores.Accounts, stores.Verification, sessions, nil, routes, &middleware.TokenConfig{Length: 32, Expiration: middleware.TokenExpiration{Account: 1, Refresh: 24}}, &middleware.Signature{Algorithm: "HS256", PrivateKey: service.Secret}, middleware.LoginRateLimits{}, service)
|
||||
post := func(path string, body any) *httptest.ResponseRecorder {
|
||||
raw, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/v1/accounts/"+path, strings.NewReader(string(raw)))
|
||||
r.RemoteAddr = "192.0.2.1:1234"
|
||||
w := httptest.NewRecorder()
|
||||
routes.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
signup := map[string]any{"name": "Customer", "email": "USER@example.com", "password": " correct password ", "locale": "ru", "idempotencyKey": "signup-one"}
|
||||
w := post("signup", signup)
|
||||
require.Equal(t, 202, w.Code, w.Body.String())
|
||||
require.Len(t, mail.messages, 1)
|
||||
require.Equal(t, "auth.activation", mail.messages[0].Template)
|
||||
require.Equal(t, "user@example.com", mail.messages[0].Email)
|
||||
require.Equal(t, "ru", mail.messages[0].Locale)
|
||||
activationURL, err := url.Parse(mail.messages[0].Parameters["url"])
|
||||
require.NoError(t, err)
|
||||
activation := activationURL.Query().Get("token")
|
||||
require.NotEmpty(t, activation)
|
||||
a, err := stores.Accounts.GetByEmail(ctx, "user@example.com")
|
||||
require.NoError(t, err)
|
||||
require.False(t, a.IsActive())
|
||||
login := map[string]any{"login": map[string]string{"login": "user@example.com", "password": " correct password "}, "clientId": "web", "deviceId": "browser"}
|
||||
require.Equal(t, 403, post("login", login).Code)
|
||||
require.Equal(t, 400, post("verification/confirm", map[string]string{"token": "invalid"}).Code)
|
||||
w = post("verification/confirm", map[string]string{"token": activation})
|
||||
require.Equal(t, 202, w.Code, w.Body.String())
|
||||
require.Equal(t, 400, post("verification/confirm", map[string]string{"token": activation}).Code)
|
||||
a, err = stores.Accounts.GetByEmail(ctx, a.Login)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, customer.AccountActive, a.Status)
|
||||
w = post("login", login)
|
||||
require.Equal(t, 202, w.Code, w.Body.String())
|
||||
require.Len(t, mail.messages, 2)
|
||||
require.Empty(t, sessions.tokens)
|
||||
var pending struct {
|
||||
Data challengeResponse `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &pending))
|
||||
require.NotEmpty(t, pending.Data.PendingToken.Token)
|
||||
require.NotContains(t, w.Body.String(), mail.messages[1].Parameters["code"])
|
||||
code := mail.messages[1].Parameters["code"]
|
||||
require.Len(t, code, 6)
|
||||
wrong := "000000"
|
||||
if code == wrong {
|
||||
wrong = "111111"
|
||||
}
|
||||
require.Equal(t, 400, post("login/verify", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "code": wrong}).Code)
|
||||
require.Empty(t, sessions.tokens)
|
||||
require.Equal(t, 429, post("login/resend", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "idempotencyKey": "resend-too-soon"}).Code)
|
||||
w = post("login/verify", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "code": code})
|
||||
require.Equal(t, 200, w.Code, w.Body.String())
|
||||
require.Len(t, sessions.tokens, 1)
|
||||
require.Equal(t, "browser", sessions.tokens[0].DeviceID)
|
||||
require.Equal(t, 400, post("login/verify", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "code": code}).Code)
|
||||
require.Len(t, sessions.tokens, 1)
|
||||
// A queue outage cannot report a successful login challenge or create a session.
|
||||
mail.fail = true
|
||||
require.Equal(t, 503, post("login", login).Code)
|
||||
require.Len(t, sessions.tokens, 1)
|
||||
}
|
||||
@@ -31,3 +31,9 @@ api:
|
||||
token_length: 32
|
||||
check:
|
||||
min_length: 12
|
||||
notifications:
|
||||
address: localhost:8443
|
||||
token_env: SXCHNGE_NOTIFICATIONS_TOKEN
|
||||
ca_file: ../notifications/tls/ca.pem
|
||||
server_name: localhost
|
||||
activation_url: http://localhost:5126/verify-email
|
||||
@@ -69,13 +69,17 @@ require (
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
)
|
||||
|
||||
replace git.sendico.io/tech/sxchnge/shared => ../shared
|
||||
|
||||
require (
|
||||
git.sendico.io/tech/sxchnge/notifications/interface v0.0.0
|
||||
git.sendico.io/tech/sxchnge/shared v0.0.0
|
||||
github.com/go-chi/chi/v5 v5.3.2
|
||||
github.com/go-chi/cors v1.2.2
|
||||
@@ -89,8 +93,10 @@ require (
|
||||
go.mongodb.org/mongo-driver/v2 v2.9.0
|
||||
go.uber.org/zap v1.28.0
|
||||
golang.org/x/crypto v0.56.0
|
||||
google.golang.org/grpc v1.83.2
|
||||
google.golang.org/protobuf v1.36.12
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
moul.io/chizap v1.0.3
|
||||
)
|
||||
|
||||
replace git.sendico.io/tech/sxchnge/shared => ../shared
|
||||
replace git.sendico.io/tech/sxchnge/notifications/interface => ../notifications/interface
|
||||
+12
-2
@@ -63,6 +63,8 @@ github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPE
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -226,6 +228,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -267,8 +271,14 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
|
||||
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
|
||||
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// Package notifications adapts the internal notification wire contract to product models.
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
pb "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Address string `yaml:"address"`
|
||||
TokenEnv string `yaml:"token_env"`
|
||||
CAFile string `yaml:"ca_file"`
|
||||
ServerName string `yaml:"server_name"`
|
||||
ActivationURL string `yaml:"activation_url"`
|
||||
}
|
||||
type Message struct {
|
||||
Key, UserID, Email, Locale, Template string
|
||||
Parameters map[string]string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
type Sender interface {
|
||||
Send(context.Context, Message) error
|
||||
}
|
||||
type Client struct {
|
||||
conn *grpc.ClientConn
|
||||
rpc pb.NotificationsServiceClient
|
||||
token string
|
||||
}
|
||||
|
||||
func Open(c Config) (*Client, error) {
|
||||
token := os.Getenv(c.TokenEnv)
|
||||
if c.Address == "" || len(token) < 32 {
|
||||
return nil, merrors.InvalidArgument("notification address and caller token of at least 32 bytes are required")
|
||||
}
|
||||
tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12, ServerName: c.ServerName}
|
||||
if c.CAFile != "" {
|
||||
pem, err := os.ReadFile(c.CAFile)
|
||||
if err != nil {
|
||||
return nil, merrors.InternalWrap(err, "read notification CA")
|
||||
}
|
||||
pool, err := x509.SystemCertPool()
|
||||
if err != nil {
|
||||
pool = x509.NewCertPool()
|
||||
}
|
||||
if !pool.AppendCertsFromPEM(pem) {
|
||||
return nil, merrors.InvalidArgument("invalid notification CA")
|
||||
}
|
||||
tlsConfig.RootCAs = pool
|
||||
}
|
||||
conn, err := grpc.NewClient(c.Address, grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)))
|
||||
if err != nil {
|
||||
return nil, merrors.InternalWrap(err, "create notification client")
|
||||
}
|
||||
return &Client{conn: conn, rpc: pb.NewNotificationsServiceClient(conn), token: token}, nil
|
||||
}
|
||||
func (c *Client) Close() error { return c.conn.Close() }
|
||||
func (c *Client) Send(ctx context.Context, m Message) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+c.token)
|
||||
res, err := c.rpc.Send(ctx, request(m))
|
||||
if err != nil {
|
||||
return merrors.ServiceUnavailableWrap(err, "notification queue unavailable")
|
||||
}
|
||||
if res.GetNotification().GetId() == "" || len(res.GetNotification().GetDeliveries()) != 1 {
|
||||
return merrors.ServiceUnavailable("notification queue returned no receipt")
|
||||
}
|
||||
for _, d := range res.Notification.Deliveries {
|
||||
switch d.State {
|
||||
case pb.DeliveryState_DELIVERY_STATE_FAILED, pb.DeliveryState_DELIVERY_STATE_EXPIRED, pb.DeliveryState_DELIVERY_STATE_UNKNOWN:
|
||||
return merrors.ServiceUnavailable("notification could not be delivered")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func request(m Message) *pb.SendRequest {
|
||||
return &pb.SendRequest{IdempotencyKey: m.Key, UserId: m.UserID, TemplateId: m.Template, TemplateVersion: "1", Locale: m.Locale, Parameters: m.Parameters, ExpiresAt: timestamppb.New(m.ExpiresAt), Targets: []*pb.Target{{Destination: &pb.Target_Email{Email: &pb.EmailTarget{Address: m.Email}}}}}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/pem"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
pb "git.sendico.io/tech/sxchnge/notifications/interface/notifications/v1"
|
||||
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
type rpcStub struct {
|
||||
pb.NotificationsServiceClient
|
||||
request *pb.SendRequest
|
||||
auth string
|
||||
result *pb.SendResponse
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *rpcStub) Send(ctx context.Context, r *pb.SendRequest, _ ...grpc.CallOption) (*pb.SendResponse, error) {
|
||||
s.request = r
|
||||
md, _ := metadata.FromOutgoingContext(ctx)
|
||||
s.auth = md.Get("authorization")[0]
|
||||
return s.result, s.err
|
||||
}
|
||||
func TestSendMapsOwnedContractAndRejectsFailedReceipt(t *testing.T) {
|
||||
rpc := &rpcStub{result: &pb.SendResponse{Notification: &pb.Notification{Id: "job", Deliveries: []*pb.Delivery{{State: pb.DeliveryState_DELIVERY_STATE_QUEUED}}}}}
|
||||
client := &Client{rpc: rpc, token: "service-secret"}
|
||||
expiry := time.Now().Add(time.Minute)
|
||||
m := Message{Key: "key", UserID: "customer", Email: "user@example.com", Locale: "ru", Template: "auth.login", Parameters: map[string]string{"code": "123456"}, ExpiresAt: expiry}
|
||||
require.NoError(t, client.Send(context.Background(), m))
|
||||
require.Equal(t, "Bearer service-secret", rpc.auth)
|
||||
require.Equal(t, m.Email, rpc.request.Targets[0].GetEmail().Address)
|
||||
require.Equal(t, "1", rpc.request.TemplateVersion)
|
||||
require.Equal(t, m.Parameters, rpc.request.Parameters)
|
||||
require.WithinDuration(t, expiry, rpc.request.ExpiresAt.AsTime(), time.Nanosecond)
|
||||
rpc.result.Notification.Deliveries[0].State = pb.DeliveryState_DELIVERY_STATE_UNKNOWN
|
||||
require.ErrorIs(t, client.Send(context.Background(), m), merrors.ErrServiceUnavailable)
|
||||
rpc.result = nil
|
||||
require.ErrorIs(t, client.Send(context.Background(), m), merrors.ErrServiceUnavailable)
|
||||
}
|
||||
func TestOpenRequiresCredentialsAndValidCA(t *testing.T) {
|
||||
_, err := Open(Config{Address: "localhost:8443", TokenEnv: "ABSENT_NOTIFICATION_TEST_TOKEN"})
|
||||
require.ErrorIs(t, err, merrors.ErrInvalidArg)
|
||||
t.Setenv("TEST_NOTIFICATION_TOKEN", "test-token-that-is-at-least-32-bytes")
|
||||
_, err = Open(Config{Address: "localhost:8443", TokenEnv: "TEST_NOTIFICATION_TOKEN", CAFile: "missing.pem"})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
type tlsRPC struct {
|
||||
pb.UnimplementedNotificationsServiceServer
|
||||
auth chan string
|
||||
}
|
||||
|
||||
func (s *tlsRPC) Send(ctx context.Context, _ *pb.SendRequest) (*pb.SendResponse, error) {
|
||||
md, _ := metadata.FromIncomingContext(ctx)
|
||||
s.auth <- md.Get("authorization")[0]
|
||||
return &pb.SendResponse{Notification: &pb.Notification{Id: "tls-job", Deliveries: []*pb.Delivery{{State: pb.DeliveryState_DELIVERY_STATE_QUEUED}}}}, nil
|
||||
}
|
||||
func TestOpenUsesVerifiedTLSAndCallerAuthentication(t *testing.T) {
|
||||
fixture := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
cert := fixture.TLS.Certificates[0]
|
||||
fixture.Close()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
server := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12})))
|
||||
rpc := &tlsRPC{auth: make(chan string, 1)}
|
||||
pb.RegisterNotificationsServiceServer(server, rpc)
|
||||
go func() { _ = server.Serve(listener) }()
|
||||
t.Cleanup(server.Stop)
|
||||
ca := filepath.Join(t.TempDir(), "ca.pem")
|
||||
require.NoError(t, os.WriteFile(ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Certificate[0]}), 0600))
|
||||
t.Setenv("TEST_TLS_CALLER_TOKEN", "test-service-token-with-at-least-32-bytes")
|
||||
client, err := Open(Config{Address: listener.Addr().String(), TokenEnv: "TEST_TLS_CALLER_TOKEN", CAFile: ca})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
require.NoError(t, client.Send(context.Background(), Message{ExpiresAt: time.Now().Add(time.Minute)}))
|
||||
require.Equal(t, "Bearer test-service-token-with-at-least-32-bytes", <-rpc.auth)
|
||||
}
|
||||
@@ -1,24 +1,13 @@
|
||||
package verification
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
"git.sendico.io/tech/sxchnge/shared/logging/fields"
|
||||
"go.uber.org/zap"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const hashPreviewChars = 12
|
||||
|
||||
func hashPreview(value string) string {
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
if len(value) <= hashPreviewChars {
|
||||
return value
|
||||
}
|
||||
return value[:hashPreviewChars] + "..."
|
||||
}
|
||||
|
||||
func tokenIsDigitsOnly(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
@@ -38,7 +27,6 @@ func tokenStateFields(token *verificationmodel.VerificationToken) []zap.Field {
|
||||
zap.String("token_purpose", string(token.Purpose)),
|
||||
zap.Bool("token_has_target", strings.TrimSpace(token.Target) != ""),
|
||||
zap.Bool("token_has_idempotency_key", token.IdempotencyKey != nil),
|
||||
zap.String("token_verify_hash_prefix", hashPreview(token.VerifyTokenHash)),
|
||||
zap.Bool("token_has_salt", token.Salt != nil),
|
||||
zap.Bool("token_used", token.UsedAt != nil),
|
||||
zap.Time("token_expires_at", token.ExpiresAt),
|
||||
@@ -46,18 +34,12 @@ func tokenStateFields(token *verificationmodel.VerificationToken) []zap.Field {
|
||||
zap.Bool("token_has_max_retries", token.MaxRetries != nil),
|
||||
}
|
||||
|
||||
if token.Salt != nil {
|
||||
fields = append(fields, zap.String("token_salt_prefix", hashPreview(*token.Salt)))
|
||||
}
|
||||
if token.UsedAt != nil {
|
||||
fields = append(fields, zap.Time("token_used_at", *token.UsedAt))
|
||||
}
|
||||
if token.MaxRetries != nil {
|
||||
fields = append(fields, zap.Int("token_max_retries", *token.MaxRetries))
|
||||
}
|
||||
if token.IdempotencyKey != nil {
|
||||
fields = append(fields, zap.String("token_idempotency_key_prefix", hashPreview(*token.IdempotencyKey)))
|
||||
}
|
||||
if strings.TrimSpace(token.ContextRef) != "" {
|
||||
fields = append(fields, zap.String("token_context_ref", token.ContextRef))
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ package verification
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
mutil "git.sendico.io/tech/sxchnge/shared/database/query"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
|
||||
@@ -132,8 +132,6 @@ func (db *verificationDB) lookupConsumeOTPToken(
|
||||
zap.Time("candidate_expires_at", token.ExpiresAt),
|
||||
zap.Int("candidate_attempts", token.Attempts),
|
||||
zap.Bool("candidate_has_max_retries", token.MaxRetries != nil),
|
||||
zap.String("candidate_stored_hash_prefix", hashPreview(token.VerifyTokenHash)),
|
||||
zap.String("candidate_computed_hash_prefix", hashPreview(hash)),
|
||||
zap.Bool("candidate_match", match),
|
||||
)
|
||||
if match {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package verification
|
||||
|
||||
import (
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
@@ -72,7 +72,6 @@ func (scope consumeScope) startFields() []zap.Field {
|
||||
zap.Int("raw_token_trimmed_len", len(scope.trimmedRawToken)),
|
||||
zap.Bool("raw_token_digits_only", tokenIsDigitsOnly(scope.rawToken)),
|
||||
zap.Bool("raw_token_has_edge_whitespace", scope.rawToken != scope.trimmedRawToken),
|
||||
zap.String("raw_token_hash_prefix", hashPreview(tokenHash(scope.rawToken))),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ package verification
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
"time"
|
||||
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
@@ -21,7 +21,7 @@ func activeContextFilter(request *Request, now time.Time) builder.Query {
|
||||
filters := []builder.Query{
|
||||
repository.Filter("accountRef", request.AccountRef),
|
||||
repository.Filter("purpose", request.Purpose),
|
||||
repository.Filter("target", request.Target),
|
||||
targetFilter(request.Target),
|
||||
repository.Filter("usedAt", nil),
|
||||
repository.Query().Comparison(repository.Field("expiresAt"), builder.Gt, now),
|
||||
}
|
||||
@@ -33,7 +33,7 @@ func cooldownActiveContextFilter(request *Request, now, cutoff time.Time) builde
|
||||
filters := []builder.Query{
|
||||
repository.Filter("accountRef", request.AccountRef),
|
||||
repository.Filter("purpose", request.Purpose),
|
||||
repository.Filter("target", request.Target),
|
||||
targetFilter(request.Target),
|
||||
repository.Filter("usedAt", nil),
|
||||
repository.Query().Comparison(repository.Field("expiresAt"), builder.Gt, now),
|
||||
repository.Query().Comparison(repository.Field("createdAt"), builder.Gt, cutoff),
|
||||
@@ -140,7 +140,10 @@ func (db *verificationDB) CreateWithResult(
|
||||
// Always store an idempotency key. Client keys enable replay; generated
|
||||
// keys avoid null-key collisions on older Mongo indexes and keep
|
||||
// transaction retries deterministic.
|
||||
idempotencyKey = syntheticIdempotencyKey()
|
||||
idempotencyKey, err = syntheticIdempotencyKey()
|
||||
if err != nil {
|
||||
return CreateResult{}, err
|
||||
}
|
||||
hasIdempotency = true
|
||||
}
|
||||
|
||||
@@ -246,3 +249,12 @@ func (db *verificationDB) CreateWithResult(
|
||||
createResult.VerificationToken = raw
|
||||
return createResult, nil
|
||||
}
|
||||
|
||||
// Older records omit an empty target; Mongo does not equate a missing field
|
||||
// with the empty string. Match both forms for cooldown, invalidation and replay.
|
||||
func targetFilter(target string) builder.Query {
|
||||
if target != "" {
|
||||
return repository.Filter("target", target)
|
||||
}
|
||||
return repository.Query().Or(repository.Filter("target", ""), repository.Filter("target", nil))
|
||||
}
|
||||
@@ -2,15 +2,16 @@ package verification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
"strings"
|
||||
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
mutil "git.sendico.io/tech/sxchnge/shared/database/query"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
|
||||
"git.sendico.io/tech/sxchnge/shared/errors"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func normalizedIdempotencyKey(value *string) (string, bool) {
|
||||
@@ -24,8 +25,12 @@ func normalizedIdempotencyKey(value *string) (string, bool) {
|
||||
return key, true
|
||||
}
|
||||
|
||||
func syntheticIdempotencyKey() string {
|
||||
return "auto:" + bson.NewObjectID().Hex()
|
||||
func syntheticIdempotencyKey() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", merrors.InternalWrap(err, "generate verification key")
|
||||
}
|
||||
return "auto:" + base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func idempotencyContextFilter(
|
||||
@@ -35,7 +40,7 @@ func idempotencyContextFilter(
|
||||
return repository.Query().And(
|
||||
repository.Filter("accountRef", request.AccountRef),
|
||||
repository.Filter("purpose", request.Purpose),
|
||||
repository.Filter("target", request.Target),
|
||||
targetFilter(request.Target),
|
||||
repository.Filter("idempotencyKey", idempotencyKey),
|
||||
)
|
||||
}
|
||||
@@ -75,6 +80,12 @@ func (db *verificationDB) lookupIdempotencyReplay(ctx context.Context, request *
|
||||
if existingContextRef != request.ContextRef {
|
||||
return nil, false, ErrorIdempotencyConflict()
|
||||
}
|
||||
if tokens[i].UsedAt != nil {
|
||||
return nil, false, ErorrTokenAlreadyUsed()
|
||||
}
|
||||
if tokens[i].MaxRetries != nil && tokens[i].Attempts >= *tokens[i].MaxRetries {
|
||||
return nil, false, ErrorTokenAttemptsExceeded()
|
||||
}
|
||||
if replayed == nil {
|
||||
token := tokens[i]
|
||||
replayed = &token
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package verification
|
||||
|
||||
import (
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
"time"
|
||||
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
@@ -62,6 +62,18 @@ func matchBsonD(filter bson.D, tok *verificationmodel.VerificationToken) bool {
|
||||
|
||||
func matchElem(elem bson.E, tok *verificationmodel.VerificationToken) bool {
|
||||
switch elem.Key {
|
||||
case "$or":
|
||||
arr, ok := elem.Value.(bson.A)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, sub := range arr {
|
||||
d, ok := sub.(bson.D)
|
||||
if ok && matchBsonD(d, tok) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case "$and":
|
||||
arr, ok := elem.Value.(bson.A)
|
||||
if !ok {
|
||||
|
||||
@@ -3,12 +3,13 @@ package verification
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
verificationmodel "git.sendico.io/tech/sxchnge/product/verification/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCreate_CooldownBlocksCreation(t *testing.T) {
|
||||
@@ -174,3 +175,16 @@ func TestCreate_IdempotencySurvivesCallbackRetry(t *testing.T) {
|
||||
}
|
||||
repo.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestCreateWithResultReplayCannotRedeliverConsumedCode(t *testing.T) {
|
||||
db := newTestVerificationDB(t)
|
||||
ctx := context.Background()
|
||||
id := bson.NewObjectID()
|
||||
r := NewOTPRequest(id, verificationmodel.PurposeLogin, "").WithIdempotencyKey("private-server-key").WithContextRef("login")
|
||||
first, err := db.Create(ctx, r)
|
||||
require.NoError(t, err)
|
||||
_, err = db.Consume(ctx, id, verificationmodel.PurposeLogin, "login", first)
|
||||
require.NoError(t, err)
|
||||
_, err = db.CreateWithResult(ctx, r)
|
||||
require.ErrorIs(t, err, ErrTokenAlreadyUsed)
|
||||
}
|
||||
Reference in new issue
Block a user