Harden dashboard password reset flow
--- Build: pass | Tests: FAIL — 26 failed
This commit is contained in:
parent
8414953776
commit
5c685f1285
5 changed files with 209 additions and 25 deletions
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue