Files
cloud-host/backend/src/auth/auth.service.ts
T
keyhan fd38f5659f 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>
2026-06-18 00:49:02 +03:30

251 lines
8.2 KiB
TypeScript

import {
Injectable,
UnauthorizedException,
ConflictException,
BadRequestException,
NotFoundException,
ForbiddenException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcrypt';
import { UsersService } from '../users/users.service';
import { VerificationService } from '../users/verification.service';
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, 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 {
requiresVerification: true;
phone: string; // masked
}
@Injectable()
export class AuthService {
constructor(
private usersService: UsersService,
private verificationService: VerificationService,
private jwtService: JwtService,
private configService: ConfigService,
) {}
/**
* Create an account from a mobile number. Email is optional contact info only.
* The account starts unverified; a LOGIN OTP is sent and must be confirmed via
* `verifyOtp` before tokens are issued.
*/
async register(registerDto: RegisterDto): Promise<VerificationRequired> {
const phone = normalizeIranMobile(registerDto.phone);
if (!phone) {
throw new BadRequestException('Invalid mobile number');
}
// An unverified account means a previous signup was never confirmed — let
// the user resume it. Only a *verified* phone is a real duplicate.
const existing = await this.usersService.findByPhone(phone);
if (existing?.phoneVerified) {
throw new ConflictException('Mobile number already registered');
}
const email = registerDto.email?.trim().toLowerCase() || null;
if (email) {
const emailOwner = await this.usersService.findByEmail(email);
if (emailOwner && emailOwner.id !== existing?.id) {
throw new ConflictException('Email already in use');
}
}
const hashedPassword = await bcrypt.hash(registerDto.password, 12);
// Resume the stale record (overwriting name/password/email) instead of
// locking the number behind it, or create a fresh account.
const user = existing
? await this.usersService.update(existing.id, {
email,
firstName: registerDto.firstName,
lastName: registerDto.lastName,
password: hashedPassword,
})
: await this.usersService.create({
phone,
email,
firstName: registerDto.firstName,
lastName: registerDto.lastName,
password: hashedPassword,
phoneVerified: false,
});
const { destination } = await this.verificationService.issueLoginOtp(
user,
OtpMessageKind.REGISTER,
);
return { requiresVerification: true, phone: destination };
}
/**
* Password login by mobile. If the phone isn't verified yet, an OTP is sent and
* verification is required to finish.
*/
async login(loginDto: LoginDto) {
const phone = normalizeIranMobile(loginDto.phone);
if (!phone) {
throw new UnauthorizedException('Invalid credentials');
}
const user = await this.usersService.findByPhone(phone);
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
const isPasswordValid = await bcrypt.compare(loginDto.password, user.password);
if (!isPasswordValid) {
throw new UnauthorizedException('Invalid credentials');
}
if (!user.isActive) {
throw new UnauthorizedException('Account is deactivated');
}
if (!user.phoneVerified) {
// Account exists but never finished signup verification — treat as
// registration completion (welcome wording), not a passwordless login.
const { destination } = await this.verificationService.issueLoginOtp(
user,
OtpMessageKind.REGISTER,
);
return { requiresVerification: true, phone: destination } as VerificationRequired;
}
const tokens = await this.generateTokens(user);
return { user: this.publicUser(user), ...tokens };
}
/**
* Passwordless login: send a one-time code to a registered phone. Returns a
* generic response regardless of whether the phone exists (no enumeration).
*/
async requestOtp(rawPhone: string) {
const phone = normalizeIranMobile(rawPhone);
if (phone) {
const user = await this.usersService.findByPhone(phone);
if (user && user.isActive) {
await this.verificationService.issueLoginOtp(user);
}
}
return { sent: true };
}
/** Verify a LOGIN OTP (registration completion or passwordless login). */
async verifyOtp(rawPhone: string, code: string) {
const phone = normalizeIranMobile(rawPhone);
if (!phone) {
throw new BadRequestException('Invalid mobile number');
}
const user = await this.usersService.findByPhone(phone);
if (!user || !user.isActive) {
throw new UnauthorizedException('Invalid credentials');
}
await this.verificationService.verifyLoginOtp(user, code);
const fresh = (await this.usersService.findById(user.id)) ?? user;
const tokens = await this.generateTokens(fresh);
return { user: this.publicUser(fresh), ...tokens };
}
async refreshToken(refreshToken: string) {
try {
const payload = this.jwtService.verify(refreshToken, {
secret: this.configService.get('jwt.refreshSecret'),
});
const user = await this.usersService.findById(payload.sub);
if (!user || !user.isActive) {
throw new UnauthorizedException();
}
// 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,
phone: user.phone,
email: user.email,
phoneVerified: user.phoneVerified,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
};
}
private async generateTokens(user: User, act?: ActClaim) {
// `email` may be null; use phone as the human-readable identity claim.
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, { expiresIn: accessExpiresIn }),
this.jwtService.signAsync(payload, {
secret: this.configService.get('jwt.refreshSecret'),
expiresIn: refreshExpiresIn,
}),
]);
return { accessToken, refreshToken };
}
}