enroll admin 2fa
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user