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:
@@ -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,
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user