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