enroll admin 2fa

This commit is contained in:
root
2026-06-25 01:22:39 -04:00
parent 3b142ca4c7
commit 81b24080ac
2 changed files with 168 additions and 0 deletions
+1
View File
@@ -51,6 +51,7 @@
"db:migrate": "turbo db:migrate", "db:migrate": "turbo db:migrate",
"db:seed": "turbo db:seed", "db:seed": "turbo db:seed",
"db:seed:admin": "npm run db:seed --workspace @rentaldrivego/database", "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", "db:studio": "cd packages/database && npx prisma studio",
"test:api": "npm run test --workspace @rentaldrivego/api", "test:api": "npm run test --workspace @rentaldrivego/api",
"test:api:integration": "npm run test:integration --workspace @rentaldrivego/api", "test:api:integration": "npm run test:integration --workspace @rentaldrivego/api",
+167
View File
@@ -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)
})