← back to Norma

app/api/auth/login/route.ts

127 lines

import { NextRequest, NextResponse } from 'next/server';
import { readJson } from '@/lib/body';
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(rlKey: string): NextResponse {
  recordLoginFailure(rlKey);
  return NextResponse.json(
    { error: 'Invalid credentials' },
    { status: 401 },
  );
}

export async function POST(request: NextRequest) {
  const ip = loginClientIp(request);

  try {
    let body: unknown;
    try {
      body = await readJson(request);
    } catch {
      return NextResponse.json(
        { error: 'Invalid request body — expected JSON' },
        { status: 400 },
      );
    }
    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 },
      );
    }

    // Brute-force gate scoped per (client-ip, username): a flood against ONE
    // account — or shared-IP / no-proxy traffic that would otherwise collapse to
    // a single key — can no longer lock out every other user (tenant-wide-lockout
    // DoS). 10 failed attempts / 15 min, then 30-min lock. Only failures bump it.
    const rlKey = `${ip}:${String(username)}`;
    const gate = checkLoginAttempt(rlKey);
    if (gate.locked) {
      return NextResponse.json(
        { error: 'too many attempts', retryAfter: gate.retryAfter },
        { status: 429, headers: { 'Retry-After': String(gate.retryAfter) } },
      );
    }

    // 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(rlKey);
    }

    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(rlKey);
    }

    // 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, username) brute-force counter
    clearLoginCounter(rlKey);

    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 },
    );
  }
}