diff --git a/extensions/whoami.ts b/extensions/whoami.ts index 5bc792f67..ab493017e 100644 --- a/extensions/whoami.ts +++ b/extensions/whoami.ts @@ -25,7 +25,7 @@ const CLIENT_VISIBLE_FEATURE_FLAGS: ReadonlySet = new Set([ extension.get( '/whoami', - { subdomain: 'api', requireAuth: true }, + { subdomain: 'api', requireAuth: true, allowUnconfirmed: true }, async (req, res) => { const actor = Context.get('actor'); if (!actor?.user?.id) { diff --git a/src/backend/controllers/auth/AuthController.js b/src/backend/controllers/auth/AuthController.js index 9ea5dbf02..0568cd511 100644 --- a/src/backend/controllers/auth/AuthController.js +++ b/src/backend/controllers/auth/AuthController.js @@ -630,7 +630,12 @@ export class AuthController extends PuterController { router.post( '/logout', - { subdomain: ['api', ''], requireAuth: true, antiCsrf: true }, + { + subdomain: ['api', ''], + requireAuth: true, + allowUnconfirmed: true, + antiCsrf: true, + }, async (req, res) => { // Clear the session cookie res.clearCookie(this.config.cookie_name); @@ -670,6 +675,7 @@ export class AuthController extends PuterController { { subdomain: ['api', ''], requireUserActor: true, + allowUnconfirmed: true, rateLimit: { scope: 'send-confirm-email', limit: 10, @@ -716,6 +722,7 @@ export class AuthController extends PuterController { { subdomain: ['api', ''], requireUserActor: true, + allowUnconfirmed: true, rateLimit: { scope: 'confirm-email', limit: 10, @@ -1274,6 +1281,7 @@ export class AuthController extends PuterController { { subdomain: ['api', ''], requireUserActor: true, + allowUnconfirmed: true, captcha: true, rateLimit: { scope: 'save-account', @@ -1477,7 +1485,7 @@ export class AuthController extends PuterController { router.get( '/get-anticsrf-token', - { subdomain: '', requireAuth: true }, + { subdomain: '', requireAuth: true, allowUnconfirmed: true }, async (req, res) => { const sessionId = req.actor?.user?.uuid; if (!sessionId) @@ -1677,7 +1685,12 @@ export class AuthController extends PuterController { router.post( '/auth/revoke-session', - { subdomain: 'api', requireUserActor: true, antiCsrf: true }, + { + subdomain: 'api', + requireUserActor: true, + allowUnconfirmed: true, + antiCsrf: true, + }, async (req, res) => { const { uuid } = req.body; if (!uuid || typeof uuid !== 'string') { @@ -2241,7 +2254,11 @@ export class AuthController extends PuterController { router.get( '/get-gui-token', - { subdomain: ['api', ''], requireUserActor: true }, + { + subdomain: ['api', ''], + requireUserActor: true, + allowUnconfirmed: true, + }, async (req, res) => { if (!req.actor?.session?.uid) throw new HttpError(400, 'No session bound to this actor'); @@ -2257,7 +2274,11 @@ export class AuthController extends PuterController { router.get( '/session/sync-cookie', - { subdomain: ['api', ''], requireUserActor: true }, + { + subdomain: ['api', ''], + requireUserActor: true, + allowUnconfirmed: true, + }, async (req, res) => { if (!req.actor?.session?.uid) { res.status(400).end(); @@ -2293,6 +2314,7 @@ export class AuthController extends PuterController { { subdomain: ['api', ''], requireUserActor: true, + allowUnconfirmed: true, middleware: createUserProtectedGate(userProtectedDeps, { allowTempUsers: true, }), diff --git a/src/backend/core/http/middleware/gates.ts b/src/backend/core/http/middleware/gates.ts index c9397f68b..e49dc1ba4 100644 --- a/src/backend/core/http/middleware/gates.ts +++ b/src/backend/core/http/middleware/gates.ts @@ -49,8 +49,9 @@ const rejectAuth = (req: Request): HttpError => { * per-route `RouteOptions` and pushes the relevant gate(s) onto the express * middleware chain in this order: * - * subdomain → requireAuth (+ suspended check) → requireUserActor → - * adminOnly → allowedAppIds → caller middleware → handler + * subdomain → requireAuth (+ suspended check) → emailConfirmed → + * requireUserActor → adminOnly → allowedAppIds → + * caller middleware → handler * * Each gate either calls `next()` to pass through, calls `next('route')` to * skip (subdomain only), or throws an `HttpError` for the terminal error @@ -198,6 +199,32 @@ export const requireVerifiedGate = (strictFlag: boolean): RequestHandler => { }; }; +// ── requireEmailConfirmed ──────────────────────────────────────────── + +/** + * Reject authenticated users whose account is pending email confirmation + * (`requires_email_confirmation && !email_confirmed`). Runs on every + * authenticated route by default; routes that set `allowUnconfirmed: true` + * skip this gate. + * + * Returns 403 with `email_confirmation_required` so clients can show the + * confirmation prompt instead of a generic error. + */ +export const requireEmailConfirmedGate = (): RequestHandler => { + return (req, _res, next) => { + const user = req.actor?.user; + if (user?.requires_email_confirmation && !user?.email_confirmed) { + next( + new HttpError(403, 'Please confirm your email to continue', { + legacyCode: 'email_confirmation_required', + }), + ); + return; + } + next(); + }; +}; + // ── allowedAppIds ─────────────────────────────────────────────────── /** diff --git a/src/backend/core/http/types.ts b/src/backend/core/http/types.ts index 581bac9be..9dba8b466 100644 --- a/src/backend/core/http/types.ts +++ b/src/backend/core/http/types.ts @@ -57,8 +57,9 @@ export type RoutePath = string | RegExp | Array; * The materializer (`v2/server.ts#materializeRoute`) translates these into a * middleware chain in this order: * - * subdomain → requireAuth (+ suspended) → requireUserActor → - * adminOnly → allowedAppIds → caller `middleware: []` → handler + * subdomain → requireAuth (+ suspended) → emailConfirmed → + * requireUserActor → adminOnly → allowedAppIds → + * caller `middleware: []` → handler * * `requireUserActor`, `adminOnly`, and `allowedAppIds` all imply * `requireAuth`; the materializer dedupes so only one auth gate ends up @@ -99,6 +100,19 @@ export interface RouteOptions { /** Reject unless the actor is acting through one of these apps. Implies `requireAuth`. */ allowedAppIds?: string[]; + /** + * Allow users whose account is pending email confirmation to access + * this route. By default, any authenticated route rejects users where + * `requires_email_confirmation && !email_confirmed` with 403. Set + * this to `true` on essential flows that must remain accessible + * before confirmation: logout, email-confirm, whoami, save-account, + * anti-CSRF token, etc. + * + * Only meaningful when the route also requires authentication (via + * `requireAuth`, `requireUserActor`, `adminOnly`, or `allowedAppIds`). + */ + allowUnconfirmed?: boolean; + /** * Reject unless the actor's user has a confirmed email. 400 with * `account_is_not_verified` on failure. No-op when diff --git a/src/backend/server.ts b/src/backend/server.ts index 1aa6fff17..7c73dc4f7 100644 --- a/src/backend/server.ts +++ b/src/backend/server.ts @@ -36,6 +36,7 @@ import { adminOnlyGate, allowedAppIdsGate, requireAuthGate, + requireEmailConfirmedGate, requireUserActorGate, requireVerifiedGate, subdomainGate, @@ -785,6 +786,15 @@ export class PuterServer { ); if (needsAuth) mwChain.push(requireAuthGate()); + // Default-on email confirmation gate. Every authenticated route + // rejects users pending confirmation unless `allowUnconfirmed` + // opts out. This prevents unconfirmed accounts from accessing + // AI, FS, driver, etc. endpoints while still allowing essential + // flows (logout, confirm-email, whoami, save-account, …). + if (needsAuth && !opts.allowUnconfirmed) { + mwChain.push(requireEmailConfirmedGate()); + } + // `requireVerified` intentionally does NOT imply `requireUserActor`: // FS routes (and similar) want the user's email to be confirmed even // when an app acts on the user's behalf. `requireVerifiedGate` reads