diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/UsersSection.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/UsersSection.tsx index c6fff754..076d1f65 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/UsersSection.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/UsersSection.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; -import { Crown, Trash2, Loader2, KeyRound, SquareTerminal as TerminalIcon } from 'lucide-react'; +import { Crown, Trash2, Loader2, KeyRound, RotateCcw, Copy, SquareTerminal as TerminalIcon } from 'lucide-react'; import { useClient } from 'hooks/useClient'; import { Button } from '@/components/ui/button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; @@ -47,6 +47,9 @@ export const UsersSection = () => { const queryClient = useQueryClient(); const [pendingId, setPendingId] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null); + const [confirmReset, setConfirmReset] = useState(null); + /** The one and only sighting of a generated password. Cleared when the dialog closes, and gone for good. */ + const [newPassword, setNewPassword] = useState<{ email: string; password: string } | null>(null); const { data, isLoading, isError } = useQuery({ queryKey: USERS_KEY, @@ -106,6 +109,27 @@ export const UsersSection = () => { } }; + /** + * A new platform password, generated by the server and shown once. + * + * Generated rather than typed because the failure this exists for is "I forgot to copy it down", and an + * owner typing a replacement can lose it the same way twice. Only the argon2 hash is stored, so the + * dialog below really is the only time anyone sees it — which is why it is a dialog and not a toast. + */ + const resetPassword = async (user: ManagedUser) => { + setPendingId(user.id); + setConfirmReset(null); + try { + const result = await client.post<{ email: string; password: string }>(`/users/${user.id}/password`, {}); + setNewPassword(result); + await queryClient.invalidateQueries({ queryKey: USERS_KEY }); + } catch (ex) { + toast.error(ex instanceof Error ? ex.message : 'Could not reset the password'); + } finally { + setPendingId(null); + } + }; + const remove = async (user: ManagedUser) => { setPendingId(user.id); setConfirmDelete(null); @@ -218,6 +242,22 @@ export const UsersSection = () => { )} + {/* The owner is excluded because they have change-password, which asks for the current one — + and resetting themselves from here would sign them out of the session doing it. */} + {!user.isOwner && ( + + )} + + + + + setNewPassword(null)}>Done + + + ); }; diff --git a/src/servers/api/users/reset-user-password.ts b/src/servers/api/users/reset-user-password.ts new file mode 100644 index 00000000..9e7f020b --- /dev/null +++ b/src/servers/api/users/reset-user-password.ts @@ -0,0 +1,121 @@ +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 }); +}; diff --git a/src/servers/api/users/users-router.ts b/src/servers/api/users/users-router.ts index 5cda5ad9..ba0114a4 100644 --- a/src/servers/api/users/users-router.ts +++ b/src/servers/api/users/users-router.ts @@ -7,6 +7,7 @@ import { updateUserHandler } from './update-user'; import { listUsersHandler, updateUserRoleHandler, deleteUserHandler } from './manage-users'; import { createUserHandler } from './create-user'; import { provisionLinuxHandler } from './provision-linux-route'; +import { resetUserPasswordHandler } from './reset-user-password'; import { capabilityAdminRouter } from './capabilities-routes'; export const usersRouter = createRouter(); @@ -31,6 +32,9 @@ usersRouter.get('/', ownerGate, listUsersHandler); usersRouter.post('/', ownerGate, createUserHandler); usersRouter.patch('/:id/role', ownerGate, updateUserRoleHandler); usersRouter.post('/:id/provision-linux', ownerGate, provisionLinuxHandler); +// Generates a new platform password, returns it once, and signs the account out everywhere. Not the same +// thing as auth/reset-password.ts, which is the token flow the person runs on themselves. +usersRouter.post('/:id/password', ownerGate, resetUserPasswordHandler); usersRouter.delete('/:id', ownerGate, deleteUserHandler); // Which capabilities each role holds. Owner-gated inside its own router.