61 lines
2.3 KiB
TypeScript
61 lines
2.3 KiB
TypeScript
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
import { Pool } from "pg";
|
|
|
|
const LIFETIME_MS = 24 * 60 * 60 * 1000;
|
|
|
|
// Standalone so operators can recover an account without running the server.
|
|
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 client = await pool.connect();
|
|
try {
|
|
await client.query("BEGIN");
|
|
// Same user-row lock as the web issuance and redemption endpoints.
|
|
const found = await client.query<{ id: string; name: string }>(
|
|
"SELECT id, name FROM users WHERE lower(email) = $1 FOR UPDATE", [email],
|
|
);
|
|
const user = found.rows[0];
|
|
if (!user) {
|
|
await client.query("ROLLBACK");
|
|
console.error(`No account with the email ${email}.`);
|
|
return 1;
|
|
}
|
|
|
|
const token = randomBytes(32).toString("base64url");
|
|
const tokenHash = createHash("sha256").update(token).digest("hex");
|
|
const expiresAt = new Date(Date.now() + LIFETIME_MS);
|
|
|
|
await client.query("DELETE FROM recovery_links WHERE user_id = $1", [user.id]);
|
|
await client.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 client.query("COMMIT");
|
|
|
|
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;
|
|
} catch (error) {
|
|
await client.query("ROLLBACK");
|
|
console.error("Failed to create recovery link:", error);
|
|
return 1;
|
|
} finally {
|
|
client.release();
|
|
await pool.end();
|
|
}
|
|
};
|
|
|
|
process.exit(await main());
|