Integrate email notifications into signup and login (#38)

This commit is contained in:
Stephan D committed 2026-09-12 15:07:00 +02:00
1 parent 326d58f705
commit ad5a061164
55 files changed
+1859 -3464

No files matched your search

+5 -2
View File
@@ -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;
+5 -3
View File
@@ -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 {
+31 -5
View File
@@ -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
}
+1
View File
@@ -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)
+215
View File
@@ -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
}
+74
View File
@@ -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())
}
}
+4 -3
View File
@@ -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)
+6 -3
View File
@@ -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())
}
}
}
+12 -1
View File
@@ -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)
}
+6
View File
@@ -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
+8 -2
View File
@@ -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
View File
@@ -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=
+88
View File
@@ -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))),
)
}
+16 -4
View File
@@ -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)
}