fix chatbox
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
import {cookies} from 'next/headers';
|
||||
import {NextResponse} from 'next/server';
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {verifyCredentials} from '@/features/auth/mock/users.mock';
|
||||
import {
|
||||
LOGIN_ERROR_PARAM,
|
||||
type LoginErrorCode,
|
||||
} from '@/features/auth/services/loginErrorCodes';
|
||||
import {resolveRedirectTarget} from '@/features/auth/services/redirectTarget';
|
||||
import {
|
||||
REMEMBERED_MAX_AGE_SECONDS,
|
||||
SESSION_COOKIE,
|
||||
@@ -25,6 +30,23 @@ export const dynamic = 'force-dynamic';
|
||||
* • deciding how long the session lasts (rememberMe is a request, not an
|
||||
* instruction — the server sets the cookie lifetime)
|
||||
* • issuing the httpOnly cookie the client can never read or forge
|
||||
*
|
||||
* ── Two content types, one endpoint ──────────────────────────────────────
|
||||
* It answers both `application/json` (the hydrated form, via authRepository)
|
||||
* and `application/x-www-form-urlencoded` (the browser posting the form
|
||||
* natively, before React has hydrated or when its bundle never arrived).
|
||||
*
|
||||
* That second path is not a nicety, it is the fix for a real leak. The sign-in
|
||||
* form has named inputs; a <form> with no method and no action submits GET to
|
||||
* its own URL, so a submit landing in the pre-hydration window rewrote the
|
||||
* address bar to `/login?email=…&password=…` — putting the password in the
|
||||
* URL bar, in browser history, in the referrer and in every access log between
|
||||
* here and the user. Declaring method="post" action="/api/auth/login" means
|
||||
* the worst case is now a normal POST with the credentials in the body.
|
||||
*
|
||||
* The two paths differ ONLY in how the answer is shaped: JSON gets an
|
||||
* envelope, a native post gets a 303 redirect, because a browser that just
|
||||
* submitted a form needs somewhere to land, not a document full of braces.
|
||||
*/
|
||||
|
||||
interface LoginRequestBody {
|
||||
@@ -33,54 +55,140 @@ interface LoginRequestBody {
|
||||
rememberMe?: unknown;
|
||||
}
|
||||
|
||||
function failure(error: LoginError, status: number): Response {
|
||||
// Shaped as the app's standard envelope so the client's error path is the
|
||||
// same one every other endpoint uses; `field` rides alongside for the form.
|
||||
const body: ApiFailure & {field: LoginError['field']} = {
|
||||
error: {code: status === 401 ? 'unauthorized' : 'bad_request', message: error.message},
|
||||
field: error.field,
|
||||
};
|
||||
return Response.json(body, {status, headers: {'cache-control': 'no-store'}});
|
||||
/** What the request asked for, normalised across both content types. */
|
||||
interface ParsedLogin {
|
||||
email: string;
|
||||
password: string;
|
||||
rememberMe: boolean;
|
||||
/** Only meaningful on the native path — where to land after success. */
|
||||
next: string | null;
|
||||
/** True when the browser posted the form itself, so answer in redirects. */
|
||||
isFormPost: boolean;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest): Promise<Response> {
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
async function parseRequest(req: NextRequest): Promise<ParsedLogin | null> {
|
||||
const contentType = req.headers.get('content-type') ?? '';
|
||||
|
||||
if (
|
||||
contentType.includes('application/x-www-form-urlencoded') ||
|
||||
contentType.includes('multipart/form-data')
|
||||
) {
|
||||
const form = await req.formData();
|
||||
return {
|
||||
email: asString(form.get('email')).trim(),
|
||||
password: asString(form.get('password')),
|
||||
// An unchecked checkbox is absent from the payload entirely; any present
|
||||
// value means checked. Never parsed as a boolean-ish string.
|
||||
rememberMe: form.get('rememberMe') !== null,
|
||||
next: asString(form.get('next')) || null,
|
||||
isFormPost: true,
|
||||
};
|
||||
}
|
||||
|
||||
let body: LoginRequestBody;
|
||||
try {
|
||||
body = (await req.json()) as LoginRequestBody;
|
||||
} catch {
|
||||
return failure({field: 'form', message: 'Malformed request body.'}, 400);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
email: asString(body.email).trim(),
|
||||
password: asString(body.password),
|
||||
rememberMe: body.rememberMe === true,
|
||||
next: null,
|
||||
isFormPost: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A failure, in whichever dialect the caller speaks.
|
||||
*
|
||||
* The redirect carries a code, never the submitted values — bouncing the email
|
||||
* back through the URL to repopulate the field would reintroduce exactly the
|
||||
* leak this endpoint exists to close.
|
||||
*/
|
||||
function failure(
|
||||
req: NextRequest,
|
||||
parsed: Pick<ParsedLogin, 'isFormPost' | 'next'> | null,
|
||||
code: LoginErrorCode,
|
||||
error: LoginError,
|
||||
status: number,
|
||||
): NextResponse {
|
||||
if (parsed?.isFormPost) {
|
||||
const target = new URL('/login', req.url);
|
||||
target.searchParams.set(LOGIN_ERROR_PARAM, code);
|
||||
// Preserve the deep link so a failed attempt does not cost the user the
|
||||
// page they were originally trying to reach. Validated, not echoed raw.
|
||||
const next = resolveRedirectTarget(parsed.next);
|
||||
if (next !== '/dashboard') target.searchParams.set('next', next);
|
||||
// 303: the browser must follow with GET, not repeat the POST.
|
||||
return NextResponse.redirect(target, 303);
|
||||
}
|
||||
|
||||
const email = typeof body.email === 'string' ? body.email.trim() : '';
|
||||
const password = typeof body.password === 'string' ? body.password : '';
|
||||
const rememberMe = body.rememberMe === true;
|
||||
// Shaped as the app's standard envelope so the client's error path is the
|
||||
// same one every other endpoint uses; `field` rides alongside for the form.
|
||||
const body: ApiFailure & {field: LoginError['field']} = {
|
||||
error: {
|
||||
code: status === 401 ? 'unauthorized' : 'bad_request',
|
||||
message: error.message,
|
||||
},
|
||||
field: error.field,
|
||||
};
|
||||
return NextResponse.json(body, {
|
||||
status,
|
||||
headers: {'cache-control': 'no-store'},
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
const parsed = await parseRequest(req);
|
||||
|
||||
if (!parsed) {
|
||||
return failure(
|
||||
req,
|
||||
null,
|
||||
'malformed',
|
||||
{field: 'form', message: 'Malformed request body.'},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
// Server-side validation, repeated rather than trusted from the client. The
|
||||
// form validates too, for latency; this validates because the form is not a
|
||||
// security boundary and a POST can arrive without it.
|
||||
if (!email) {
|
||||
return failure({field: 'email', message: 'Enter your email address.'}, 400);
|
||||
if (!parsed.email) {
|
||||
return failure(req, parsed, 'email_required', {
|
||||
field: 'email',
|
||||
message: 'Enter your email address.',
|
||||
}, 400);
|
||||
}
|
||||
if (!/^\S+@\S+\.\S+$/.test(email)) {
|
||||
return failure(
|
||||
{field: 'email', message: 'Enter a valid email address.'},
|
||||
400,
|
||||
);
|
||||
if (!/^\S+@\S+\.\S+$/.test(parsed.email)) {
|
||||
return failure(req, parsed, 'email_invalid', {
|
||||
field: 'email',
|
||||
message: 'Enter a valid email address.',
|
||||
}, 400);
|
||||
}
|
||||
if (!password) {
|
||||
return failure({field: 'password', message: 'Enter your password.'}, 400);
|
||||
if (!parsed.password) {
|
||||
return failure(req, parsed, 'password_required', {
|
||||
field: 'password',
|
||||
message: 'Enter your password.',
|
||||
}, 400);
|
||||
}
|
||||
|
||||
const check = verifyCredentials(email, password);
|
||||
const check = verifyCredentials(parsed.email, parsed.password);
|
||||
|
||||
if (check.outcome === 'unknown_email' || check.outcome === 'wrong_password') {
|
||||
return failure(
|
||||
{field: 'form', message: 'Invalid email or password.'},
|
||||
401,
|
||||
);
|
||||
return failure(req, parsed, 'invalid_credentials', {
|
||||
field: 'form',
|
||||
message: 'Invalid email or password.',
|
||||
}, 401);
|
||||
}
|
||||
|
||||
const maxAge = rememberMe
|
||||
const maxAge = parsed.rememberMe
|
||||
? REMEMBERED_MAX_AGE_SECONDS
|
||||
: SESSION_MAX_AGE_SECONDS;
|
||||
|
||||
@@ -95,23 +203,42 @@ export async function POST(req: NextRequest): Promise<Response> {
|
||||
maxAge,
|
||||
);
|
||||
|
||||
// A browser-session cookie still needs a server-side expiry, or a tab left
|
||||
// open for a week would hold a valid token indefinitely.
|
||||
const store = await cookies();
|
||||
store.set(
|
||||
SESSION_COOKIE,
|
||||
token,
|
||||
sessionCookieOptions(rememberMe ? maxAge : undefined),
|
||||
);
|
||||
|
||||
const session: AuthSession = {
|
||||
user: check.user,
|
||||
expiresAt: new Date(Date.now() + maxAge * 1000).toISOString(),
|
||||
};
|
||||
|
||||
const payload: ApiSuccess<AuthSession> = {
|
||||
data: session,
|
||||
meta: {generatedAt: new Date().toISOString()},
|
||||
};
|
||||
return Response.json(payload, {headers: {'cache-control': 'no-store'}});
|
||||
const response = parsed.isFormPost
|
||||
? // 303 so the browser re-issues as GET. Without it, a refresh on the
|
||||
// landing page would re-POST the credentials.
|
||||
NextResponse.redirect(
|
||||
new URL(resolveRedirectTarget(parsed.next), req.url),
|
||||
303,
|
||||
)
|
||||
: NextResponse.json(
|
||||
{
|
||||
data: session,
|
||||
meta: {generatedAt: new Date().toISOString()},
|
||||
} satisfies ApiSuccess<AuthSession>,
|
||||
{headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
|
||||
/**
|
||||
* Set on the response rather than through the `cookies()` store, because
|
||||
* this response may be a redirect: the header has to ride along with the 303
|
||||
* itself or the browser follows it to the dashboard still signed out, gets
|
||||
* bounced back to /login by the proxy, and the sign-in appears to have
|
||||
* silently failed.
|
||||
*
|
||||
* A browser-session cookie still needs a server-side expiry, or a tab left
|
||||
* open for a week would hold a valid token indefinitely — hence the token's
|
||||
* own `exp` regardless of whether maxAge is sent.
|
||||
*/
|
||||
response.cookies.set(
|
||||
SESSION_COOKIE,
|
||||
token,
|
||||
sessionCookieOptions(parsed.rememberMe ? maxAge : undefined),
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user