feat(admin): login-as-user impersonation with audit log

Let super admins act as a user from the user detail dashboard for
support/debugging ("full with guardrails", audit-only).

Backend: AuthService.impersonate issues a short-lived token for the
target carrying an `act` claim (acting admin); refresh preserves it and
JwtStrategy surfaces `impersonatedBy`. Guardrails: cannot impersonate an
admin or a deactivated account; new ImpersonationGuard blocks sensitive
self-service (change own password/phone) while impersonating. New
AuditLog entity records impersonation start/stop (admin, target, ip,
time); admin endpoints POST users/:id/impersonate + .../impersonation/
stop and GET users/:id/audit.

Frontend: lib/impersonation swaps admin/impersonation tokens in
localStorage; persistent banner with exit; "Login as user" button and an
"Admin access log" tab on the detail page; logout clears impersonation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
keyhan
2026-06-18 00:49:02 +03:30
parent 7958d2fa72
commit fd38f5659f
17 changed files with 532 additions and 27 deletions
+52 -6
View File
@@ -3,6 +3,8 @@ import {
UnauthorizedException,
ConflictException,
BadRequestException,
NotFoundException,
ForbiddenException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
@@ -13,7 +15,13 @@ import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { User } from '../users/entities/user.entity';
import { normalizeIranMobile } from '../common/phone.util';
import { OtpMessageKind } from '../common/enums';
import { OtpMessageKind, UserRole } from '../common/enums';
/** The acting admin recorded inside an impersonation token (RFC-8693-style "act"). */
export interface ActClaim {
sub: string; // admin user id
role: string; // admin role at impersonation time
}
/** Returned when an action needs phone verification before tokens are issued. */
export interface VerificationRequired {
@@ -165,12 +173,42 @@ export class AuthService {
throw new UnauthorizedException();
}
return this.generateTokens(user);
// Preserve the impersonation context across refreshes so an admin's
// "login as user" session survives token rotation.
return this.generateTokens(user, payload.act);
} catch {
throw new UnauthorizedException('Invalid refresh token');
}
}
/**
* Issue tokens that authenticate as `targetUserId` while recording the acting
* admin in an `act` claim. Guardrails: the target must exist, be active, and
* not be an admin (no admin-on-admin impersonation). Tokens are short-lived.
*/
async impersonate(admin: { id: string; role: string }, targetUserId: string) {
const target = await this.usersService.findById(targetUserId);
if (!target) {
throw new NotFoundException('User not found');
}
if (!target.isActive) {
throw new BadRequestException('Cannot impersonate a deactivated account');
}
if (target.role === UserRole.ADMIN) {
throw new ForbiddenException('Cannot impersonate an administrator');
}
const tokens = await this.generateTokens(target, {
sub: admin.id,
role: admin.role,
});
return {
user: this.publicUser(target),
...tokens,
impersonation: { by: admin.id, at: new Date().toISOString() },
};
}
private publicUser(user: User) {
return {
id: user.id,
@@ -183,19 +221,27 @@ export class AuthService {
};
}
private async generateTokens(user: User) {
private async generateTokens(user: User, act?: ActClaim) {
// `email` may be null; use phone as the human-readable identity claim.
const payload = {
const payload: Record<string, unknown> = {
sub: user.id,
email: user.email ?? user.phone,
role: user.role,
};
// Impersonation sessions carry the acting admin and are deliberately short-lived.
if (act) {
payload.act = act;
}
const accessExpiresIn = act ? '30m' : this.configService.get('jwt.expiresIn');
const refreshExpiresIn = act
? '30m'
: this.configService.get('jwt.refreshExpiresIn');
const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(payload),
this.jwtService.signAsync(payload, { expiresIn: accessExpiresIn }),
this.jwtService.signAsync(payload, {
secret: this.configService.get('jwt.refreshSecret'),
expiresIn: this.configService.get('jwt.refreshExpiresIn'),
expiresIn: refreshExpiresIn,
}),
]);
@@ -7,6 +7,8 @@ interface JwtPayload {
sub: string;
email: string;
role: string;
/** Present on impersonation tokens: the acting admin. */
act?: { sub: string; role: string };
}
@Injectable()
@@ -24,6 +26,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
id: payload.sub,
email: payload.email,
role: payload.role,
// Non-null only while an admin is impersonating this user.
impersonatedBy: payload.act?.sub ?? null,
impersonatorRole: payload.act?.role ?? null,
};
}
}