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

+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)
}