Add admin-assisted password reset

There's no email infrastructure in this app, so a self-service
"forgot password" flow isn't feasible yet. Adds a "Reset Password"
button per user in the admin panel instead: generates a random
temporary password (shown once, for the admin to relay out-of-band),
overwrites the user's password hash, and signs them out of every
existing session so a stolen session can't outlive the reset.

Verified live: old password rejected after reset, new temporary
password logs in successfully.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-07-06 14:31:06 -04:00
parent f853d5e301
commit 9e57f8088a
5 changed files with 91 additions and 5 deletions
+5
View File
@@ -87,3 +87,8 @@ export async function consumeBackupCode(submitted, hashes) {
}
return null;
}
/** A random temporary password for admin-assisted resets — shown once, relayed to the user out-of-band. */
export function generateTemporaryPassword() {
return randomBytes(6).toString('hex');
}
+26
View File
@@ -298,6 +298,11 @@ export function adminDeleteProject(id) {
db.prepare('DELETE FROM projects WHERE id = ?').run(id);
}
/** Overwrites a user's password hash directly — used for admin-assisted password resets. */
export function adminSetPassword(userId, passwordHash) {
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(passwordHash, userId);
}
// ---------------------------------------------------------------------------
// Session store backing (used by server/sessionStore.js)
// ---------------------------------------------------------------------------
@@ -327,3 +332,24 @@ export function destroySession(sid) {
export function pruneExpiredSessions() {
db.prepare('DELETE FROM sessions WHERE expires < ?').run(Date.now());
}
/**
* Logs a user out everywhere by deleting every session that belongs to them.
* Sessions don't have an indexed user_id column (they're just an opaque JSON
* blob to express-session), so this scans and parses — fine at this app's scale.
*/
export function destroyAllSessionsForUser(userId) {
const rows = db.prepare('SELECT sid, sess FROM sessions').all();
const staleSids = rows
.filter((row) => {
try {
return JSON.parse(row.sess)?.userId === userId;
} catch {
return false;
}
})
.map((row) => row.sid);
if (staleSids.length === 0) return;
const placeholders = staleSids.map(() => '?').join(',');
db.prepare(`DELETE FROM sessions WHERE sid IN (${placeholders})`).run(...staleSids);
}
+17 -1
View File
@@ -10,12 +10,13 @@ import {
enableTotp, disableTotp, setBackupCodeHashes, setPodcastName,
getShareToken, setShareToken, clearShareToken, getProjectByShareToken,
adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject,
adminSetPassword, destroyAllSessionsForUser,
} from './db.js';
import { SqliteSessionStore } from './sessionStore.js';
import {
isValidEmail, isValidPassword, hashPassword, verifyPassword, requireAuth, requireAdmin, isAdminEmail,
generateTotpSecret, totpKeyUri, verifyTotpToken,
generateBackupCodes, hashBackupCodes, consumeBackupCode,
generateBackupCodes, hashBackupCodes, consumeBackupCode, generateTemporaryPassword,
} from './auth.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -389,6 +390,21 @@ app.delete('/api/admin/users/:id', requireAuth, requireAdmin, (req, res) => {
}
});
// POST /api/admin/users/:id/reset-password — sets a random temporary password and
// signs the user out everywhere, since there's no self-service "forgot password" flow yet.
app.post('/api/admin/users/:id/reset-password', requireAuth, requireAdmin, async (req, res) => {
try {
const temporaryPassword = generateTemporaryPassword();
const passwordHash = await hashPassword(temporaryPassword);
adminSetPassword(req.params.id, passwordHash);
destroyAllSessionsForUser(req.params.id);
res.json({ temporaryPassword });
} catch (err) {
console.error('POST /api/admin/users/:id/reset-password error:', err);
res.status(500).json({ error: 'Failed to reset password.' });
}
});
app.get('/api/admin/projects', requireAuth, requireAdmin, (req, res) => {
try {
res.json(adminGetAllProjects());