66 lines
2.4 KiB
TypeScript
66 lines
2.4 KiB
TypeScript
/**
|
|
* Prints a one-time recovery link for an account. Meant for whoever runs the
|
|
* server when nobody can sign in as an admin anymore:
|
|
*
|
|
* bun run recover <email>
|
|
*
|
|
* It only talks to the database, the server does not have to be running. The
|
|
* link works like the ones admins make under Settings → Users: it lets the
|
|
* account set a new password, deletes its saved API keys, turns two-factor
|
|
* off and signs every device out.
|
|
*
|
|
* Kept free of imports from the app so it also runs in the production image.
|
|
* The link has to be made the same way as in server/utils/recovery.ts.
|
|
*/
|
|
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
import { Pool } from 'pg';
|
|
|
|
const LIFETIME_MS = 24 * 60 * 60 * 1000;
|
|
|
|
const main = async (): Promise<number> => {
|
|
const email = process.argv[2]?.trim().toLowerCase();
|
|
|
|
if (!email) {
|
|
console.error('Usage: bun run recover <email>');
|
|
return 1;
|
|
}
|
|
|
|
const pool = new Pool({
|
|
connectionString: process.env.DATABASE_URL || 'postgres://postgres:password@localhost:5432/veridian',
|
|
});
|
|
|
|
const found = await pool.query('SELECT id, name FROM users WHERE lower(email) = $1', [email]);
|
|
const user = found.rows[0] as { id: string; name: string } | undefined;
|
|
|
|
if (!user) {
|
|
console.error(`No account with the email ${email}.`);
|
|
await pool.end();
|
|
return 1;
|
|
}
|
|
|
|
const token = randomBytes(32).toString('base64url');
|
|
const tokenHash = createHash('sha256').update(token).digest('hex');
|
|
const expiresAt = new Date(Date.now() + LIFETIME_MS);
|
|
|
|
// only one link works at a time
|
|
await pool.query('DELETE FROM recovery_links WHERE user_id = $1', [user.id]);
|
|
await pool.query(
|
|
'INSERT INTO recovery_links (id, token_hash, user_id, reset_security, expires_at) VALUES ($1, $2, $3, true, $4)',
|
|
[randomUUID(), tokenHash, user.id, expiresAt],
|
|
);
|
|
await pool.end();
|
|
|
|
const baseUrl = (process.env.NUXT_PUBLIC_URL ?? 'http://localhost:3000').replace(/\/$/, '');
|
|
|
|
console.log(`Recovery link for ${user.name} <${email}>:`);
|
|
console.log('');
|
|
console.log(` ${baseUrl}/auth/recover/${token}`);
|
|
console.log('');
|
|
console.log('It works once and expires in 24 hours. Using it deletes the saved API keys');
|
|
console.log('of this account, turns two-factor off and signs every device out.');
|
|
|
|
return 0;
|
|
};
|
|
|
|
process.exit(await main());
|