import type { Express, Request, Response } from "express"; import type { RowDataPacket } from "mysql2"; import type mysql from "mysql2/promise"; import argon2 from "argon2"; import bcrypt from "bcryptjs"; const IDENTITY_CANDIDATES = [ "email", "unique_name", "phone", "mobile", "username", "user_name", "login_name", "account", "name", "user_unique_id", ]; const PASSWORD_CANDIDATES = [ "password", "passwd", "pwd", "user_password", "password_hash", "user_pwd", "enc_password", ]; function safeIdent(raw: string): string { if (!/^[a-zA-Z0-9_]+$/.test(raw)) { throw new Error("Invalid SQL identifier"); } return `\`${raw}\``; } /** Logical name from env; defaults to MySQL table `user` in database DB_NAME / MYSQL_DATABASE. */ function authTable(): string { return process.env.AUTH_USER_TABLE?.trim() || process.env.DB_USER_TABLE?.trim() || "user"; } /** Match INFORMATION_SCHEMA even when MySQL stores the name as User / USER. */ async function resolveActualTableName( pool: mysql.Pool, schema: string, configured: string, ): Promise { const [exact] = await pool.query( "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? LIMIT 1", [schema, configured], ); if (exact.length > 0) return String(exact[0].TABLE_NAME); const [folded] = await pool.query( "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ? AND LOWER(TABLE_NAME) = LOWER(?) LIMIT 1", [schema, configured], ); if (folded.length > 0) { const actual = String(folded[0].TABLE_NAME); if (actual !== configured) { console.log(`[auth] resolved table name "${configured}" -> "${actual}"`); } return actual; } throw new Error(`No table matching "${configured}" in database "${schema}"`); } async function resolveColumns( pool: mysql.Pool, schema: string, table: string, ): Promise<{ identityCols: string[]; passwordCol: string | null }> { const [rows] = await pool.query( "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?", [schema, table], ); const cols = new Set(rows.map((r) => String(r.COLUMN_NAME))); const envLogin = process.env.AUTH_LOGIN_COLUMNS?.split(",") .map((s) => s.trim()) .filter(Boolean); const envPass = process.env.AUTH_PASSWORD_COLUMN?.trim(); const identityCols = envLogin?.length ? envLogin.filter((c) => cols.has(c)) : IDENTITY_CANDIDATES.filter((c) => cols.has(c)); const passwordCol = envPass && cols.has(envPass) ? envPass : PASSWORD_CANDIDATES.find((c) => cols.has(c)) ?? null; return { identityCols, passwordCol }; } let cachedColumns: { identityCols: string[]; passwordCol: string | null } | null = null; let cachedTable = ""; async function getAuthColumns(pool: mysql.Pool, schema: string, table: string) { if (cachedColumns && cachedTable === `${schema}.${table}`) return cachedColumns; cachedColumns = await resolveColumns(pool, schema, table); cachedTable = `${schema}.${table}`; console.log( `[auth] table=${table} identity=${cachedColumns.identityCols.join(",") || "(none)"} password=${cachedColumns.passwordCol ?? "(none)"}`, ); return cachedColumns; } async function verifyPassword(plain: string, stored: string | null | undefined): Promise { if (stored == null) return false; const s = String(stored); if (s.startsWith("$argon2")) { try { return await argon2.verify(s, plain); } catch { return false; } } if (s.length >= 59 && /^\$2[aby]\$\d{2}\$/.test(s)) { try { return await bcrypt.compare(plain, s); } catch { return false; } } return plain === s; } function pickDisplayName(row: RowDataPacket, login: string): string { const nick = row.nickname ?? row.nick_name ?? row.name ?? row.user_name ?? row.username ?? row.unique_name; if (nick != null && String(nick).trim() !== "") return String(nick); return login; } function pickUserId(row: RowDataPacket): string | number | null { const v = row.id ?? row.user_id ?? row.uid; if (v != null && v !== "") return v as string | number; return null; } export function registerAuthRoutes(app: Express, getPool: () => mysql.Pool): void { app.get("/api/auth/status", async (_req: Request, res: Response) => { try { const pool = getPool(); const schema = process.env.MYSQL_DATABASE || process.env.DB_NAME || ""; const configured = authTable(); const resolved = await resolveActualTableName(pool, schema, configured); const { identityCols, passwordCol } = await getAuthColumns(pool, schema, resolved); res.json({ ok: true, database: schema, userTable: { configured, resolved }, loginColumns: identityCols, passwordColumn: passwordCol, }); } catch (err) { console.error("[auth/status]", err); res.status(503).json({ ok: false, error: err instanceof Error ? err.message : "Auth status unavailable", }); } }); app.post("/api/auth/login", async (req: Request, res: Response) => { const login = typeof req.body?.login === "string" ? req.body.login.trim() : ""; const password = typeof req.body?.password === "string" ? req.body.password : ""; if (!login || !password) { res.status(400).json({ ok: false, error: "Missing login or password" }); return; } try { const pool = getPool(); const schema = process.env.MYSQL_DATABASE || process.env.DB_NAME || ""; const configuredTable = authTable(); const table = await resolveActualTableName(pool, schema, configuredTable); const { identityCols, passwordCol } = await getAuthColumns(pool, schema, table); if (!passwordCol) { console.error( `[auth/login] Table "${table}" has no known password column; set AUTH_PASSWORD_COLUMN in .env`, ); res.status(503).json({ ok: false, error: "Server auth is not configured" }); return; } if (identityCols.length === 0) { console.error( `[auth/login] Table "${table}" has no known login columns; set AUTH_LOGIN_COLUMNS in .env`, ); res.status(503).json({ ok: false, error: "Server auth is not configured" }); return; } const t = safeIdent(table); const orClause = identityCols.map((c) => `${safeIdent(c)} = ?`).join(" OR "); const params = identityCols.map(() => login); const sql = `SELECT * FROM ${t} WHERE ${orClause} LIMIT 1`; const [found] = await pool.query(sql, params); const row = found[0]; if (!row) { res.status(401).json({ ok: false, error: "Invalid credentials" }); return; } const storedHash = row[passwordCol] as string | null | undefined; const ok = await verifyPassword(password, storedHash); if (!ok) { res.status(401).json({ ok: false, error: "Invalid credentials" }); return; } res.json({ ok: true, user: { id: pickUserId(row), displayName: pickDisplayName(row, login), login, }, }); } catch (err) { console.error("[auth/login]", err); res.status(503).json({ ok: false, error: "Login temporarily unavailable" }); } }); app.post("/api/auth/admin", async (req: Request, res: Response) => { const password = typeof req.body?.password === "string" ? req.body.password : ""; const expected = process.env.ADMIN_PASSWORD ?? "admin123"; if (!password) { res.status(400).json({ ok: false, error: "Missing password" }); return; } const match = await verifyPassword(password, expected); if (!match) { res.status(401).json({ ok: false, error: "Invalid admin password" }); return; } res.json({ ok: true }); }); }