Enforce TOTP second factor on SSO login (#31)
SSO login minted a full session immediately, ignoring the account's totp_enabled flag — so a 2FA admin with a linked Google/Discord/OIDC identity could sign in without their authenticator code, silently downgrading the account to single-factor (the strength of the IdP login). The local password flow already gates on needsTotp(); SSO did not. Wire SSO through the same staged-TOTP gate: - ssoState: createTotpPending/verifyTotpPending + a short-lived httpOnly sso_totp cookie. The pending token carries stage:'totp' (session validation rejects it) + kind:'sso_totp' (scoped to the SSO endpoint) plus the resolved context (userId, provider, authMethod, returnTo). - sso.controller: finishLogin now stages the challenge and redirects to /admin/login?sso_totp=1 instead of creating a session when the account has TOTP on. New finishSsoTotp verifies the code (backoff + bot-scoring on failure, mirroring loginTotp) and only then mints the session. - sso.routes: POST /auth/sso/totp behind the same backoff/slow/limiter stack and code validation as the local TOTP endpoint. - client: AdminLogin detects ?sso_totp=1 and completes over fetch via api.ssoLoginTotp; the challenge never touches the URL or JS. Keeps the second factor httpOnly throughout, consistent with the SSO tx cookie. 12 new tests; full suite 106/106. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
This commit is contained in:
@@ -1,11 +1,18 @@
|
||||
const express = require('express')
|
||||
const { body } = require('express-validator')
|
||||
|
||||
const ctrl = require('./sso.controller')
|
||||
const { requireAuth } = require('../../../auth/session.middleware')
|
||||
const { ssoStartLimiter } = require('../../../middleware/rateLimit')
|
||||
const { ssoStartLimiter, loginLimiter } = require('../../../middleware/rateLimit')
|
||||
const { slowLogin, backoffGuard } = require('../../../middleware/loginProtection')
|
||||
const validate = require('../../../middleware/validate')
|
||||
|
||||
const ssoRouter = express.Router()
|
||||
|
||||
// Same throttling stack the local login/TOTP endpoints use — the SSO TOTP step is
|
||||
// a code-guessing surface too (cheapest rejection first).
|
||||
const loginGuards = [backoffGuard, slowLogin, loginLimiter]
|
||||
|
||||
// Public discovery — the login page reads this to render provider buttons.
|
||||
ssoRouter.get(
|
||||
'/providers',
|
||||
@@ -57,4 +64,22 @@ ssoRouter.get(
|
||||
ctrl.callback,
|
||||
)
|
||||
|
||||
// Second factor for an SSO login whose account has TOTP enabled. The callback
|
||||
// stages an httpOnly pending-TOTP cookie and bounces the browser to the login
|
||||
// page (?sso_totp=1); the page posts the code here to finish and receive a session.
|
||||
ssoRouter.post(
|
||||
'/sso/totp',
|
||||
// #swagger.tags = ['Auth · SSO']
|
||||
// #swagger.summary = 'Complete an SSO login with a TOTP code'
|
||||
// #swagger.description = 'Second step when a linked account has 2FA enabled. Reads the staged pending-TOTP cookie set by the callback plus the current authenticator code, and on success sets the session cookie. Rate limited and behind bot/backoff guards.'
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["code"], properties: { code: { type: "string" } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Session issued', content: { "application/json": { schema: { type: "object", properties: { user: { $ref: "#/components/schemas/User" }, returnTo: { type: "string" } } } } } } */
|
||||
/* #swagger.responses[401] = { description: 'Invalid code or expired challenge', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[429] = { description: 'Too many attempts (rate limited / backoff)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
...loginGuards,
|
||||
body('code').isString().trim().isLength({ min: 6, max: 8 }),
|
||||
validate,
|
||||
ctrl.finishSsoTotp,
|
||||
)
|
||||
|
||||
module.exports = ssoRouter
|
||||
|
||||
Reference in New Issue
Block a user