the support case this exists for is "it does not work for them and it does for me". reproducing that with a password reset means locking somebody out of their own account in order to look at it, which is worse than the bug. THE TOKEN SAYS WHO IS REALLY HOLDING IT. `impersonatedBy` rides on the claim and reaches every handler, because user-middleware puts the whole payload on the context. without it an impersonated action is indistinguishable from that person's own — in a log, in a transcript, in anything keyed on a user id — and a support tool that can forge activity as somebody else is a different and much worse thing than a support tool. ONE HOUR, not thirty days. a session lasts a month because a person uses their own account daily; impersonation is a task you finish. an hour is long enough to reproduce a problem and short enough that a forgotten tab stops being a live credential for somebody else's account by lunchtime. IT CANNOT CHAIN, and that is the guard that matters. `ownerGate` refuses any token carrying `impersonatedBy`, so impersonating another Super Admin does NOT hand user management back — which would have made this a way to launder owner actions through somebody else's id. stated twice, in the gate and in the handler, because one file away is too far for this one. THE WAY BACK IS A STASHED TOKEN, not a route. nothing on the server mints an owner session — a far more dangerous thing to own than this whole feature. the cost is that clearing browser storage strands you as the other person until the hour expires, and signing in again fixes that. the banner is loud and sits ABOVE the header, outside everything a fullscreen panel can hide. the failure it prevents is not a hole, it is forgetting: reading a mailbox or deleting something while believing it is yours. it asks the server who it is rather than decoding the token, so it cannot keep showing a name after the hour has run out. verified end to end against a throwaway account, since this host has one user: impersonate 200 with the target's id and impersonatedBy naming the owner, 60 minute lifetime, /auth/me answering as them; self 400, unknown user 404, chaining 403, user management while impersonating 403; one [SECURITY] line per start. the probe account was deleted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
71 lines
3.7 KiB
TypeScript
71 lines
3.7 KiB
TypeScript
import type { Handler } from 'hono';
|
|
import { getUserById } from 'officerdb';
|
|
import { sign } from '@@/jwt';
|
|
import * as errors from '@@/custom-errors';
|
|
import { clientIp } from '../auth/client-ip';
|
|
|
|
/**
|
|
* Sign in AS another account, from user management.
|
|
*
|
|
* The support tool this is for: "it does not work for them and it does for me". Reproducing that with a
|
|
* password reset means locking the person out of their own account to look at it, which is worse than
|
|
* the bug.
|
|
*
|
|
* ── The token says whose it is, and who is really holding it ──
|
|
*
|
|
* `impersonatedBy` is on the token and reaches every handler, because `user-middleware` puts the whole
|
|
* payload on the context. Without it an impersonated action is indistinguishable from the person's own
|
|
* — in a log, in a chat transcript, in anything keyed on the user id — and a support tool that can
|
|
* forge activity as somebody else is a different and much worse thing.
|
|
*
|
|
* ── One hour, not thirty days ──
|
|
*
|
|
* A session token lasts a month because a person uses their own account daily. Impersonation is a task
|
|
* you finish. An hour is long enough to reproduce a problem and short enough that a forgotten tab stops
|
|
* being a live credential for somebody else's account by lunchtime.
|
|
*
|
|
* ── What it cannot do ──
|
|
*
|
|
* It cannot chain: `ownerGate` refuses a request whose token carries `impersonatedBy`, so an
|
|
* impersonated session cannot impersonate a third account, and — the case that matters — impersonating
|
|
* another Super Admin does not hand back user management. Otherwise this route would be a way to launder
|
|
* owner actions through somebody else's id.
|
|
*
|
|
* It also cannot outlive the account: `user-middleware` re-reads the row on every request and refuses a
|
|
* deleted or non-Active user, exactly as it does for an ordinary session.
|
|
*
|
|
* ── What it deliberately does NOT do ──
|
|
*
|
|
* It does not touch the owner's own token. Returning to yourself is the client putting back what it
|
|
* kept, so there is no server state to leak, expire or get wrong — and closing the tab is not a way to
|
|
* be stranded as somebody else.
|
|
*/
|
|
export const impersonateUserHandler: Handler = async (ctx) => {
|
|
const actor = ctx.get('user') as { id: number; email?: string; impersonatedBy?: unknown };
|
|
|
|
// Belt to `ownerGate`'s braces: that gate already refuses an impersonated token, and this is the route
|
|
// where being wrong about it is worst, so it is stated here too rather than assumed one file away.
|
|
if (actor.impersonatedBy) throw errors.FORBIDDEN('An impersonated session cannot impersonate');
|
|
|
|
const targetId = Number(ctx.req.param('id'));
|
|
if (!Number.isInteger(targetId) || targetId <= 0) throw errors.BAD_REQUEST('A user id is required');
|
|
if (targetId === actor.id) throw errors.BAD_REQUEST('You are already yourself');
|
|
|
|
const target = await getUserById(targetId);
|
|
if (!target) throw errors.NOT_FOUND('No such user');
|
|
// The same refusal sign-in makes. Impersonating a blocked account would be a way to use it.
|
|
if (target.status !== 'Active') throw errors.FORBIDDEN(`That account is ${target.status}`);
|
|
|
|
const { id, email, name, username } = target;
|
|
const token = await sign({ id, email, name, username, impersonatedBy: { id: actor.id, email: actor.email } }, '1h');
|
|
|
|
// Same channel and shape as the distress revoke, so both land in one place when somebody asks who did
|
|
// what. Names both parties: the id in every downstream record from here on will be the TARGET's.
|
|
console.warn(
|
|
`[SECURITY] impersonation started — actor=${actor.email ?? actor.id}, target=${email ?? id}, ` +
|
|
`ip=${clientIp(ctx)} — ${new Date().toISOString()}`,
|
|
);
|
|
|
|
return ctx.json({ token, user: { id, email, name, username }, expiresInMinutes: 60 });
|
|
};
|