mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
- lock the resolved account and rotate its password in one transaction - revoke sessions, tokens, trusted devices, and elevations with rollback safety
148 lines
5.0 KiB
JavaScript
148 lines
5.0 KiB
JavaScript
/**
|
|
* scripts/reset-password.mjs — operator-only CLI to reset a single user's
|
|
* password from inside the running container.
|
|
*
|
|
* WHY plain `.mjs` and not the `.ts` scripts pattern: the production standalone
|
|
* image strips `tsx` and ships the Prisma client only as bundled TypeScript
|
|
* inside `server.js` (not importable from a standalone process). This script
|
|
* therefore talks to Postgres through `pg` (present in the image) and hashes
|
|
* with `@node-rs/argon2` (also present), reusing the ONE shared Argon2id param
|
|
* object that the app's `hashPassword()` uses, so the hash it writes is
|
|
* byte-compatible with a hash the app would mint.
|
|
*
|
|
* Usage (inside the container — operator only):
|
|
* docker compose exec app node scripts/reset-password.mjs <username-or-email> [new-password]
|
|
*
|
|
* If the password is omitted it is read from stdin without echoing. The
|
|
* password is never printed back. Exits non-zero with a clear message when the
|
|
* identifier matches zero or more than one user.
|
|
*
|
|
* Reads `DATABASE_URL` from the environment (the same var the app uses). The
|
|
* connection honours any `sslmode=` in the URL exactly like the app pool.
|
|
*/
|
|
|
|
import { createInterface } from "node:readline";
|
|
import { hash } from "@node-rs/argon2";
|
|
import pg from "pg";
|
|
import { ARGON2_HASH_OPTIONS } from "../src/lib/auth/argon2-params.mjs";
|
|
|
|
function fail(message) {
|
|
console.error(`reset-password: ${message}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
/** Read a line from stdin without echoing it to the terminal. */
|
|
function promptHidden(label) {
|
|
return new Promise((resolve) => {
|
|
process.stdout.write(label);
|
|
const rl = createInterface({
|
|
input: process.stdin,
|
|
output: process.stdout,
|
|
});
|
|
// Override the line-writer so typed characters are never echoed. The
|
|
// prompt label above is already written; the closing newline is emitted
|
|
// when the answer resolves.
|
|
rl._writeToOutput = () => {};
|
|
rl.question("", (answer) => {
|
|
rl.close();
|
|
process.stdout.write("\n");
|
|
resolve(answer);
|
|
});
|
|
});
|
|
}
|
|
|
|
async function main() {
|
|
const identifier = process.argv[2];
|
|
if (!identifier) {
|
|
fail(
|
|
"usage: node scripts/reset-password.mjs <username-or-email> [new-password]",
|
|
);
|
|
}
|
|
|
|
const databaseUrl = process.env.DATABASE_URL;
|
|
if (!databaseUrl) {
|
|
fail("DATABASE_URL must be set");
|
|
}
|
|
|
|
let newPassword = process.argv[3];
|
|
if (!newPassword) {
|
|
newPassword = await promptHidden("New password (input hidden): ");
|
|
}
|
|
if (!newPassword || newPassword.length < 12) {
|
|
fail("new password must be at least 12 characters");
|
|
}
|
|
|
|
const client = new pg.Client({ connectionString: databaseUrl });
|
|
await client.connect();
|
|
try {
|
|
await client.query("BEGIN");
|
|
try {
|
|
// Case-insensitive match on username OR email, mirroring the login
|
|
// lookup. Lock every match until commit so identity resolution and the
|
|
// credential-family revocation are one serialized account operation.
|
|
const found = await client.query(
|
|
`SELECT id, username FROM users
|
|
WHERE lower(username) = lower($1) OR lower(email) = lower($1)
|
|
FOR UPDATE`,
|
|
[identifier],
|
|
);
|
|
|
|
if (found.rowCount === 0) {
|
|
throw new Error(`no user matches "${identifier}"`);
|
|
}
|
|
if (found.rowCount > 1) {
|
|
throw new Error(
|
|
`"${identifier}" matches ${found.rowCount} users — refusing to guess; reset by exact username`,
|
|
);
|
|
}
|
|
|
|
const user = found.rows[0];
|
|
const passwordHash = await hash(newPassword, ARGON2_HASH_OPTIONS);
|
|
|
|
await client.query(
|
|
`UPDATE users
|
|
SET password_hash = $1, updated_at = now()
|
|
WHERE id = $2`,
|
|
[passwordHash, user.id],
|
|
);
|
|
const elevations = await client.query(
|
|
`DELETE FROM step_up_elevations WHERE user_id = $1`,
|
|
[user.id],
|
|
);
|
|
const apiTokens = await client.query(
|
|
`UPDATE api_tokens SET revoked = true WHERE user_id = $1`,
|
|
[user.id],
|
|
);
|
|
const refreshTokens = await client.query(
|
|
`UPDATE refresh_tokens SET revoked_at = now() WHERE user_id = $1`,
|
|
[user.id],
|
|
);
|
|
const trustedDevices = await client.query(
|
|
`DELETE FROM trusted_devices WHERE user_id = $1`,
|
|
[user.id],
|
|
);
|
|
const sessions = await client.query(
|
|
`DELETE FROM sessions WHERE user_id = $1`,
|
|
[user.id],
|
|
);
|
|
|
|
await client.query("COMMIT");
|
|
|
|
// Never echo the password or credential material. Counts are safe
|
|
// operator confirmation that every credential family was addressed.
|
|
console.log(
|
|
`reset-password: password updated for "${user.username}"; revoked ${sessions.rowCount} sessions, ${apiTokens.rowCount} API tokens, ${refreshTokens.rowCount} refresh tokens, ${trustedDevices.rowCount} trusted devices, and ${elevations.rowCount} step-up elevations`,
|
|
);
|
|
} catch (error) {
|
|
await client.query("ROLLBACK").catch(() => {});
|
|
throw error;
|
|
}
|
|
} finally {
|
|
await client.end();
|
|
}
|
|
}
|
|
|
|
main().catch((err) => {
|
|
fail(err instanceof Error ? err.message : String(err));
|
|
});
|