2026-10-12 11:48:59 +03:30
|
|
|
import { ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common';
|
2026-09-22 01:20:00 +03:30
|
|
|
import { PassportStrategy } from '@nestjs/passport';
|
2026-09-23 01:05:36 +03:30
|
|
|
import { AdminRole } from '@prisma/client';
|
2026-10-12 11:48:59 +03:30
|
|
|
import type { Request } from 'express';
|
2026-09-22 01:20:00 +03:30
|
|
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
|
|
|
|
import { PrismaService } from '../../prisma/prisma.service';
|
|
|
|
|
import { customerJwtSecret } from './customer-auth.service';
|
2026-09-23 01:05:36 +03:30
|
|
|
import {
|
2026-10-12 11:48:59 +03:30
|
|
|
ACT_AS_CUSTOMER_HEADER,
|
2026-09-23 01:05:36 +03:30
|
|
|
CUSTOMER_TOKEN_TYPE,
|
|
|
|
|
EXPERT_TOKEN_TYPE,
|
|
|
|
|
type AppJwtPayload,
|
|
|
|
|
type AuthenticatedCustomer,
|
|
|
|
|
} from './app-principal';
|
2026-09-25 19:04:48 +03:30
|
|
|
import { AppSessionService } from '../sessions/app-session.service';
|
2026-09-23 01:05:36 +03:30
|
|
|
|
|
|
|
|
export {
|
2026-10-12 11:48:59 +03:30
|
|
|
ACT_AS_CUSTOMER_HEADER,
|
2026-09-23 01:05:36 +03:30
|
|
|
CUSTOMER_TOKEN_TYPE,
|
|
|
|
|
EXPERT_TOKEN_TYPE,
|
2026-10-12 11:48:59 +03:30
|
|
|
isActingAsCustomer,
|
2026-09-23 01:05:36 +03:30
|
|
|
type AuthenticatedCustomer,
|
|
|
|
|
} from './app-principal';
|
|
|
|
|
|
|
|
|
|
/** Kept for the existing imports; the payload now covers both principals. */
|
|
|
|
|
export type CustomerJwtPayload = AppJwtPayload;
|
2026-09-22 01:20:00 +03:30
|
|
|
|
|
|
|
|
/**
|
2026-09-23 01:05:36 +03:30
|
|
|
* The app principal, kept entirely separate from JwtStrategy (which reads
|
|
|
|
|
* AdminUser for the admin PANEL). The reference had the same split: `auth:api`
|
|
|
|
|
* for admins and `auth('api-customer')` for the app.
|
|
|
|
|
*
|
|
|
|
|
* This strategy accepts TWO token types and resolves both to one shape:
|
|
|
|
|
*
|
|
|
|
|
* typ 'customer' -> the Customer row named by `sub`
|
|
|
|
|
* typ 'sales-expert' -> the AdminUser named by `sub`, plus their trading
|
|
|
|
|
* Customer row, which becomes `id`
|
|
|
|
|
*
|
|
|
|
|
* Because `id` is always a Customer id, every route already written for a
|
|
|
|
|
* customer works unchanged for an expert — which is exactly the reference's
|
|
|
|
|
* behaviour, where an agent was a customer with extra screens.
|
|
|
|
|
*
|
|
|
|
|
* An expert's app token is NOT an admin-panel token: its `typ` is
|
|
|
|
|
* 'sales-expert', while JwtStrategy requires the admin type, so it is refused
|
|
|
|
|
* on every admin route.
|
2026-09-22 01:20:00 +03:30
|
|
|
*/
|
|
|
|
|
@Injectable()
|
|
|
|
|
export class CustomerJwtStrategy extends PassportStrategy(Strategy, 'customer-jwt') {
|
2026-09-25 19:04:48 +03:30
|
|
|
constructor(
|
|
|
|
|
private readonly prisma: PrismaService,
|
|
|
|
|
private readonly sessions: AppSessionService,
|
|
|
|
|
) {
|
2026-09-22 01:20:00 +03:30
|
|
|
super({
|
|
|
|
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
|
|
|
|
ignoreExpiration: false,
|
|
|
|
|
secretOrKey: customerJwtSecret(),
|
2026-10-12 11:48:59 +03:30
|
|
|
// Needed only to read the act-as header; the token itself is still the
|
|
|
|
|
// only thing that establishes WHO is calling.
|
|
|
|
|
passReqToCallback: true,
|
2026-09-22 01:20:00 +03:30
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-12 11:48:59 +03:30
|
|
|
async validate(req: Request, payload: AppJwtPayload): Promise<AuthenticatedCustomer> {
|
2026-09-25 19:04:48 +03:30
|
|
|
// نشست باطلشده (خروج سراسری از IdP یا ابطال محلی). در حالت خاموشِ ردیابی،
|
|
|
|
|
// بدون هیچ کوئری true برمیگردد ⇒ هزینه و رفتار دقیقاً مثل قبل.
|
|
|
|
|
if (!(await this.sessions.isActive(payload?.jti))) {
|
|
|
|
|
throw new UnauthorizedException();
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-23 01:05:36 +03:30
|
|
|
if (payload?.typ === EXPERT_TOKEN_TYPE) {
|
2026-10-12 11:48:59 +03:30
|
|
|
return this.validateExpert(payload, this.actAsHeader(req));
|
2026-09-23 01:05:36 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A token that is not explicitly a customer token is refused, whatever
|
|
|
|
|
// else it carries.
|
2026-09-22 01:20:00 +03:30
|
|
|
if (payload?.typ !== CUSTOMER_TOKEN_TYPE) {
|
|
|
|
|
throw new UnauthorizedException();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Re-read on every request, so deactivating or archiving a customer takes
|
|
|
|
|
// effect immediately instead of at token expiry.
|
|
|
|
|
const customer = await this.prisma.customer.findUnique({
|
|
|
|
|
where: { id: payload.sub },
|
|
|
|
|
select: { id: true, mobile: true, isActive: true, isArchived: true },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!customer || !customer.isActive || customer.isArchived) {
|
|
|
|
|
throw new UnauthorizedException();
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-23 01:05:36 +03:30
|
|
|
return { id: customer.id, mobile: customer.mobile, type: CUSTOMER_TOKEN_TYPE };
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-12 11:48:59 +03:30
|
|
|
/**
|
|
|
|
|
* The act-as header, or null. Deliberately read ONLY for an expert token: on
|
|
|
|
|
* a customer's token the header has no effect at all, rather than being an
|
|
|
|
|
* error — a customer cannot grant themselves anything by sending it.
|
|
|
|
|
*/
|
|
|
|
|
private actAsHeader(req: Request): string | null {
|
|
|
|
|
const raw = (req?.headers as Record<string, unknown> | undefined)?.[ACT_AS_CUSTOMER_HEADER];
|
|
|
|
|
const value = Array.isArray(raw) ? raw[0] : raw;
|
|
|
|
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-23 01:05:36 +03:30
|
|
|
/**
|
|
|
|
|
* A sales expert. `sub` is their AdminUser id, so the role and active flag
|
|
|
|
|
* are re-checked on every request: demoting an expert or deactivating them
|
|
|
|
|
* locks them out immediately rather than at token expiry.
|
|
|
|
|
*
|
|
|
|
|
* The trading Customer row is resolved here rather than stored in the token,
|
|
|
|
|
* so a token issued before that row existed keeps working.
|
2026-10-12 11:48:59 +03:30
|
|
|
*
|
|
|
|
|
* ACTING AS A CUSTOMER («ورود به حساب مشتری», owner request 2026-10-12)
|
|
|
|
|
*
|
|
|
|
|
* With `actAs` set, the returned principal's `id` is that customer, so every
|
|
|
|
|
* customer route — orders, me, ledger, report, favourites, documents — serves
|
|
|
|
|
* the customer's own data with no change to any route. That is the whole
|
|
|
|
|
* feature, and it is why this is the only place the behaviour lives.
|
|
|
|
|
*
|
|
|
|
|
* How this differs from the reference, which did the same thing by handing the
|
|
|
|
|
* expert the customer's own `api_token` (ApiMobileController.php:197-201):
|
|
|
|
|
*
|
|
|
|
|
* - no token is minted, so nothing durable leaks into a device's storage
|
|
|
|
|
* and ending the app session ends the impersonation;
|
|
|
|
|
* - ownership is re-checked on EVERY request, so moving a customer to
|
|
|
|
|
* another expert cuts access off at once instead of at token expiry;
|
|
|
|
|
* - `adminUserId` survives, so the real actor is auditable — in the
|
|
|
|
|
* reference the request was indistinguishable from the customer's own.
|
2026-09-23 01:05:36 +03:30
|
|
|
*/
|
2026-10-12 11:48:59 +03:30
|
|
|
private async validateExpert(
|
|
|
|
|
payload: AppJwtPayload,
|
|
|
|
|
actAs: string | null,
|
|
|
|
|
): Promise<AuthenticatedCustomer> {
|
2026-09-23 01:05:36 +03:30
|
|
|
const admin = await this.prisma.adminUser.findUnique({
|
|
|
|
|
where: { id: payload.sub },
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
mobile: true,
|
|
|
|
|
role: true,
|
|
|
|
|
isActive: true,
|
|
|
|
|
salesExpertProfile: {
|
|
|
|
|
select: {
|
|
|
|
|
tradingCustomerId: true,
|
|
|
|
|
tradingCustomer: { select: { id: true, isActive: true, isArchived: true } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!admin || !admin.isActive || admin.role !== AdminRole.SALES_EXPERT) {
|
|
|
|
|
throw new UnauthorizedException();
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-12 11:48:59 +03:30
|
|
|
if (actAs) {
|
|
|
|
|
// Scoped by referredByAgentId in the same query as the id, so another
|
|
|
|
|
// expert's customer and a non-existent id are the same answer: no row,
|
|
|
|
|
// 403. The lookup cannot be talked out of its scope.
|
|
|
|
|
const customer = await this.prisma.customer.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
id: actAs,
|
|
|
|
|
referredByAgentId: admin.id,
|
|
|
|
|
isActive: true,
|
|
|
|
|
isArchived: false,
|
|
|
|
|
},
|
|
|
|
|
select: { id: true, mobile: true },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!customer) {
|
|
|
|
|
// Not 401: the expert's own session is perfectly valid, it is this
|
|
|
|
|
// particular impersonation that is refused — so the app can clear the
|
|
|
|
|
// act-as state instead of logging them out.
|
|
|
|
|
throw new ForbiddenException('دسترسی به این مشتری مجاز نیست');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: customer.id,
|
|
|
|
|
mobile: customer.mobile,
|
|
|
|
|
type: EXPERT_TOKEN_TYPE,
|
|
|
|
|
adminUserId: admin.id,
|
|
|
|
|
actingAsCustomerId: customer.id,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-23 01:05:36 +03:30
|
|
|
const trading = admin.salesExpertProfile?.tradingCustomer;
|
|
|
|
|
if (!trading || !trading.isActive || trading.isArchived) {
|
|
|
|
|
// The account is created at sign-in; if it is missing or has been
|
|
|
|
|
// deactivated, the session cannot act. Refused rather than silently
|
|
|
|
|
// degraded, so an expert never places an order against nothing.
|
|
|
|
|
throw new UnauthorizedException();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: trading.id,
|
|
|
|
|
mobile: admin.mobile,
|
|
|
|
|
type: EXPERT_TOKEN_TYPE,
|
|
|
|
|
adminUserId: admin.id,
|
|
|
|
|
};
|
2026-09-22 01:20:00 +03:30
|
|
|
}
|
|
|
|
|
}
|