Harden dashboard password reset flow

---
Build: pass | Tests: FAIL — 26 failed
This commit is contained in:
Operator & Codex 2026-05-03 20:45:48 +02:00
parent 8414953776
commit 5c685f1285
5 changed files with 209 additions and 25 deletions

View file

@ -21,26 +21,24 @@ describe('operator-auth args', () => {
});
describe('operator-auth env updates', () => {
it('writes controlplane email and operator password when absent', () => {
it('writes controlplane email when absent', () => {
const updated = updateOperatorAuthEnvContent(
'ASSISTANT_NAME=Clawdie\n',
'operator@example.com',
'secret-pass',
);
expect(updated).toContain('CONTROLPLANE_OPERATOR_EMAIL=operator@example.com');
expect(updated).toContain('OPERATOR_PASSWORD=secret-pass');
expect(updated).not.toContain('OPERATOR_PASSWORD=');
});
it('replaces existing controlplane email and operator password', () => {
it('replaces existing controlplane email and removes operator password', () => {
const updated = updateOperatorAuthEnvContent(
'CONTROLPLANE_OPERATOR_EMAIL=old@example.com\nOPERATOR_PASSWORD=old\n',
'operator@example.com',
'secret-pass',
);
expect(updated).toContain('CONTROLPLANE_OPERATOR_EMAIL=operator@example.com');
expect(updated).toContain('OPERATOR_PASSWORD=secret-pass');
expect(updated).not.toContain('OPERATOR_PASSWORD=');
expect(updated).not.toContain('old@example.com');
});
});

View file

@ -117,7 +117,8 @@ export async function run(args: string[]): Promise<void> {
console.log(` Name: ${result.operatorName}`);
console.log(` Email: ${result.operatorEmail}`);
console.log(` Better Auth: ${result.betterAuthStatus}`);
console.log(' Saved CONTROLPLANE_OPERATOR_EMAIL and OPERATOR_PASSWORD to .env');
console.log(' Saved CONTROLPLANE_OPERATOR_EMAIL to .env');
console.log(' Password was not written to disk; store it yourself.');
} catch (error) {
logger.error({ err: error }, 'operator-auth setup failed');
throw error;

View file

@ -29,7 +29,6 @@ function isValidEmail(value: string): boolean {
export function updateOperatorAuthEnvContent(
envContent: string,
operatorEmail: string,
operatorPassword: string,
): string {
const lines = envContent ? envContent.replace(/\r\n/gu, '\n').split('\n') : [];
@ -45,23 +44,21 @@ export function updateOperatorAuthEnvContent(
}
setLine('CONTROLPLANE_OPERATOR_EMAIL', operatorEmail);
setLine('OPERATOR_PASSWORD', operatorPassword);
return `${lines.filter(Boolean).join('\n')}\n`;
return `${lines
.filter((line) => line && !line.startsWith('OPERATOR_PASSWORD='))
.join('\n')}\n`;
}
function writeOperatorAuthEnv(
envFile: string,
operatorEmail: string,
operatorPassword: string,
): void {
const current = fs.existsSync(envFile) ? fs.readFileSync(envFile, 'utf-8') : '';
const updated = updateOperatorAuthEnvContent(
current,
operatorEmail,
operatorPassword,
);
fs.writeFileSync(envFile, updated, { mode: 0o600 });
const updated = updateOperatorAuthEnvContent(current, operatorEmail);
const tempFile = `${envFile}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tempFile, updated, { mode: 0o600 });
fs.renameSync(tempFile, envFile);
fs.chmodSync(envFile, 0o600);
}
export interface ResetOperatorAuthResult {
@ -128,7 +125,7 @@ export async function resetOperatorAuthCredentials(opts: {
: betterAuthStatus;
const envFile = path.join(process.cwd(), '.env');
writeOperatorAuthEnv(envFile, operatorEmail, opts.operatorPassword);
writeOperatorAuthEnv(envFile, operatorEmail);
return {
operatorName,

View file

@ -230,6 +230,145 @@ async function issueNewDashboardPassword(): Promise<{
};
}
const NEWPASS_PRIVATE_TTL_MS = 60_000;
const NEWPASS_COOLDOWN_MS = 60_000;
const recentNewPassByAdmin = new Map<number, number>();
function schedulePrivateTelegramDelete(
ctxArg: TelegramCommandContext,
chatId: number,
messageId: number,
delayMs = NEWPASS_PRIVATE_TTL_MS,
): void {
const timer = setTimeout(() => {
void ctxArg.api.deleteMessage(chatId, messageId).catch((err) => {
logger.debug(
{ err, chatId, messageId },
'Failed to delete sensitive Telegram password message',
);
});
}, delayMs);
timer.unref?.();
}
async function notifyNewPassCooldown(
ctxArg: TelegramCommandContext,
remainingMs: number,
): Promise<void> {
const seconds = Math.max(1, Math.ceil(remainingMs / 1000));
const message = `Dashboard password reset is cooling down. Try again in about ${seconds}s.`;
try {
if (ctxArg.callbackQuery) {
await ctxArg.answerCallbackQuery({ text: message, show_alert: true });
return;
}
} catch {
// fall through to chat reply
}
await ctxArg.reply(message);
}
async function deliverNewDashboardPasswordPrivately(
ctxArg: TelegramCommandContext,
chatJid: string,
): Promise<{
email: string;
betterAuthStatus: 'created' | 'existing' | 'reset';
} | null> {
const requesterId = ctxArg.from?.id;
if (!requesterId) {
await ctxArg.reply('Could not determine which admin requested the reset.');
return null;
}
const now = Date.now();
const lastIssuedAt = recentNewPassByAdmin.get(requesterId) || 0;
const remainingMs = lastIssuedAt + NEWPASS_COOLDOWN_MS - now;
if (remainingMs > 0) {
await notifyNewPassCooldown(ctxArg, remainingMs);
return null;
}
const operatorEmail = resolveOperatorEmailForReset();
let dmMessage: { message_id: number } | null = null;
try {
dmMessage = await ctxArg.api.sendMessage(
requesterId,
`Preparing a temporary dashboard password for <code>${escapeHtml(
operatorEmail,
)}</code>.`,
{
parse_mode: 'HTML',
link_preview_options: { is_disabled: true },
},
);
} catch (err) {
logger.warn(
{ err, requesterId, chatJid, operatorEmail },
'Dashboard password reset private delivery unavailable',
);
await ctxArg.reply(
'I could not send you a private Telegram message. Open a private chat with the bot first, then retry /newpass. No password was changed.',
);
return null;
}
let result: Awaited<ReturnType<typeof issueNewDashboardPassword>>;
try {
result = await issueNewDashboardPassword();
} catch (err) {
logger.error(
{ err, requesterId, chatJid, operatorEmail },
'Dashboard password reset failed before private delivery',
);
try {
await ctxArg.api.editMessageText(
requesterId,
dmMessage.message_id,
'Dashboard password reset failed. No credential was sent. If login fails, use the host-side recovery command.',
);
} catch {
// best-effort only
}
await ctxArg.reply(
'Dashboard password reset failed before the temporary password could be delivered.',
);
return null;
}
const privateMessage = [
'Dashboard password reset.',
`Email: <code>${escapeHtml(result.email)}</code>`,
`Temporary password: <code>${escapeHtml(result.password)}</code>`,
'This message will be deleted in 1 minute. Log in and change the password immediately.',
].join('\n');
let sensitiveMessageId = dmMessage.message_id;
try {
await ctxArg.api.editMessageText(requesterId, dmMessage.message_id, privateMessage, {
parse_mode: 'HTML',
link_preview_options: { is_disabled: true },
});
} catch {
const sent = await ctxArg.api.sendMessage(requesterId, privateMessage, {
parse_mode: 'HTML',
link_preview_options: { is_disabled: true },
});
sensitiveMessageId = sent.message_id;
}
schedulePrivateTelegramDelete(ctxArg, requesterId, sensitiveMessageId);
recentNewPassByAdmin.set(requesterId, now);
logger.info(
{ requesterId, chatJid, operatorEmail: result.email, betterAuthStatus: result.betterAuthStatus },
'Dashboard password reset issued via Telegram',
);
return {
email: result.email,
betterAuthStatus: result.betterAuthStatus,
};
}
export function buildStatusRuntimeLines(opts: {
@ -1059,12 +1198,13 @@ export async function handleNewPassCommand(
const text = (ctxArg.message?.text || '').trim();
const action = text.split(/\s+/)[1]?.toLowerCase();
if (action === 'confirm' || action === 'yes') {
const result = await issueNewDashboardPassword();
const result = await deliverNewDashboardPasswordPrivately(ctxArg, chatJid);
if (!result) return;
await ctxArg.reply(
[
'Dashboard password reset.',
`Email: <code>${escapeHtml(result.email)}</code>`,
`Temporary password: <code>${escapeHtml(result.password)}</code>`,
'I sent the temporary password to your private Telegram chat and will delete it there after 1 minute.',
'Log in and change it immediately.',
].join('\n'),
{
@ -1109,11 +1249,19 @@ export async function handleNewPassCallback(
return;
}
const result = await issueNewDashboardPassword();
const result = await deliverNewDashboardPasswordPrivately(ctxArg, chatJid);
if (!result) {
try {
await ctxArg.answerCallbackQuery();
} catch {
// best-effort only
}
return;
}
const message = [
'Dashboard password reset.',
`Email: <code>${escapeHtml(result.email)}</code>`,
`Temporary password: <code>${escapeHtml(result.password)}</code>`,
'I sent the temporary password to your private Telegram chat and will delete it there after 1 minute.',
'Log in and change it immediately.',
].join('\n');
try {
@ -1127,7 +1275,7 @@ export async function handleNewPassCallback(
link_preview_options: { is_disabled: true },
});
}
await ctxArg.answerCallbackQuery({ text: 'Dashboard password reset' });
await ctxArg.answerCallbackQuery({ text: 'Dashboard password reset sent privately' });
}
// ── /publishreport ───────────────────────────────────────────────────────

View file

@ -81,10 +81,16 @@ describe('/newpass command', () => {
}));
const replies: Array<{ text: string; opts?: any }> = [];
const privateDmApi = {
sendMessage: vi.fn(async () => ({ message_id: 77 })),
editMessageText: vi.fn(async () => ({})),
deleteMessage: vi.fn(async () => ({})),
};
const ctxArg = {
from: { id: 123 },
chat: { id: 999 },
message: { text: '/newpass confirm' },
api: privateDmApi,
reply: async (text: string, opts?: unknown) => {
replies.push({ text, opts });
},
@ -106,11 +112,45 @@ describe('/newpass command', () => {
expect(replies).toHaveLength(1);
expect(replies[0]?.text).toContain('Dashboard password reset.');
expect(replies[0]?.text).toContain('Email: <code>hello@clawdie.si</code>');
expect(replies[0]?.text).toContain('Temporary password: <code>');
expect(replies[0]?.text).toContain('private Telegram chat');
expect(replies[0]?.text).not.toContain('Temporary password: <code>');
expect(replies[0]?.text).toContain('Log in and change it immediately.');
expect(replies[0]?.opts).toEqual({
parse_mode: 'HTML',
link_preview_options: { is_disabled: true },
});
expect(privateDmApi.sendMessage).toHaveBeenCalledTimes(1);
expect(privateDmApi.editMessageText).toHaveBeenCalledTimes(1);
expect(privateDmApi.editMessageText.mock.calls[0]?.[2]).toContain(
'Temporary password: <code>',
);
});
it('aborts before reset when private delivery is unavailable', async () => {
process.env.TELEGRAM_ADMIN_IDS = '123';
process.env.TELEGRAM_OPS_CHAT_ID = 'tg:999';
readEnvFile.mockReturnValue({ CONTROLPLANE_OPERATOR_EMAIL: 'hello@clawdie.si' });
const replies: Array<{ text: string; opts?: any }> = [];
const ctxArg = {
from: { id: 123 },
chat: { id: 999 },
message: { text: '/newpass confirm' },
api: {
sendMessage: vi.fn(async () => {
throw new Error('forbidden');
}),
},
reply: async (text: string, opts?: unknown) => {
replies.push({ text, opts });
},
};
const { handleNewPassCommand } = await import('./telegram-commands.js');
await handleNewPassCommand(ctxArg, 'tg:999');
expect(resetOperatorAuthCredentials).not.toHaveBeenCalled();
expect(replies).toHaveLength(1);
expect(replies[0]?.text).toContain('Open a private chat with the bot first');
});
});