import type { Handler } from 'hono'; import { getUserById, updateUser, OWNER_USER_ID } from 'officerdb'; import argon2 from 'argon2'; import * as errors from '@@/custom-errors'; import { validatePassword } from '../auth/validate-password'; // The owner resetting somebody else's platform password. // // Named for what it is, and NOT to be confused with `api/auth/reset-password.ts`, which is the // token-from-an-email flow the person runs themselves. This one is the owner acting on another account, // and the only thing the two share is the column they write. // // ── Why the platform generates it rather than the owner typing one ── // // Because the failure this exists to fix is "I created the account and forgot to copy the password down". // An owner-typed reset can lose it exactly the same way on the second attempt. A generated one is shown // once, in a dialog built to be copied, and is never recoverable afterwards — which is the honest state, // since only the argon2 hash is stored. // // ── The Linux account is deliberately untouched ── // // A member's Linux account has no password and never had one: `ensureOsUser` runs `useradd` with no `-p`, // so it is created password-locked. Nothing needs one — their terminal runs through `setpriv`, which does // not authenticate; their SSH access is the key the owner pasted; `su - ` as root does not ask. // And machine-setup sets `PasswordAuthentication no`, so a Linux password could not be used to log in even // if it existed. Setting one here would be creating a new way in, not repairing a lost one. /** * Characters the generated password is built from. * * Split by class because `validatePassword` requires ALL FOUR — twelve characters, a lowercase, an * uppercase, a digit and a special. A uniform draw over the union satisfies that only with high * probability, and "the reset button fails once a month" is a worse bug than it looks, because it happens * to somebody who is already locked out. * * The specials are a subset of the class `validatePassword` accepts, chosen to survive being copied, * pasted, quoted in a shell and read aloud: no quotes, no backslash, no backtick. */ const CLASSES = ['abcdefghijkmnopqrstuvwxyz', 'ABCDEFGHJKLMNPQRSTUVWXYZ', '23456789', '!@#$%^&*()-_=+'] as const; const PASSWORD_LENGTH = 20; /** Uniform over `alphabet` by rejection sampling — `% n` on a byte biases the first characters. */ function pick(alphabet: string): string { const max = 256 - (256 % alphabet.length); const byte = new Uint8Array(1); for (;;) { crypto.getRandomValues(byte); if (byte[0]! < max) return alphabet[byte[0]! % alphabet.length]!; } } /** * A password that satisfies `validatePassword` by construction, not by luck. * * One character from each class first, the remainder from the union, then shuffled — otherwise the first * four positions would always be lower, upper, digit, special, which is a pattern worth not having. * * Fisher-Yates with the same rejection sampling, for the same reason. * * `l`, `I`, `1`, `O` and `0` are absent from the alphabets above. This gets read off a screen and typed * somewhere else often enough that the ambiguous pairs are worth losing. */ export function generatePassword(length = PASSWORD_LENGTH): string { const union = CLASSES.join(''); const chars = CLASSES.map((cls) => pick(cls)); while (chars.length < length) chars.push(pick(union)); for (let i = chars.length - 1; i > 0; i--) { const bound = i + 1; const max = 256 - (256 % bound); const byte = new Uint8Array(1); let j: number; do { crypto.getRandomValues(byte); } while (byte[0]! >= max); j = byte[0]! % bound; [chars[i], chars[j]] = [chars[j]!, chars[i]!]; } const password = chars.join(''); // Generated then CHECKED against the same validator every other path uses. If the rules ever gain a // requirement the alphabets above do not cover, this throws here — at the one call site, loudly — rather // than minting passwords the login form would go on to reject. validatePassword(password); return password; } export const resetUserPasswordHandler: Handler = async function (ctx) { const id = Number(ctx.req.param('id')); if (!Number.isInteger(id) || id < 1) throw errors.BAD_REQUEST('Invalid user id'); // The owner has `PUT /auth/change-password`, which asks for the current one. Resetting themselves from // here would sign them out of the session they are doing it from, for no gain. if (id === OWNER_USER_ID) { throw errors.BAD_REQUEST('Change your own password from Settings, which asks for the current one.'); } const existing = await getUserById(id); if (!existing) throw errors.NOT_FOUND('User not found'); const password = generatePassword(); // `passwordChangedAt: new Date()` is what signs them out everywhere. `userMiddleware` refuses any token // whose `iat` predates it, so every session they have — browser, phone, an agent holding a token — // stops at the next request. That is the point: a reset happens because the old password was lost or // leaked, and leaving live sessions behind would defeat both reasons. // // It also overwrites the null that `create-user.ts` leaves to mean "the owner chose this, not them". // Nothing reads that; the only consumer of this column is the token check above. await updateUser(id, { password: await argon2.hash(password), passwordChangedAt: new Date() }); // Returned exactly once. Only the hash is stored, so there is no second chance to fetch it and the UI // says so — which is the whole reason it is generated rather than typed. return ctx.json({ email: existing.email, password }); };