← back to Norma
app/api/auth/login/route.ts
118 lines
import { NextRequest, NextResponse } from 'next/server';
import { createSession, buildAuthCookie, verifyPassword, hashPassword } from '@/lib/auth';
import {
checkLoginAttempt,
recordLoginFailure,
clearLoginCounter,
loginClientIp,
} from '@/lib/rate-limit';
import { query } from '@/lib/db';
/**
* Standard 401 for invalid credentials.
* Records the failure against the IP's brute-force counter.
*/
function invalidCredentials(ip: string): NextResponse {
recordLoginFailure(ip);
return NextResponse.json(
{ error: 'Invalid credentials' },
{ status: 401 },
);
}
export async function POST(request: NextRequest) {
const ip = loginClientIp(request);
// Brute-force gate: 10 failed attempts / 15 min per IP, then 30-min lock.
// Only failures bump the counter — see recordLoginFailure() calls below.
const gate = checkLoginAttempt(ip);
if (gate.locked) {
return NextResponse.json(
{ error: 'too many attempts', retryAfter: gate.retryAfter },
{
status: 429,
headers: { 'Retry-After': String(gate.retryAfter) },
},
);
}
try {
const body = await request.json();
const { username, password, clientType } = body as {
username?: string;
password?: string;
clientType?: 'retail' | 'trade';
};
if (!username || !password) {
return NextResponse.json(
{ error: 'Username and password are required' },
{ status: 400 },
);
}
// Query tier_credentials by username only — verify password in application code
// NOTE: tier_credentials has no full_name / is_active / last_login_at columns
// in this deployment (migration not applied; schema changes are forbidden).
// Select only real columns; treat every account as active.
const result = await query(
`SELECT username, role, org_id, display_name, client_type, password_hash
FROM tier_credentials WHERE username = $1`,
[username],
);
if (result.rows.length === 0) {
return invalidCredentials(ip);
}
const row = result.rows[0];
// Verify password (supports both bcrypt and legacy SHA-256 hashes)
const { match, needsRehash } = await verifyPassword(password, row.password_hash);
if (!match) {
return invalidCredentials(ip);
}
// Auto-upgrade SHA-256 hashes to bcrypt on successful login
if (needsRehash) {
const bcryptHash = await hashPassword(password);
await query(
'UPDATE tier_credentials SET password_hash = $1, updated_at = NOW() WHERE username = $2',
[bcryptHash, row.username],
).catch((err: Error) => console.error('[auth/login] Rehash failed:', err.message));
}
// Store client_type selection (retail/trade) on the credential
if (clientType && (clientType === 'retail' || clientType === 'trade')) {
await query(
'UPDATE tier_credentials SET client_type = $1, updated_at = NOW() WHERE username = $2',
[clientType, row.username],
).catch(() => { /* non-fatal */ });
}
// Successful login — clear this IP's brute-force counter
clearLoginCounter(ip);
const token = createSession(row.username, row.role, row.org_id);
const cookieValue = buildAuthCookie(token);
const response = NextResponse.json({
success: true,
role: row.role,
orgId: row.org_id,
displayName: row.display_name,
fullName: row.display_name,
clientType: clientType || row.client_type || null,
isInteriorDesigner: clientType === 'trade',
});
response.headers.set('Set-Cookie', cookieValue);
return response;
} catch (err) {
console.error('[auth/login] Error:', (err as Error).message);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 },
);
}
}