diff --git a/package.json b/package.json index 744f842..bd2189a 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "db:migrate": "turbo db:migrate", "db:seed": "turbo db:seed", "db:seed:admin": "npm run db:seed --workspace @rentaldrivego/database", + "admin:2fa:enroll": "node scripts/enroll-admin-2fa.cjs", "db:studio": "cd packages/database && npx prisma studio", "test:api": "npm run test --workspace @rentaldrivego/api", "test:api:integration": "npm run test:integration --workspace @rentaldrivego/api", diff --git a/scripts/enroll-admin-2fa.cjs b/scripts/enroll-admin-2fa.cjs new file mode 100644 index 0000000..b3f3423 --- /dev/null +++ b/scripts/enroll-admin-2fa.cjs @@ -0,0 +1,167 @@ +#!/usr/bin/env node + +const fs = require('node:fs') +const path = require('node:path') +const { authenticator } = require('otplib') + +function parseEnvFile(content) { + const env = {} + + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim() + if (!line || line.startsWith('#')) continue + + const normalized = line.startsWith('export ') ? line.slice(7).trim() : line + const separatorIndex = normalized.indexOf('=') + if (separatorIndex === -1) continue + + const key = normalized.slice(0, separatorIndex).trim() + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue + + let value = normalized.slice(separatorIndex + 1).trim() + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + const quote = value[0] + value = value.slice(1, -1) + if (quote === '"') { + value = value + .replace(/\\n/g, '\n') + .replace(/\\r/g, '\r') + .replace(/\\t/g, '\t') + .replace(/\\"/g, '"') + .replace(/\\\\/g, '\\') + } + } + + env[key] = value + } + + return env +} + +function loadLocalEnv() { + const envPath = path.resolve(process.cwd(), '.env.local') + if (!fs.existsSync(envPath)) return {} + return parseEnvFile(fs.readFileSync(envPath, 'utf8')) +} + +function normalizeApiBase(value) { + const base = (value || 'http://localhost:4000').replace(/\/$/, '') + return base.endsWith('/api/v1') ? base : `${base}/api/v1` +} + +function updateCookieJar(cookieJar, setCookieHeaders) { + for (const header of setCookieHeaders) { + const pair = header.split(';')[0] + const separatorIndex = pair.indexOf('=') + if (separatorIndex === -1) continue + + const name = pair.slice(0, separatorIndex) + const value = pair.slice(separatorIndex + 1) + if (value) cookieJar.set(name, value) + else cookieJar.delete(name) + } +} + +function cookieHeader(cookieJar) { + return Array.from(cookieJar.entries()) + .map(([name, value]) => `${name}=${value}`) + .join('; ') +} + +async function request(apiBase, cookieJar, pathName, options = {}) { + const headers = { + ...(options.body ? { 'Content-Type': 'application/json' } : {}), + ...(cookieJar.size ? { Cookie: cookieHeader(cookieJar) } : {}), + ...options.headers, + } + + const response = await fetch(`${apiBase}${pathName}`, { + ...options, + headers, + }) + + updateCookieJar(cookieJar, response.headers.getSetCookie?.() ?? []) + + const text = await response.text() + let json = null + if (text) { + try { + json = JSON.parse(text) + } catch { + throw new Error(`Non-JSON response from ${pathName}: ${text.slice(0, 200)}`) + } + } + + return { response, json } +} + +async function main() { + const localEnv = loadLocalEnv() + const env = { ...localEnv, ...process.env } + + const email = env.ADMIN_SEED_EMAIL || 'admin@rentaldrivego.ma' + const password = env.ADMIN_SEED_PASSWORD + const apiBase = normalizeApiBase(env.NEXT_PUBLIC_API_URL || env.API_URL) + + if (!password || /^(placeholder|changeme|change-me)$/i.test(password)) { + throw new Error('Set ADMIN_SEED_PASSWORD in .env.local or in the command environment.') + } + + const cookieJar = new Map() + + const login = await request(apiBase, cookieJar, '/admin/auth/login', { + method: 'POST', + body: JSON.stringify({ email, password }), + }) + + if (!login.response.ok) { + const error = login.json?.error || login.response.status + if (error === 'totp_required') { + throw new Error('This admin already has 2FA enabled. Use your authenticator or recovery code to sign in.') + } + throw new Error(login.json?.message || `Admin login failed: ${error}`) + } + + const me = await request(apiBase, cookieJar, '/admin/auth/me') + const admin = me.json?.data ?? me.json + if (admin?.totpEnabled) { + console.log(`Admin 2FA is already enabled for ${email}.`) + return + } + + const setup = await request(apiBase, cookieJar, '/admin/auth/2fa/setup', { method: 'POST' }) + if (!setup.response.ok) { + throw new Error(setup.json?.message || 'Failed to create admin 2FA setup secret.') + } + + const secret = setup.json?.data?.secret ?? setup.json?.secret + if (!secret) throw new Error('2FA setup response did not include a secret.') + + const code = authenticator.generate(secret) + const verify = await request(apiBase, cookieJar, '/admin/auth/2fa/verify', { + method: 'POST', + body: JSON.stringify({ code }), + }) + + if (!verify.response.ok) { + throw new Error(verify.json?.message || 'Failed to verify generated 2FA code.') + } + + const result = verify.json?.data ?? verify.json + console.log(`Admin 2FA enabled for ${email}.`) + console.log(`TOTP secret: ${secret}`) + console.log('Add this secret to your authenticator app. The generated QR code data URL is available from /admin/auth/2fa/setup if you prefer scanning.') + + if (Array.isArray(result?.recoveryCodes) && result.recoveryCodes.length > 0) { + console.log('\nRecovery codes:') + for (const recoveryCode of result.recoveryCodes) console.log(`- ${recoveryCode}`) + } +} + +main().catch((error) => { + console.error(error.message) + process.exit(1) +})