mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 00:14:23 +00:00
feat: count sign-in, pre-check, pair step-up and hand-off limits across every instance #1066
This commit is contained in:
@@ -6,6 +6,7 @@ import { logger } from '@/lib/logger';
|
||||
import { POLICY_DOMAINS_HEADER, POLICY_SCOPE_HEADER, type SettingsPolicy } from '@/lib/admin/types';
|
||||
import { canSeeInstanceDetails, signedInIdentity } from '@/lib/auth/instance-details';
|
||||
import { bearerIdentity } from '@/lib/auth/bearer-identity';
|
||||
import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-unavailable';
|
||||
import { scopesVisibleTo } from '@/lib/admin/policy-scopes';
|
||||
import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
|
||||
|
||||
@@ -56,6 +57,8 @@ export async function GET(request: NextRequest) {
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
});
|
||||
} catch (error) {
|
||||
// A Bearer token can't be checked without the shared store (its budget).
|
||||
if (isStoreUnavailable(error)) return storeUnavailableResponse();
|
||||
logger.error('Policy read error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { confirmHandoff, isHandoffCode } from '@/lib/auth/handoff-store';
|
||||
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
|
||||
import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-unavailable';
|
||||
|
||||
// The SSO callback page calls this when the user confirms the hand-off to the
|
||||
// mobile app, right before it opens the app's link. /api/auth/sso/complete
|
||||
@@ -23,6 +24,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
return NextResponse.json({ ok: true }, { headers: NO_STORE });
|
||||
} catch (error) {
|
||||
if (isStoreUnavailable(error)) return storeUnavailableResponse(NO_STORE);
|
||||
logger.error('Mobile hand-off confirm error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500, headers: NO_STORE });
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { logger } from '@/lib/logger';
|
||||
import { isHandoffCodeChallenge, reserveHandoffCreate, stashHandoff } from '@/lib/auth/handoff-store';
|
||||
import { loginClientAddress } from '@/lib/auth/client-address';
|
||||
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
|
||||
import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-unavailable';
|
||||
|
||||
// Password sign-in hand-off to the mobile app, browser side. The login page
|
||||
// was opened by the app with a `mobile_code_challenge`; after the user signed
|
||||
@@ -42,7 +43,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'invalid_request' }, { status: 400, headers: NO_STORE });
|
||||
}
|
||||
|
||||
if (!reserveHandoffCreate(loginClientAddress(request))) {
|
||||
if (!(await reserveHandoffCreate(loginClientAddress(request)))) {
|
||||
return NextResponse.json({ error: 'too_many_requests' }, { status: 429, headers: NO_STORE });
|
||||
}
|
||||
|
||||
@@ -53,6 +54,7 @@ export async function POST(request: NextRequest) {
|
||||
logger.info('Mobile hand-off parked', { flow: 'password' });
|
||||
return NextResponse.json({ code }, { headers: NO_STORE });
|
||||
} catch (error) {
|
||||
if (isStoreUnavailable(error)) return storeUnavailableResponse(NO_STORE);
|
||||
logger.error('Mobile hand-off create error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500, headers: NO_STORE });
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@/lib/auth/handoff-store';
|
||||
import { loginClientAddress } from '@/lib/auth/client-address';
|
||||
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
|
||||
import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-unavailable';
|
||||
|
||||
// The mobile app's side of the browser sign-in hand-off. It POSTs the code
|
||||
// from its callback link with the PKCE verifier behind the challenge it sent
|
||||
@@ -26,7 +27,7 @@ export async function POST(request: NextRequest) {
|
||||
const crossOrigin = rejectCrossOriginRequest(request);
|
||||
if (crossOrigin) return crossOrigin;
|
||||
try {
|
||||
const refund = reserveHandoffRedeem(loginClientAddress(request));
|
||||
const refund = await reserveHandoffRedeem(loginClientAddress(request));
|
||||
if (!refund) {
|
||||
return NextResponse.json({ error: 'too_many_requests' }, { status: 429, headers: NO_STORE });
|
||||
}
|
||||
@@ -50,10 +51,11 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error }, { status: 400, headers: NO_STORE });
|
||||
}
|
||||
|
||||
refund();
|
||||
await refund();
|
||||
logger.info('Mobile hand-off redeemed', { flow: result.bundle.flow });
|
||||
return NextResponse.json({ ...result.bundle, state: result.state }, { headers: NO_STORE });
|
||||
} catch (error) {
|
||||
if (isStoreUnavailable(error)) return storeUnavailableResponse(NO_STORE);
|
||||
logger.error('Mobile hand-off redeem error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500, headers: NO_STORE });
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { findServerByUrl, parseJmapServers } from '@/lib/admin/jmap-servers';
|
||||
import { IMPERSONATION_GRANT_COOKIE, openImpersonationGrant } from '@/lib/impersonation/grant-cookie';
|
||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
|
||||
import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-unavailable';
|
||||
|
||||
// Desktop side of "Link mobile app". Returns a one-time pairing code for the
|
||||
// QR; the phone redeems it at /api/auth/pair/redeem.
|
||||
@@ -80,6 +81,11 @@ const STEP_UP_ERRORS = {
|
||||
pairing_unavailable: 502,
|
||||
} as const;
|
||||
|
||||
// The answer stands; a count that couldn't be settled stays a failure (the safe side).
|
||||
function settleFailed(error: unknown): void {
|
||||
logger.warn('Pair step-up attempt not settled', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
// CSRF gate (GHSA-qvr9-m8cq-7wvg): cookies written here are SameSite=Lax,
|
||||
// so a cross-site top-level POST would otherwise reach this handler.
|
||||
@@ -128,7 +134,7 @@ export async function POST(request: NextRequest) {
|
||||
// was written under an identity address.
|
||||
const accountName = context.accountName ?? context.username;
|
||||
const attemptKey = pairAttemptKey(serverUrl, accountName);
|
||||
if (!beginPairStepUp(attemptKey)) {
|
||||
if (!(await beginPairStepUp(attemptKey))) {
|
||||
return NextResponse.json({ error: 'too_many_attempts' }, { status: 429 });
|
||||
}
|
||||
const totp = typeof body?.totp === 'string' && body.totp.trim() ? body.totp.trim() : undefined;
|
||||
@@ -146,10 +152,10 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
if (!result.ok) {
|
||||
const wrong = result.error === 'invalid_credentials' || (result.error === 'totp_required' && !!totp);
|
||||
settlePairStepUp(attemptKey, wrong ? 'wrong_credential' : 'other');
|
||||
await settlePairStepUp(attemptKey, wrong ? 'wrong_credential' : 'other').catch(settleFailed);
|
||||
return NextResponse.json({ error: result.error }, { status: STEP_UP_ERRORS[result.error] });
|
||||
}
|
||||
settlePairStepUp(attemptKey, 'success');
|
||||
await settlePairStepUp(attemptKey, 'success').catch(settleFailed);
|
||||
grantId = stashGrant(result.grant, owner);
|
||||
setPairReauthInStore(cookieStore, { grantId, owner });
|
||||
logger.info('Pair step-up succeeded', { flow: result.grant.flow, method: 'password' });
|
||||
@@ -171,6 +177,8 @@ export async function POST(request: NextRequest) {
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
} catch (error) {
|
||||
// Attempts can't be counted, nor grants kept, without the shared store.
|
||||
if (isStoreUnavailable(error)) return storeUnavailableResponse({ 'Cache-Control': 'no-store' });
|
||||
logger.error('Pair create error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { revokeWopiTokens } from '@/lib/wopi/revocation';
|
||||
import { clearPairReauthInStore } from '@/lib/auth/pair-reauth';
|
||||
import { loginClientAddress } from '@/lib/auth/client-address';
|
||||
import { LOGIN_BUDGET_RETRY_AFTER_SECONDS, reserveLoginAttempt } from '@/lib/auth/verify-budget';
|
||||
import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-unavailable';
|
||||
import { isPastMaxAge, limitsEnforcedSince, maxSessionHoursFor, SESSION_TOO_OLD } from '@/lib/auth/session-age';
|
||||
|
||||
function sessionCookieOptions() {
|
||||
@@ -184,7 +185,7 @@ export async function POST(request: NextRequest) {
|
||||
// Wrong credentials tried from here count against this server's address
|
||||
// upstream, so each client gets a failure budget (lib/auth/verify-budget.ts).
|
||||
const clientIp = loginClientAddress(request);
|
||||
const refund = reserveLoginAttempt(clientIp);
|
||||
const refund = await reserveLoginAttempt(clientIp);
|
||||
if (!refund) {
|
||||
return NextResponse.json(
|
||||
{ error: 'too_many_attempts', hint: 'Too many failed sign-in attempts. Try again later.' },
|
||||
@@ -205,10 +206,10 @@ export async function POST(request: NextRequest) {
|
||||
} catch (error) {
|
||||
// Only a credential the server rejected spends the budget; an outage
|
||||
// or a mismatched claim says nothing about the password.
|
||||
if (!(error instanceof JmapAuthVerificationError && error.status === 401)) refund();
|
||||
if (!(error instanceof JmapAuthVerificationError && error.status === 401)) await refund();
|
||||
throw error;
|
||||
}
|
||||
refund();
|
||||
await refund();
|
||||
// A remembered sign-in starts the maximum session length.
|
||||
const signedInAt = Date.now();
|
||||
const token = encryptSession(normalizedServerUrl, username, accessToken ? { token: accessToken } : password, signedInAt);
|
||||
@@ -228,6 +229,8 @@ export async function POST(request: NextRequest) {
|
||||
// The browser counts the maximum session length from the server's time.
|
||||
return NextResponse.json(warning ? { ok: true, warning, signedInAt } : { ok: true, signedInAt });
|
||||
} catch (error) {
|
||||
// The sign-in limit can't be counted without the shared store: refused.
|
||||
if (isStoreUnavailable(error)) return storeUnavailableResponse();
|
||||
if (error instanceof JmapAuthVerificationError) {
|
||||
return NextResponse.json(
|
||||
verificationFailureBody('session', error, upstreamUrl, upstreamTrusted),
|
||||
|
||||
@@ -14,6 +14,7 @@ import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
|
||||
import { insecureCookieHint, verificationFailureBody } from '@/lib/auth/verification-failure';
|
||||
import { loginClientAddress } from '@/lib/auth/client-address';
|
||||
import { LOGIN_BUDGET_RETRY_AFTER_SECONDS, reserveLoginAttempt } from '@/lib/auth/verify-budget';
|
||||
import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-unavailable';
|
||||
import { recordStoredCredentialStatus } from '@/lib/stalwart/stored-credential-budget';
|
||||
|
||||
function getSlot(request: NextRequest, bodySlot: unknown): number {
|
||||
@@ -80,7 +81,7 @@ export async function POST(request: NextRequest) {
|
||||
// Wrong credentials tried from here count against this server's address
|
||||
// upstream, so each client gets a failure budget (lib/auth/verify-budget.ts).
|
||||
const clientIp = loginClientAddress(request);
|
||||
const refund = reserveLoginAttempt(clientIp);
|
||||
const refund = await reserveLoginAttempt(clientIp);
|
||||
if (!refund) {
|
||||
return NextResponse.json(
|
||||
{ error: 'too_many_attempts', hint: 'Too many failed sign-in attempts. Try again later.' },
|
||||
@@ -97,10 +98,10 @@ export async function POST(request: NextRequest) {
|
||||
} catch (error) {
|
||||
// Only a credential the server rejected spends the budget; an outage
|
||||
// or a mismatched claim says nothing about the password.
|
||||
if (!(error instanceof JmapAuthVerificationError && error.status === 401)) refund();
|
||||
if (!(error instanceof JmapAuthVerificationError && error.status === 401)) await refund();
|
||||
throw error;
|
||||
}
|
||||
refund();
|
||||
await refund();
|
||||
// Just signed in: an older refusal of this credential no longer applies.
|
||||
recordStoredCredentialStatus(authHeader, 200);
|
||||
|
||||
@@ -127,6 +128,8 @@ export async function POST(request: NextRequest) {
|
||||
if (warning) logger.warn(`stalwart-context: ${warning}`);
|
||||
return NextResponse.json(warning ? { ok: true, warning } : { ok: true });
|
||||
} catch (error) {
|
||||
// The sign-in limit can't be counted without the shared store: refused.
|
||||
if (isStoreUnavailable(error)) return storeUnavailableResponse();
|
||||
if (error instanceof JmapAuthVerificationError) {
|
||||
return NextResponse.json(
|
||||
verificationFailureBody('stalwart-context', error, upstreamUrl, upstreamTrusted),
|
||||
|
||||
@@ -14,6 +14,7 @@ import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
|
||||
import { stalwartPasswordLogin } from '@/lib/auth/stalwart-password-login';
|
||||
import { loginClientAddress } from '@/lib/auth/client-address';
|
||||
import { LOGIN_BUDGET_RETRY_AFTER_SECONDS, reserveLoginAttempt } from '@/lib/auth/verify-budget';
|
||||
import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-unavailable';
|
||||
import { writeSignInStamp } from '@/lib/auth/session-age';
|
||||
|
||||
/**
|
||||
@@ -59,7 +60,7 @@ async function attemptLogin(
|
||||
|
||||
// Wrong passwords tried from here count against this server's address
|
||||
// upstream, so each client gets a failure budget (lib/auth/verify-budget.ts).
|
||||
const refund = reserveLoginAttempt(clientIp);
|
||||
const refund = await reserveLoginAttempt(clientIp);
|
||||
if (!refund) {
|
||||
return NextResponse.json(
|
||||
{ error: 'too_many_attempts' },
|
||||
@@ -82,7 +83,7 @@ async function attemptLogin(
|
||||
// Only a rejected password spends the budget. `totp_required` means the
|
||||
// password was right and the code is still missing, which every TOTP login
|
||||
// goes through once; outages say nothing about the password.
|
||||
if (result.ok || result.error !== 'invalid_credentials') refund();
|
||||
if (result.ok || result.error !== 'invalid_credentials') await refund();
|
||||
|
||||
if (!result.ok) {
|
||||
switch (result.error) {
|
||||
@@ -197,6 +198,8 @@ export async function POST(request: NextRequest) {
|
||||
rememberMe !== false,
|
||||
);
|
||||
} catch (error) {
|
||||
// The sign-in limit can't be counted without the shared store: refused.
|
||||
if (isStoreUnavailable(error)) return storeUnavailableResponse();
|
||||
logger.error('TOTP login error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
|
||||
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
|
||||
import { getClientIP } from '@/lib/admin/session';
|
||||
import { reserveVerifyProbe } from '@/lib/auth/verify-budget';
|
||||
import { reserveVerifyProbe, type BudgetRefund } from '@/lib/auth/verify-budget';
|
||||
import { isStoreUnavailable } from '@/lib/auth/store-unavailable';
|
||||
|
||||
/**
|
||||
* Server-side credential pre-check for the login form (#969): a password
|
||||
@@ -83,7 +84,15 @@ export async function POST(request: NextRequest) {
|
||||
// Wrong passwords tried from here count against this server's address
|
||||
// upstream; over budget the browser probes the JMAP server itself.
|
||||
const clientIp = getClientIP(request);
|
||||
const refund = reserveVerifyProbe(clientIp);
|
||||
let refund: BudgetRefund | null;
|
||||
try {
|
||||
refund = await reserveVerifyProbe(clientIp);
|
||||
} catch (error) {
|
||||
// The shared store is down: the budget can't be counted, so nothing
|
||||
// is sent upstream and the browser probes for itself.
|
||||
if (isStoreUnavailable(error)) return respond('inconclusive');
|
||||
throw error;
|
||||
}
|
||||
if (!refund) return respond('inconclusive');
|
||||
|
||||
const authHeader = hasToken
|
||||
@@ -91,13 +100,13 @@ export async function POST(request: NextRequest) {
|
||||
: 'Basic ' + Buffer.from(username + ':' + password).toString('base64');
|
||||
try {
|
||||
await verifyJmapAuth(upstreamUrl, authHeader, { trusted: upstreamTrusted, clientIp });
|
||||
refund();
|
||||
await refund();
|
||||
return respond('ok');
|
||||
} catch (error) {
|
||||
if (error instanceof JmapAuthVerificationError && error.upstreamStatus === 401) {
|
||||
return respond('unauthorized');
|
||||
}
|
||||
refund();
|
||||
await refund();
|
||||
logger.debug('Login pre-check inconclusive', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// With a shared store (#1066) the sign-in limits live in the store. When it
|
||||
// can't be reached the limits can't be counted, and a limit that can't be
|
||||
// counted must refuse, never let the attempt through to the mail server.
|
||||
|
||||
vi.mock('next/server', () => ({
|
||||
NextResponse: {
|
||||
json: (data: unknown, init?: { status?: number; headers?: Record<string, string> }) => ({
|
||||
json: async () => data,
|
||||
status: init?.status ?? 200,
|
||||
headers: new Headers(init?.headers),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/logger', () => ({
|
||||
logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('next/headers', () => ({
|
||||
cookies: async () => ({ get: () => undefined, set: vi.fn(), delete: vi.fn(), getAll: () => [] }),
|
||||
}));
|
||||
|
||||
const config: Record<string, unknown> = {};
|
||||
vi.mock('@/lib/admin/config-manager', () => ({
|
||||
configManager: {
|
||||
ensureLoaded: async () => {},
|
||||
get: (key: string, fallback: unknown) => (key in config ? config[key] : fallback),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth/crypto', () => ({
|
||||
encryptSession: () => 'encrypted-session',
|
||||
decryptSession: () => null,
|
||||
decryptPayload: () => null,
|
||||
encryptPayload: () => 'encrypted',
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/stalwart/auth-context', () => ({
|
||||
setStalwartAuthContext: vi.fn(),
|
||||
setStalwartAuthContextInStore: vi.fn(),
|
||||
clearStalwartAuthContextInStore: vi.fn(),
|
||||
readStalwartAuthContextFromStore: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/telemetry/login-tracker', () => ({ recordLogin: vi.fn() }));
|
||||
|
||||
import { setStoreForTests } from '@/lib/shared-store';
|
||||
import { resetLocalStoreForTests } from '@/lib/shared-store/local';
|
||||
import { switchableSharedStore } from './helpers/shared-store-down';
|
||||
|
||||
const SERVER = 'https://mail.example.org';
|
||||
const HOST = 'webmail.example';
|
||||
const CHALLENGE = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
|
||||
|
||||
function request(body: unknown) {
|
||||
const headers: Record<string, string> = {
|
||||
host: HOST,
|
||||
origin: `https://${HOST}`,
|
||||
'content-type': 'application/json',
|
||||
'sec-fetch-site': 'same-origin',
|
||||
'x-forwarded-for': '198.51.100.7',
|
||||
};
|
||||
return {
|
||||
method: 'POST',
|
||||
json: async () => body,
|
||||
headers: { get: (k: string) => headers[k.toLowerCase()] ?? null },
|
||||
nextUrl: { searchParams: new URLSearchParams(), origin: `https://${HOST}` },
|
||||
};
|
||||
}
|
||||
|
||||
type RouteResponse = { status: number; headers: Headers; json: () => Promise<Record<string, unknown>> };
|
||||
|
||||
async function post(route: string, body: unknown) {
|
||||
const mod = (await import(/* @vite-ignore */ route)) as { POST: (r: unknown) => Promise<unknown> };
|
||||
const res = (await mod.POST(request(body))) as RouteResponse;
|
||||
return { status: res.status, retryAfter: res.headers.get('retry-after'), body: await res.json() };
|
||||
}
|
||||
|
||||
let store: ReturnType<typeof switchableSharedStore>;
|
||||
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
for (const k of Object.keys(config)) delete config[k];
|
||||
config.jmapServerUrl = SERVER;
|
||||
vi.resetModules();
|
||||
resetLocalStoreForTests();
|
||||
store = switchableSharedStore();
|
||||
store.down = true;
|
||||
setStoreForTests(store);
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => new Response('{}', { status: 401 }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchSpy.mockRestore();
|
||||
setStoreForTests(null);
|
||||
resetLocalStoreForTests();
|
||||
});
|
||||
|
||||
const basic = `Basic ${Buffer.from('alice@example.org:secret').toString('base64')}`;
|
||||
|
||||
describe('sign-in routes while the shared store is down', () => {
|
||||
it.each([
|
||||
['/api/auth/session', '@/app/api/auth/session/route', { serverUrl: SERVER, username: 'alice@example.org', password: 'secret' }],
|
||||
['/api/auth/stalwart-context', '@/app/api/auth/stalwart-context/route', { serverUrl: SERVER, username: 'alice@example.org', authHeader: basic }],
|
||||
['/api/auth/totp-token-exchange', '@/app/api/auth/totp-token-exchange/route', { serverUrl: SERVER, username: 'alice@example.org', password: 'secret' }],
|
||||
])('%s refuses with 503 and sends nothing upstream', async (_name, route, body) => {
|
||||
const res = await post(route, body);
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.retryAfter).toBe('10');
|
||||
expect(res.body.error).toBe('temporarily_unavailable');
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('the login pre-check answers inconclusive and sends nothing upstream', async () => {
|
||||
const res = await post('@/app/api/auth/verify/route', { serverUrl: SERVER, username: 'alice', password: 'secret' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ result: 'inconclusive' });
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('the mobile hand-off neither parks nor redeems a sign-in', async () => {
|
||||
const created = await post('@/app/api/auth/handoff/create/route', {
|
||||
code_challenge: CHALLENGE,
|
||||
state: 's',
|
||||
server_url: SERVER,
|
||||
username: 'alice@example.org',
|
||||
password: 'secret',
|
||||
});
|
||||
expect(created.status).toBe(503);
|
||||
const redeemed = await post('@/app/api/auth/handoff/redeem/route', { code: 'a'.repeat(64), code_verifier: 'v'.repeat(43) });
|
||||
expect(redeemed.status).toBe(503);
|
||||
});
|
||||
|
||||
it('signs in again once the store is back', async () => {
|
||||
store.down = false;
|
||||
const res = await post('@/app/api/auth/session/route', { serverUrl: SERVER, username: 'alice@example.org', password: 'secret' });
|
||||
// The fake mail server refuses every password: the attempt got through to it.
|
||||
expect(res.status).toBe(401);
|
||||
expect(fetchSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { fakeSharedStore } from '@/lib/shared-store/__tests__/fake-shared';
|
||||
import { StoreUnavailableError, type SharedStore } from '@/lib/shared-store/types';
|
||||
|
||||
/**
|
||||
* A shared store that can be taken down: while `down` is set every kv,
|
||||
* counter and collection call rejects with StoreUnavailableError, as the
|
||||
* Redis store does when its server can't be reached.
|
||||
*/
|
||||
export function switchableSharedStore(): SharedStore & { down: boolean } {
|
||||
const base = fakeSharedStore();
|
||||
const guard = <T extends object>(target: T): T =>
|
||||
new Proxy(target, {
|
||||
get(obj, prop, receiver) {
|
||||
const value = Reflect.get(obj, prop, receiver);
|
||||
if (typeof value !== 'function') return value;
|
||||
return (...args: unknown[]) => {
|
||||
if (store.down) return Promise.reject(new StoreUnavailableError('down (test)'));
|
||||
return value.apply(obj, args);
|
||||
};
|
||||
},
|
||||
});
|
||||
const store: SharedStore & { down: boolean } = {
|
||||
...base,
|
||||
down: false,
|
||||
kv: guard(base.kv),
|
||||
counters: guard(base.counters),
|
||||
collection: (name, options) => guard(base.collection(name, options)),
|
||||
};
|
||||
return store;
|
||||
}
|
||||
@@ -23,60 +23,60 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe('pair step-up attempts', () => {
|
||||
it('locks after five failures', () => {
|
||||
for (let i = 0; i < 4; i++) recordPairStepUpFailure(OWNER, T0 + i);
|
||||
expect(isPairStepUpLocked(OWNER, T0 + 10)).toBe(false);
|
||||
recordPairStepUpFailure(OWNER, T0 + 5);
|
||||
expect(isPairStepUpLocked(OWNER, T0 + 10)).toBe(true);
|
||||
it('locks after five failures', async () => {
|
||||
for (let i = 0; i < 4; i++) await recordPairStepUpFailure(OWNER, T0 + i);
|
||||
expect(await isPairStepUpLocked(OWNER, T0 + 10)).toBe(false);
|
||||
await recordPairStepUpFailure(OWNER, T0 + 5);
|
||||
expect(await isPairStepUpLocked(OWNER, T0 + 10)).toBe(true);
|
||||
});
|
||||
|
||||
it('unlocks when the fifteen-minute window has passed', () => {
|
||||
for (let i = 0; i < 5; i++) recordPairStepUpFailure(OWNER, T0);
|
||||
expect(isPairStepUpLocked(OWNER, T0 + WINDOW_MS - 1)).toBe(true);
|
||||
expect(isPairStepUpLocked(OWNER, T0 + WINDOW_MS)).toBe(false);
|
||||
it('unlocks when the fifteen-minute window has passed', async () => {
|
||||
for (let i = 0; i < 5; i++) await recordPairStepUpFailure(OWNER, T0);
|
||||
expect(await isPairStepUpLocked(OWNER, T0 + WINDOW_MS - 1)).toBe(true);
|
||||
expect(await isPairStepUpLocked(OWNER, T0 + WINDOW_MS)).toBe(false);
|
||||
// A failure after the window starts a new count.
|
||||
recordPairStepUpFailure(OWNER, T0 + WINDOW_MS);
|
||||
expect(isPairStepUpLocked(OWNER, T0 + WINDOW_MS + 1)).toBe(false);
|
||||
await recordPairStepUpFailure(OWNER, T0 + WINDOW_MS);
|
||||
expect(await isPairStepUpLocked(OWNER, T0 + WINDOW_MS + 1)).toBe(false);
|
||||
});
|
||||
|
||||
it('counts the window from the first failure, not the last', () => {
|
||||
recordPairStepUpFailure(OWNER, T0);
|
||||
for (let i = 0; i < 4; i++) recordPairStepUpFailure(OWNER, T0 + WINDOW_MS - 10 + i);
|
||||
expect(isPairStepUpLocked(OWNER, T0 + WINDOW_MS - 1)).toBe(true);
|
||||
expect(isPairStepUpLocked(OWNER, T0 + WINDOW_MS)).toBe(false);
|
||||
it('counts the window from the first failure, not the last', async () => {
|
||||
await recordPairStepUpFailure(OWNER, T0);
|
||||
for (let i = 0; i < 4; i++) await recordPairStepUpFailure(OWNER, T0 + WINDOW_MS - 10 + i);
|
||||
expect(await isPairStepUpLocked(OWNER, T0 + WINDOW_MS - 1)).toBe(true);
|
||||
expect(await isPairStepUpLocked(OWNER, T0 + WINDOW_MS)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps accounts apart', () => {
|
||||
for (let i = 0; i < 5; i++) recordPairStepUpFailure(OWNER, T0);
|
||||
expect(isPairStepUpLocked('1:alice@example.org', T0)).toBe(false);
|
||||
expect(isPairStepUpLocked('0:bob@example.org', T0)).toBe(false);
|
||||
it('keeps accounts apart', async () => {
|
||||
for (let i = 0; i < 5; i++) await recordPairStepUpFailure(OWNER, T0);
|
||||
expect(await isPairStepUpLocked('1:alice@example.org', T0)).toBe(false);
|
||||
expect(await isPairStepUpLocked('0:bob@example.org', T0)).toBe(false);
|
||||
});
|
||||
|
||||
it('clears the count after a successful step-up', () => {
|
||||
for (let i = 0; i < 5; i++) recordPairStepUpFailure(OWNER, T0);
|
||||
clearPairStepUpFailures(OWNER);
|
||||
expect(isPairStepUpLocked(OWNER, T0)).toBe(false);
|
||||
it('clears the count after a successful step-up', async () => {
|
||||
for (let i = 0; i < 5; i++) await recordPairStepUpFailure(OWNER, T0);
|
||||
await clearPairStepUpFailures(OWNER);
|
||||
expect(await isPairStepUpLocked(OWNER, T0)).toBe(false);
|
||||
});
|
||||
|
||||
it('drops entries whose day ran out, so the map stays bounded', () => {
|
||||
recordPairStepUpFailure('0:a@example.org', T0);
|
||||
recordPairStepUpFailure('0:b@example.org', T0);
|
||||
recordPairStepUpFailure('0:c@example.org', T0 + DAY_MS);
|
||||
it('drops entries whose day ran out, so the map stays bounded', async () => {
|
||||
await recordPairStepUpFailure('0:a@example.org', T0);
|
||||
await recordPairStepUpFailure('0:b@example.org', T0);
|
||||
await recordPairStepUpFailure('0:c@example.org', T0 + DAY_MS);
|
||||
const map = (globalThis as unknown as { __bulwarkPairAttempts: Map<string, unknown> }).__bulwarkPairAttempts;
|
||||
expect([...map.keys()]).toEqual(['0:c@example.org']);
|
||||
});
|
||||
|
||||
it("caps failures per day well below the mail server ban", () => {
|
||||
it("caps failures per day well below the mail server ban", async () => {
|
||||
// 5 per window, window after window: the 21st attempt of the day is refused.
|
||||
let now = T0;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
if (i > 0 && i % 5 === 0) now += WINDOW_MS;
|
||||
expect(beginPairStepUp(OWNER, now)).toBe(true);
|
||||
settlePairStepUp(OWNER, 'wrong_credential');
|
||||
expect(await beginPairStepUp(OWNER, now)).toBe(true);
|
||||
await settlePairStepUp(OWNER, 'wrong_credential');
|
||||
}
|
||||
now += WINDOW_MS;
|
||||
expect(isPairStepUpLocked(OWNER, now)).toBe(true);
|
||||
expect(isPairStepUpLocked(OWNER, T0 + DAY_MS)).toBe(false);
|
||||
expect(await isPairStepUpLocked(OWNER, now)).toBe(true);
|
||||
expect(await isPairStepUpLocked(OWNER, T0 + DAY_MS)).toBe(false);
|
||||
});
|
||||
|
||||
it('keys an account by server and login name, whatever the cookie slot', () => {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/lib/logger', () => ({
|
||||
logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
|
||||
import { setStoreForTests } from '@/lib/shared-store';
|
||||
import { resetLocalStoreForTests } from '@/lib/shared-store/local';
|
||||
import { switchableSharedStore } from './helpers/shared-store-down';
|
||||
|
||||
// With several replicas behind a load balancer every sign-in limit lived in
|
||||
// one process's memory: each replica granted its own five wrong passwords,
|
||||
// its own 30 pre-checks and its own pair step-ups (#1066). With a shared
|
||||
// store they are counted once for the whole deployment.
|
||||
|
||||
const T0 = 1_800_000_000_000;
|
||||
const CLIENT = '198.51.100.7';
|
||||
let store: ReturnType<typeof switchableSharedStore>;
|
||||
|
||||
/**
|
||||
* Another replica: a fresh copy of the module, and none of the process
|
||||
* state the auth stores pin on globalThis (only the store itself is shared).
|
||||
*/
|
||||
async function replica<T>(load: () => Promise<T>): Promise<T> {
|
||||
vi.resetModules();
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
delete g.__bulwarkPairAttempts;
|
||||
delete g.__bulwarkMobileHandoffStore;
|
||||
return load();
|
||||
}
|
||||
const budgets = () => replica(() => import('@/lib/auth/verify-budget'));
|
||||
const attempts = () => replica(() => import('@/lib/auth/pair-attempts'));
|
||||
const handoff = () => replica(() => import('@/lib/auth/handoff-store'));
|
||||
|
||||
beforeEach(() => {
|
||||
resetLocalStoreForTests();
|
||||
store = switchableSharedStore();
|
||||
setStoreForTests(store);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setStoreForTests(null);
|
||||
resetLocalStoreForTests();
|
||||
});
|
||||
|
||||
describe('sign-in budgets with a shared store', () => {
|
||||
it('counts login failures of one client across replicas', async () => {
|
||||
const a = await budgets();
|
||||
for (let i = 0; i < 3; i++) expect(await a.reserveLoginAttempt(CLIENT, T0)).not.toBeNull();
|
||||
const b = await budgets();
|
||||
for (let i = 0; i < 2; i++) expect(await b.reserveLoginAttempt(CLIENT, T0)).not.toBeNull();
|
||||
const c = await budgets();
|
||||
expect(await c.reserveLoginAttempt(CLIENT, T0)).toBeNull();
|
||||
// Another client has its own budget, and the window still ends.
|
||||
expect(await c.reserveLoginAttempt('198.51.100.8', T0)).not.toBeNull();
|
||||
expect(await c.reserveLoginAttempt(CLIENT, T0 + 15 * 60 * 1000)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('does not count a refused attempt, and a refund on one replica frees a try on another', async () => {
|
||||
const a = await budgets();
|
||||
const refunds = [];
|
||||
for (let i = 0; i < 5; i++) refunds.push(await a.reserveLoginAttempt(CLIENT, T0));
|
||||
const b = await budgets();
|
||||
expect(await b.reserveLoginAttempt(CLIENT, T0)).toBeNull();
|
||||
expect(await b.reserveLoginAttempt(CLIENT, T0)).toBeNull();
|
||||
await refunds[0]!();
|
||||
await refunds[0]!(); // refunds once only
|
||||
const c = await budgets();
|
||||
expect(await c.reserveLoginAttempt(CLIENT, T0)).not.toBeNull();
|
||||
expect(await c.reserveLoginAttempt(CLIENT, T0)).toBeNull();
|
||||
});
|
||||
|
||||
it('holds the pre-check ceiling of 30 across clients and replicas', async () => {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const replicaBudget = i % 3 === 0 ? await budgets() : (await import('@/lib/auth/verify-budget'));
|
||||
expect(await replicaBudget.reserveVerifyProbe(`203.0.113.${i}`, T0)).not.toBeNull();
|
||||
}
|
||||
const other = await budgets();
|
||||
expect(await other.reserveVerifyProbe('203.0.113.200', T0)).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses rather than allows when the store is down', async () => {
|
||||
const a = await budgets();
|
||||
store.down = true;
|
||||
await expect(a.reserveLoginAttempt(CLIENT, T0)).rejects.toThrow('down');
|
||||
await expect(a.reserveVerifyProbe(CLIENT, T0)).rejects.toThrow('down');
|
||||
});
|
||||
|
||||
it('counts pair step-up failures of one account across replicas', async () => {
|
||||
const key = 'https://mail.example.org|alice@example.org';
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const replicaAttempts = await attempts();
|
||||
expect(await replicaAttempts.beginPairStepUp(key, T0 + i)).toBe(true);
|
||||
await replicaAttempts.settlePairStepUp(key, 'wrong_credential', T0 + i);
|
||||
}
|
||||
const next = await attempts();
|
||||
expect(await next.isPairStepUpLocked(key, T0 + 10)).toBe(true);
|
||||
expect(await next.beginPairStepUp(key, T0 + 10)).toBe(false);
|
||||
|
||||
// An attempt that said nothing about the password is given back.
|
||||
await next.clearPairStepUpFailures(key);
|
||||
expect(await next.beginPairStepUp(key, T0 + 20)).toBe(true);
|
||||
await (await attempts()).settlePairStepUp(key, 'other', T0 + 20);
|
||||
for (let i = 0; i < 5; i++) expect(await (await attempts()).beginPairStepUp(key, T0 + 30)).toBe(true);
|
||||
expect(await (await attempts()).beginPairStepUp(key, T0 + 30)).toBe(false);
|
||||
});
|
||||
|
||||
it('caps pair step-ups at 20 a day across replicas', async () => {
|
||||
const key = 'https://mail.example.org|bob@example.org';
|
||||
let now = T0;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
if (i > 0 && i % 5 === 0) now += 15 * 60 * 1000;
|
||||
expect(await (await attempts()).beginPairStepUp(key, now)).toBe(true);
|
||||
}
|
||||
now += 15 * 60 * 1000;
|
||||
expect(await (await attempts()).isPairStepUpLocked(key, now)).toBe(true);
|
||||
expect(await (await attempts()).isPairStepUpLocked(key, T0 + 24 * 60 * 60 * 1000)).toBe(false);
|
||||
});
|
||||
|
||||
it('counts hand-off creates and redeems across replicas', async () => {
|
||||
for (let i = 0; i < 10; i++) expect(await (await handoff()).reserveHandoffCreate(CLIENT, T0)).toBe(true);
|
||||
expect(await (await handoff()).reserveHandoffCreate(CLIENT, T0)).toBe(false);
|
||||
for (let i = 0; i < 10; i++) expect(await (await handoff()).reserveHandoffRedeem(CLIENT, T0)).not.toBeNull();
|
||||
expect(await (await handoff()).reserveHandoffRedeem(CLIENT, T0)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -42,7 +42,7 @@ interface CacheEntry {
|
||||
}
|
||||
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
const refusedBudget = createFailureBudget({ clientMax: 10, globalMax: Infinity });
|
||||
const refusedBudget = createFailureBudget({ name: 'bearer-refused', clientMax: 10, globalMax: Infinity });
|
||||
|
||||
function bearerToken(request: Request): string | null {
|
||||
const header = request.headers.get('authorization');
|
||||
@@ -81,21 +81,22 @@ function prune(now: number): void {
|
||||
}
|
||||
|
||||
async function verify(serverUrl: string, token: string, client: string): Promise<BearerIdentity | null> {
|
||||
const refund = refusedBudget.reserve(client);
|
||||
// Store down: StoreUnavailableError reaches the caller, nothing is checked.
|
||||
const refund = await refusedBudget.reserve(client);
|
||||
if (!refund) return null;
|
||||
try {
|
||||
const identity = await fetchJmapSessionIdentity(serverUrl, `Bearer ${token}`, {
|
||||
trusted: true,
|
||||
clientIp: client,
|
||||
});
|
||||
refund();
|
||||
await refund();
|
||||
return { addresses: identity.addresses };
|
||||
} catch (error) {
|
||||
const refused = error instanceof JmapAuthVerificationError && error.upstreamStatus !== undefined
|
||||
&& (error.upstreamStatus === 401 || error.upstreamStatus === 403);
|
||||
// Only a token the server refused counts against the client.
|
||||
if (!refused) {
|
||||
refund();
|
||||
await refund();
|
||||
logger.warn('Bearer identity check failed', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
@@ -107,6 +108,8 @@ async function verify(serverUrl: string, token: string, client: string): Promise
|
||||
/**
|
||||
* The identity behind the request's Bearer token, or null when there is no
|
||||
* token, no configured server to check it against, or the server refused it.
|
||||
* Rejects with StoreUnavailableError when the refused-token budget can't be
|
||||
* counted (shared store down).
|
||||
*/
|
||||
export async function bearerIdentity(request: Request, now = Date.now()): Promise<BearerIdentity | null> {
|
||||
const token = bearerToken(request);
|
||||
@@ -125,6 +128,9 @@ export async function bearerIdentity(request: Request, now = Date.now()): Promis
|
||||
// A refused token is checked again sooner than a good one.
|
||||
void result.then((identity) => {
|
||||
if (!identity) entry.expiresAt = Math.min(entry.expiresAt, now + INVALID_TTL_MS);
|
||||
}, () => {
|
||||
// Not checked at all (the shared store is down): ask again next time.
|
||||
if (cache.get(key) === entry) cache.delete(key);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createHash, randomBytes, timingSafeEqual } from 'crypto';
|
||||
import type { RedeemBundle } from '@/lib/auth/pair-bundle';
|
||||
import { isMobileCodeChallenge } from '@/lib/auth/mobile-handoff';
|
||||
import { createFailureBudget } from '@/lib/auth/verify-budget';
|
||||
import { createFailureBudget, type BudgetRefund } from '@/lib/auth/verify-budget';
|
||||
|
||||
// One-time codes for the browser sign-in hand-off to the mobile app
|
||||
// (lib/auth/mobile-handoff.ts, bulwarkmail.org/docs/development/mobile-handoff). The app opens the
|
||||
@@ -167,18 +167,18 @@ export function resetHandoffStoreForTests(): void {
|
||||
// Per-client limits on the routes. Codes carry 256 bits and die on the first
|
||||
// attempt, so guessing is hopeless either way; these keep one client from
|
||||
// filling the store or hammering the redeem route.
|
||||
const createBudget = createFailureBudget({ clientMax: 10, globalMax: Infinity });
|
||||
const redeemBudget = createFailureBudget({ clientMax: 10, globalMax: Infinity });
|
||||
const createBudget = createFailureBudget({ name: 'handoff-create', clientMax: 10, globalMax: Infinity });
|
||||
const redeemBudget = createFailureBudget({ name: 'handoff-redeem', clientMax: 10, globalMax: Infinity });
|
||||
|
||||
/** Count one parked sign-in for `client`; false when it is over its limit. */
|
||||
export function reserveHandoffCreate(client: string, now = Date.now()): boolean {
|
||||
return createBudget.reserve(client, now) !== null;
|
||||
export async function reserveHandoffCreate(client: string, now = Date.now()): Promise<boolean> {
|
||||
return (await createBudget.reserve(client, now)) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserve one redeem attempt for `client`: null when it is over its limit,
|
||||
* otherwise a refund to call when the redeem succeeded (only failures count).
|
||||
*/
|
||||
export function reserveHandoffRedeem(client: string, now = Date.now()): (() => void) | null {
|
||||
export function reserveHandoffRedeem(client: string, now = Date.now()): Promise<BudgetRefund | null> {
|
||||
return redeemBudget.reserve(client, now);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,14 @@
|
||||
// starts, and is given back only once it turns out not to have been a wrong
|
||||
// credential: otherwise parallel requests would all pass the check before the
|
||||
// first one failed.
|
||||
//
|
||||
// One process keeps the counts in memory, as always. With a shared store
|
||||
// (#1066) they are counters there, so every replica counts the same attempts;
|
||||
// when the store is down every function rejects with StoreUnavailableError
|
||||
// and the step-up is refused.
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { getStore, isSharedMode } from '@/lib/shared-store';
|
||||
|
||||
const MAX_FAILURES = 5;
|
||||
const WINDOW_MS = 15 * 60 * 1000;
|
||||
@@ -51,12 +59,42 @@ function live(key: string, now: number): Entry | null {
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function isPairStepUpLocked(key: string, now = Date.now()): boolean {
|
||||
// ─── shared store ──────────────────────────────────────────────────────────
|
||||
|
||||
/** The two counters of an account; the key itself holds a login name, so it is hashed. */
|
||||
function sharedLimits(key: string) {
|
||||
const id = createHash('sha256').update(key).digest('base64url');
|
||||
return [
|
||||
{ key: `pair-attempts:window:${id}`, max: MAX_FAILURES, windowMs: WINDOW_MS },
|
||||
{ key: `pair-attempts:day:${id}`, max: MAX_DAILY_FAILURES, windowMs: DAY_MS },
|
||||
];
|
||||
}
|
||||
|
||||
// ─── API ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function isPairStepUpLocked(key: string, now = Date.now()): Promise<boolean> {
|
||||
if (isSharedMode()) {
|
||||
const limits = sharedLimits(key);
|
||||
const counts = await Promise.all(limits.map((limit) => getStore().counters.get(limit.key, now)));
|
||||
return limits.some((limit, i) => (counts[i]?.count ?? 0) >= limit.max);
|
||||
}
|
||||
return isLockedLocal(key, now);
|
||||
}
|
||||
|
||||
export async function recordPairStepUpFailure(key: string, now = Date.now()): Promise<void> {
|
||||
if (isSharedMode()) {
|
||||
await getStore().counters.reserve(sharedLimits(key).map((limit) => ({ ...limit, max: Infinity })), now);
|
||||
return;
|
||||
}
|
||||
recordFailureLocal(key, now);
|
||||
}
|
||||
|
||||
function isLockedLocal(key: string, now: number): boolean {
|
||||
const entry = live(key, now);
|
||||
return !!entry && (entry.failures >= MAX_FAILURES || entry.daily >= MAX_DAILY_FAILURES);
|
||||
}
|
||||
|
||||
export function recordPairStepUpFailure(key: string, now = Date.now()): void {
|
||||
function recordFailureLocal(key: string, now: number): void {
|
||||
const entry = live(key, now);
|
||||
if (entry) {
|
||||
entry.failures += 1;
|
||||
@@ -74,9 +112,16 @@ export function recordPairStepUpFailure(key: string, now = Date.now()): void {
|
||||
* Start a step-up: false when the account is locked out, otherwise the
|
||||
* attempt is counted as a failure until {@link settlePairStepUp} says better.
|
||||
*/
|
||||
export function beginPairStepUp(key: string, now = Date.now()): boolean {
|
||||
if (isPairStepUpLocked(key, now)) return false;
|
||||
recordPairStepUpFailure(key, now);
|
||||
export async function beginPairStepUp(key: string, now = Date.now()): Promise<boolean> {
|
||||
if (isSharedMode()) {
|
||||
// Check and count in one step, so parallel attempts on several replicas
|
||||
// can't all pass before the first one is counted.
|
||||
return (await getStore().counters.reserve(sharedLimits(key), now)).ok;
|
||||
}
|
||||
// Check and count with nothing in between, as parallel requests would
|
||||
// otherwise all pass the check.
|
||||
if (isLockedLocal(key, now)) return false;
|
||||
recordFailureLocal(key, now);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -85,12 +130,20 @@ export function beginPairStepUp(key: string, now = Date.now()): boolean {
|
||||
* count, a wrong credential keeps its failure, anything else (the server was
|
||||
* unreachable, pairing is unavailable) gives the attempt back.
|
||||
*/
|
||||
export function settlePairStepUp(key: string, outcome: 'success' | 'wrong_credential' | 'other'): void {
|
||||
export async function settlePairStepUp(
|
||||
key: string,
|
||||
outcome: 'success' | 'wrong_credential' | 'other',
|
||||
now = Date.now(),
|
||||
): Promise<void> {
|
||||
if (outcome === 'success') {
|
||||
clearPairStepUpFailures(key);
|
||||
await clearPairStepUpFailures(key);
|
||||
return;
|
||||
}
|
||||
if (outcome === 'other') {
|
||||
if (isSharedMode()) {
|
||||
await getStore().counters.refund(sharedLimits(key).map((limit) => ({ key: limit.key })), now);
|
||||
return;
|
||||
}
|
||||
const entry = store().get(key);
|
||||
if (entry) {
|
||||
entry.failures = Math.max(0, entry.failures - 1);
|
||||
@@ -99,6 +152,10 @@ export function settlePairStepUp(key: string, outcome: 'success' | 'wrong_creden
|
||||
}
|
||||
}
|
||||
|
||||
export function clearPairStepUpFailures(key: string): void {
|
||||
export async function clearPairStepUpFailures(key: string): Promise<void> {
|
||||
if (isSharedMode()) {
|
||||
await Promise.all(sharedLimits(key).map((limit) => getStore().counters.reset(limit.key)));
|
||||
return;
|
||||
}
|
||||
store().delete(key);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { StoreUnavailableError } from '@/lib/shared-store/types';
|
||||
|
||||
/**
|
||||
* Answers for when the shared store (#1066) can't be reached. Sign-in limits
|
||||
* fail closed: a limit that can't be counted refuses the attempt instead of
|
||||
* letting it through. Pairing and the mobile hand-off can neither issue nor
|
||||
* redeem anything without the store.
|
||||
*/
|
||||
|
||||
export const STORE_RETRY_AFTER_SECONDS = 10;
|
||||
|
||||
/**
|
||||
* Also by name: Next gives each bundle its own copy of the error class, and
|
||||
* the one store on globalThis throws the copy of whichever bundle created it.
|
||||
*/
|
||||
export function isStoreUnavailable(error: unknown): error is StoreUnavailableError {
|
||||
return error instanceof StoreUnavailableError || (error instanceof Error && error.name === 'StoreUnavailableError');
|
||||
}
|
||||
|
||||
export function storeUnavailableResponse(headers: Record<string, string> = {}): NextResponse {
|
||||
return NextResponse.json(
|
||||
{ error: 'temporarily_unavailable', hint: 'Sign-in is temporarily unavailable. Try again in a few seconds.' },
|
||||
{ status: 503, headers: { ...headers, 'Retry-After': String(STORE_RETRY_AFTER_SECONDS) } },
|
||||
);
|
||||
}
|
||||
+80
-29
@@ -11,8 +11,16 @@
|
||||
* Over budget the route answers `inconclusive`: the browser then probes the
|
||||
* JMAP server itself, from its own address, exactly as it did before the
|
||||
* pre-check existed.
|
||||
*
|
||||
* With a shared store (#1066) the counts live there, so every replica spends
|
||||
* the same budget. If the store can't be reached, `reserve` throws
|
||||
* StoreUnavailableError and the caller refuses the attempt.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { getStore, isSharedMode, type CounterLimit } from '@/lib/shared-store';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
const WINDOW_MS = 15 * 60 * 1000;
|
||||
const CLIENT_MAX_FAILURES = 5;
|
||||
/**
|
||||
@@ -29,6 +37,8 @@ interface Budget {
|
||||
}
|
||||
|
||||
export interface FailureBudgetOptions {
|
||||
/** Names this budget's counters in the shared store. */
|
||||
name: string;
|
||||
/** Failures one client may cause per window. */
|
||||
clientMax: number;
|
||||
/** Failures all clients together may cause per window; `Infinity` for no ceiling. */
|
||||
@@ -36,13 +46,22 @@ export interface FailureBudgetOptions {
|
||||
windowMs?: number;
|
||||
}
|
||||
|
||||
/** Gives a reserved attempt back. Never throws; calling it twice refunds once. */
|
||||
export type BudgetRefund = () => Promise<void>;
|
||||
|
||||
export interface FailureBudget {
|
||||
/**
|
||||
* Reserve one attempt for `client`. Returns null when the client or the
|
||||
* whole server is over budget; otherwise a function that refunds the
|
||||
* attempt, to be called when the credentials turned out to be right.
|
||||
* Reserve one attempt for `client`. Resolves to null when the client or
|
||||
* the whole server is over budget; otherwise to a function that refunds
|
||||
* the attempt, to be called when the credentials turned out to be right.
|
||||
* Rejects with StoreUnavailableError when the shared store is down.
|
||||
*/
|
||||
reserve(client: string, now?: number): (() => void) | null;
|
||||
reserve(client: string, now?: number): Promise<BudgetRefund | null>;
|
||||
}
|
||||
|
||||
/** A client address as a counter key: as it is when it's short and printable, else hashed. */
|
||||
function clientKey(client: string): string {
|
||||
return /^[\x21-\x7e]{1,128}$/.test(client) ? client : `h:${createHash('sha256').update(client).digest('base64url')}`;
|
||||
}
|
||||
|
||||
export function createFailureBudget(options: FailureBudgetOptions): FailureBudget {
|
||||
@@ -66,37 +85,69 @@ export function createFailureBudget(options: FailureBudgetOptions): FailureBudge
|
||||
}
|
||||
}
|
||||
|
||||
// One process: today's in-memory windows.
|
||||
function reserveLocal(client: string, now: number): BudgetRefund | null {
|
||||
global = fresh(global, now);
|
||||
const own = fresh(clients.get(client), now);
|
||||
if (global.used >= options.globalMax || own.used >= options.clientMax) return null;
|
||||
|
||||
if (!clients.has(client)) prune(now);
|
||||
own.used++;
|
||||
global.used++;
|
||||
clients.set(client, own);
|
||||
|
||||
const reservedGlobal = global;
|
||||
let refunded = false;
|
||||
return async () => {
|
||||
if (refunded) return;
|
||||
refunded = true;
|
||||
own.used = Math.max(0, own.used - 1);
|
||||
reservedGlobal.used = Math.max(0, reservedGlobal.used - 1);
|
||||
};
|
||||
}
|
||||
|
||||
// Several replicas: one atomic reservation over the client's and the
|
||||
// global counter; a refused attempt counts nowhere.
|
||||
async function reserveShared(client: string, now: number): Promise<BudgetRefund | null> {
|
||||
const limits: CounterLimit[] = [{ key: `budget:${options.name}:client:${clientKey(client)}`, max: options.clientMax, windowMs }];
|
||||
if (Number.isFinite(options.globalMax)) {
|
||||
limits.push({ key: `budget:${options.name}:all`, max: options.globalMax, windowMs });
|
||||
}
|
||||
const store = getStore();
|
||||
const result = await store.counters.reserve(limits, now);
|
||||
if (!result.ok) return null;
|
||||
let refunded = false;
|
||||
return async () => {
|
||||
if (refunded) return;
|
||||
refunded = true;
|
||||
try {
|
||||
// Only from the windows the attempt was counted in.
|
||||
await store.counters.refund(limits.map((limit, i) => ({ key: limit.key, resetAt: result.resetAts[i] })), now);
|
||||
} catch (error) {
|
||||
// The attempt stays counted: the safe side.
|
||||
logger.warn('Could not refund a sign-in attempt', {
|
||||
budget: options.name,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
reserve(client: string, now: number = Date.now()): (() => void) | null {
|
||||
global = fresh(global, now);
|
||||
const own = fresh(clients.get(client), now);
|
||||
if (global.used >= options.globalMax || own.used >= options.clientMax) return null;
|
||||
|
||||
if (!clients.has(client)) prune(now);
|
||||
own.used++;
|
||||
global.used++;
|
||||
clients.set(client, own);
|
||||
|
||||
const reservedGlobal = global;
|
||||
let refunded = false;
|
||||
return () => {
|
||||
if (refunded) return;
|
||||
refunded = true;
|
||||
own.used = Math.max(0, own.used - 1);
|
||||
reservedGlobal.used = Math.max(0, reservedGlobal.used - 1);
|
||||
};
|
||||
async reserve(client: string, now: number = Date.now()): Promise<BudgetRefund | null> {
|
||||
return isSharedMode() ? reserveShared(client, now) : reserveLocal(client, now);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const verifyBudget = createFailureBudget({ clientMax: CLIENT_MAX_FAILURES, globalMax: GLOBAL_MAX_FAILURES });
|
||||
const verifyBudget = createFailureBudget({ name: 'verify', clientMax: CLIENT_MAX_FAILURES, globalMax: GLOBAL_MAX_FAILURES });
|
||||
|
||||
/**
|
||||
* Reserve one probe for `client`. Returns null when the client or the whole
|
||||
* server is over budget; otherwise a function that refunds the probe, to be
|
||||
* called when the credentials turned out to be right.
|
||||
* Reserve one probe for `client`. Resolves to null when the client or the
|
||||
* whole server is over budget; otherwise to a function that refunds the
|
||||
* probe, to be called when the credentials turned out to be right.
|
||||
*/
|
||||
export function reserveVerifyProbe(client: string, now: number = Date.now()): (() => void) | null {
|
||||
export function reserveVerifyProbe(client: string, now: number = Date.now()): Promise<BudgetRefund | null> {
|
||||
return verifyBudget.reserve(client, now);
|
||||
}
|
||||
|
||||
@@ -112,9 +163,9 @@ export function reserveVerifyProbe(client: string, now: number = Date.now()): ((
|
||||
* lib/auth/client-address.ts), so a mail server that trusts this host
|
||||
* attributes the failures to the client that caused them.
|
||||
*/
|
||||
const loginBudget = createFailureBudget({ clientMax: CLIENT_MAX_FAILURES, globalMax: Infinity });
|
||||
const loginBudget = createFailureBudget({ name: 'login', clientMax: CLIENT_MAX_FAILURES, globalMax: Infinity });
|
||||
|
||||
export function reserveLoginAttempt(client: string, now: number = Date.now()): (() => void) | null {
|
||||
export function reserveLoginAttempt(client: string, now: number = Date.now()): Promise<BudgetRefund | null> {
|
||||
return loginBudget.reserve(client, now);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user