Separate second-factor verification and delivery from auth (#40)

This commit is contained in:
Stephan D committed 2026-09-12 20:36:35 +02:00
1 parent 12a38cd654
commit ecb63b61c6
33 files changed
+1772 -123

No files matched your search

+1 -1
View File
@@ -225,7 +225,7 @@ func (a *PublicRouter) login(r *http.Request) http.HandlerFunc {
if h = a.validateClientAccountBinding(client, account); h != nil {
return h
}
if a.flow != nil {
if a.factors != nil {
return a.beginLogin(r, account, req.SessionIdentifier, randomKey())
}
return a.respondPendingLogin(account)
@@ -187,7 +187,6 @@ func TestAPILoginAllowsLoginAgainAfterRevokeSameSession(t *testing.T) {
zap.NewNop(),
"/",
&testAccountDB{result: account},
nil,
rtdb,
nil,
chi.NewRouter(),
@@ -51,7 +51,6 @@ func newBrowserRefreshRouter(t *testing.T, expiresAt time.Time) (*PublicRouter,
zap.NewNop(),
"/api/v1",
&testAccountDB{result: account},
nil,
rtdb,
nil,
chi.NewRouter(),
+11 -3
View File
@@ -19,6 +19,14 @@ func (pr *PublicRouter) refreshAndRespondLogin(
session *sessionmodel.SessionIdentifier,
account *customermodel.Account,
) http.HandlerFunc {
result, err := pr.prepareLogin(ctx, r, session, account)
if err != nil {
return response.Internal(pr.logger, pr.service, err)
}
return result
}
func (pr *PublicRouter) prepareLogin(ctx context.Context, r *http.Request, session *sessionmodel.SessionIdentifier, account *customermodel.Account) (http.HandlerFunc, error) {
refreshToken, err := rtokens.PrepareRefreshToken(
ctx,
r,
@@ -32,18 +40,18 @@ func (pr *PublicRouter) refreshAndRespondLogin(
if err != nil {
pr.logger.Warn("Failed to create refresh token", zap.Error(err), mzap.StorableRef(account),
zap.String("client_id", session.ClientID), zap.String("device_id", session.DeviceID))
return response.Internal(pr.logger, pr.service, err)
return nil, err
}
accessToken, err := pr.imp.CreateAccessTokenForSession(account, refreshToken.SessionIdentifier)
if err != nil {
pr.logger.Warn("Failed to generate access token", zap.Error(err), mzap.StorableRef(account),
zap.String("client_id", session.ClientID), zap.String("device_id", session.DeviceID))
return response.Internal(pr.logger, pr.service, err)
return nil, err
}
token := sresponse.TokenData{
Token: refreshToken.RefreshToken,
Expiration: refreshToken.ExpiresAt,
}
return sresponse.Login(pr.logger, account, &accessToken, &token)
return sresponse.Login(pr.logger, account, &accessToken, &token), nil
}
+15 -5
View File
@@ -9,7 +9,7 @@ import (
"git.sendico.io/tech/sxchnge/product/auth/response"
"git.sendico.io/tech/sxchnge/product/customers/store"
"git.sendico.io/tech/sxchnge/product/sessions/store"
"git.sendico.io/tech/sxchnge/product/verification/store"
"git.sendico.io/tech/sxchnge/product/twofactor"
api "git.sendico.io/tech/sxchnge/shared/http"
"git.sendico.io/tech/sxchnge/shared/logging"
"git.sendico.io/tech/sxchnge/shared/ratelimit/store"
@@ -17,7 +17,13 @@ import (
"github.com/go-chi/chi/v5"
)
type Flows struct {
Onboarding *flow.Service
Factors twofactor.API
}
type PublicRouter struct {
factors twofactor.API
flow *flow.Service
logger mlogger.Logger
db account.DB
@@ -39,14 +45,13 @@ func NewRouter(
logger mlogger.Logger,
apiEndpoint string,
db account.DB,
vdb verification.DB,
rtdb refreshtokens.DB,
attempts ratelimits.Store,
router chi.Router,
config *middleware.TokenConfig,
signature *middleware.Signature,
limits middleware.LoginRateLimits,
flows ...*flow.Service,
flows ...Flows,
) *PublicRouter {
l := logger.Named("public")
hr := PublicRouter{
@@ -62,11 +67,16 @@ func NewRouter(
now: time.Now,
}
if len(flows) > 0 && flows[0] != nil {
hr.flow = flows[0]
if len(flows) > 0 {
hr.flow = flows[0].Onboarding
hr.factors = flows[0].Factors
}
if hr.flow != nil {
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))
}
if hr.factors != nil {
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))
}
@@ -9,20 +9,18 @@ import (
"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"
factor "git.sendico.io/tech/sxchnge/product/twofactor/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"
)
@@ -35,12 +33,15 @@ type signupRequest struct {
Key string `json:"idempotencyKey"`
}
type challengeRequest struct {
ChallengeID string `json:"challengeId"`
PendingToken string `json:"pendingToken"`
Code string `json:"code"`
Key string `json:"idempotencyKey"`
Session session.SessionIdentifier `json:"sessionIdentifier"`
}
type challengeResponse struct {
ChallengeID string `json:"challengeId"`
Channel factor.Channel `json:"channel"`
UserID string `json:"userId"`
PendingToken sresponse.TokenData `json:"pendingToken"`
Target string `json:"target"`
@@ -161,19 +162,28 @@ func (pr *PublicRouter) beginLogin(r *http.Request, a *customer.Account, session
return pr.flowError(err)
}
token := sresponse.TokenData{Token: raw, Expiration: time.Unix(claims["exp"].(int64), 0)}
return pr.queueLogin(r, a, token, key)
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)
func loginScope(raw string) factor.Scope {
return factor.Scope{Purpose: verificationmodel.PurposeLogin, ContextRef: challengeScope(raw)}
}
func (pr *PublicRouter) queueLogin(r *http.Request, a *customer.Account, token sresponse.TokenData, key, id string) http.HandlerFunc {
if pr.factors == nil {
return pr.flowError(merrors.ServiceUnavailable("second-factor service unavailable"))
}
req := factor.Request{Subject: a.ID, Scope: loginScope(token.Token), Deadline: token.Expiration, IdempotencyKey: key, IP: requestIP(r)}
var challenge factor.Challenge
var err error
if id == "" {
challenge, err = pr.factors.Begin(r.Context(), req)
} else {
challenge, err = pr.factors.Resend(r.Context(), req, id)
}
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)})
return response.Accepted(pr.logger, challengeResponse{ChallengeID: challenge.ID, Channel: challenge.Channel, UserID: a.ID.Hex(), PendingToken: token, Target: challenge.Target, ExpiresAt: challenge.ExpiresAt, ResendAt: challenge.ResendAt})
}
func (pr *PublicRouter) pending(ctx context.Context, raw string) (*customer.Account, *authmodel.AccountToken, error) {
if len(raw) > 8192 {
@@ -206,11 +216,14 @@ func (pr *PublicRouter) resendLogin(r *http.Request) http.HandlerFunc {
if err := decodeFlow(r, &req); err != nil {
return pr.flowError(err)
}
if req.ChallengeID == "" {
return pr.flowError(merrors.InvalidArgument("challenge ID required"))
}
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)
return pr.queueLogin(r, a, sresponse.TokenData{Token: req.PendingToken, Expiration: t.Expiration}, req.Key, req.ChallengeID)
}
func (pr *PublicRouter) verifyLogin(r *http.Request) http.HandlerFunc {
var req challengeRequest
@@ -228,13 +241,19 @@ func (pr *PublicRouter) verifyLogin(r *http.Request) http.HandlerFunc {
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 pr.factors == nil {
return pr.flowError(merrors.ServiceUnavailable("second-factor service unavailable"))
}
if _, err = pr.flow.Tokens.Consume(r.Context(), a.ID, verificationmodel.PurposeLogin, challengeScope(req.PendingToken), code); err != nil {
scope := loginScope(req.PendingToken)
var result http.HandlerFunc
err = pr.factors.Complete(r.Context(), a.ID, scope, req.ChallengeID, req.Code, func(tx context.Context) error {
var err error
result, err = pr.prepareLogin(tx, r, &t.SessionIdentifier, a)
return err
})
if 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)
pr.logger.Info("Login completed after second-factor proof consumption", zap.String("account_ref", a.ID.Hex()))
return result
}
@@ -17,6 +17,9 @@ import (
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"
"git.sendico.io/tech/sxchnge/product/twofactor"
"git.sendico.io/tech/sxchnge/product/twofactor/delivery"
factorstore "git.sendico.io/tech/sxchnge/product/twofactor/store"
db "git.sendico.io/tech/sxchnge/shared/database"
merrors "git.sendico.io/tech/sxchnge/shared/errors"
sharedmodel "git.sendico.io/tech/sxchnge/shared/model"
@@ -63,8 +66,12 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
require.NoError(t, service.Validate())
// Keep the real verification/customer stores and capture only session writes.
sessions := &testRefreshTokenDB{client: &session.Client{ClientID: "web"}}
records, err := factorstore.Open(zap.NewNop(), factory)
require.NoError(t, err)
factors, err := twofactor.New(stores.Accounts, stores.Verification, records, factory.TransactionFactory(), service.Secret, zap.NewNop(), delivery.Email{Sender: mail})
require.NoError(t, err)
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)
NewRouter(zap.NewNop(), "/api/v1", stores.Accounts, sessions, nil, routes, &middleware.TokenConfig{Length: 32, Expiration: middleware.TokenExpiration{Account: 1, Refresh: 24}}, &middleware.Signature{Algorithm: "HS256", PrivateKey: service.Secret}, middleware.LoginRateLimits{}, Flows{Onboarding: service, Factors: factors})
post := func(path string, body any) *httptest.ResponseRecorder {
raw, err := json.Marshal(body)
require.NoError(t, err)
@@ -113,14 +120,14 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
if code == wrong {
wrong = "111111"
}
require.Equal(t, 400, post("login/verify", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "code": wrong}).Code)
require.Equal(t, 400, post("login/verify", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "challengeId": pending.Data.ChallengeID, "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, 429, post("login/resend", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "challengeId": pending.Data.ChallengeID, "idempotencyKey": "resend-too-soon"}).Code)
w = post("login/verify", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "challengeId": pending.Data.ChallengeID, "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.Equal(t, 400, post("login/verify", map[string]string{"pendingToken": pending.Data.PendingToken.Token, "challengeId": pending.Data.ChallengeID, "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