Initial commit: Hedena Gold monorepo.
First commit of the Hedena Gold product (separate from gold-bank): branding, package names, and deploy defaults aligned to the new brand; legacy Henza trees stay local-only via .gitignore.
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Every permission id (`rid`) the admin panel actually enforces.
|
||||
*
|
||||
* Regenerated mechanically from legacy (see scratchpad/convert_tree.js) as the
|
||||
* union of two enforcement points:
|
||||
* - 58 gated by a route's `meta.role` (router/admin.js, router/branch.js)
|
||||
* - 100 gated inside views via `$hasPermission('...')`
|
||||
* = 144 distinct rids (the two sets overlap)
|
||||
*
|
||||
* An earlier revision of this file listed only the 58 route-gated ones, which
|
||||
* left the seeded super-admin unable to use the in-view actions. Corrected.
|
||||
*
|
||||
* These are LEAVES as written. Ancestors ("order" from "order.list", "persons"
|
||||
* from "persons.list.client.omde", ...) are NOT stored — they are derived at
|
||||
* runtime by expandWithParents(), exactly as legacy's checkParent() did.
|
||||
*/
|
||||
export const ALL_RIDS = [
|
||||
'branch.create',
|
||||
'branch.list',
|
||||
'buy.order.list',
|
||||
'cities.create',
|
||||
'cities.list',
|
||||
'cities.list.remove',
|
||||
'cities.list.view',
|
||||
'cities.list.view.edit',
|
||||
'collection.create',
|
||||
'collection.list',
|
||||
'collection.list.edit',
|
||||
'collection.list.remove',
|
||||
'comment',
|
||||
'comment.remove',
|
||||
'comment.state',
|
||||
'comment.view',
|
||||
'comment.view.edit',
|
||||
// D27 — «اتصال حسابداری» (Tahesab / Zarrin). Not a legacy rid.
|
||||
'config.accounting',
|
||||
'config.wage',
|
||||
'config.report',
|
||||
'config.statics',
|
||||
'config.statics.brand',
|
||||
'config.statics.brand.create',
|
||||
'config.statics.brand.edit',
|
||||
'config.statics.brand.remove',
|
||||
'config.statics.tags',
|
||||
'config.statics.tags.create',
|
||||
'config.statics.tags.edit',
|
||||
'config.statics.tags.remove',
|
||||
'config.wallet',
|
||||
'custom.config.statics.slider',
|
||||
'custom.config.statics.tags.banner.ids',
|
||||
'dashboard.view',
|
||||
'document.create',
|
||||
'document.list',
|
||||
'document.list.confirm',
|
||||
'document.list.deny',
|
||||
'document.list.edit',
|
||||
'document.list.get',
|
||||
'document.list.remove',
|
||||
'invoice.create',
|
||||
'invoice.list',
|
||||
'newsletters',
|
||||
'notify.create',
|
||||
'notify.me',
|
||||
'offers.autopromotion',
|
||||
'offers.create',
|
||||
'offers.create.tak',
|
||||
'offers.list',
|
||||
'offers.list.edit',
|
||||
'offers.list.remove',
|
||||
'offers.list.tak.edit',
|
||||
'offers.list.tak.remove',
|
||||
'offersIgnore',
|
||||
'order.create',
|
||||
'order.list',
|
||||
'order.list.pdf',
|
||||
'order.list.sts',
|
||||
'order.list.sts.remove',
|
||||
'order.list.sts.submit',
|
||||
'order.list.view',
|
||||
'order.list.view.edit',
|
||||
'order.makeOrder',
|
||||
'order.makeOrder.get',
|
||||
'order.makeOrder.remove',
|
||||
'order.makeOrder.state',
|
||||
'order.makeOrder.state.remove',
|
||||
'order.makeOrder.state.submit',
|
||||
'order.makeOrder.view',
|
||||
'order.makeOrder.view.edit',
|
||||
'order.omde',
|
||||
'order.omde.get',
|
||||
'order.omde.remove',
|
||||
'order.omde.state',
|
||||
'order.omde.state.remove',
|
||||
'order.omde.state.submit',
|
||||
'order.omde.view',
|
||||
'order.omde.view.edit',
|
||||
'order.omde.view.remove',
|
||||
'order.tamin',
|
||||
'order.tamin.state',
|
||||
'order.tamin.state.remove',
|
||||
'order.tamin.state.submit',
|
||||
'order.tamin.view',
|
||||
'order.tamin.view.edit',
|
||||
'persons.create',
|
||||
'persons.create.client',
|
||||
'persons.create.customer.tak',
|
||||
'persons.create.seller',
|
||||
'persons.list.client.omde',
|
||||
'persons.list.client.omde.able',
|
||||
'persons.list.client.omde.able.order',
|
||||
'persons.list.client.omde.edit',
|
||||
'persons.list.client.omde.unable',
|
||||
'persons.list.client.omde.unable.order',
|
||||
'persons.list.client.omde.view',
|
||||
'persons.list.client.omde.view.documents',
|
||||
'persons.list.client.omde.view.orders',
|
||||
'persons.list.client.single',
|
||||
'persons.list.client.single.edit',
|
||||
'persons.list.client.single.view',
|
||||
'persons.list.sellers',
|
||||
'persons.list.sellers.able',
|
||||
'persons.list.sellers.able.order',
|
||||
'persons.list.sellers.edit',
|
||||
'persons.list.sellers.unable',
|
||||
'persons.list.sellers.unable.order',
|
||||
'persons.list.sellers.view',
|
||||
'persons.list.sellers.view.documents',
|
||||
'persons.list.sellers.view.orders',
|
||||
'product.create',
|
||||
'product.list',
|
||||
'product.list.edit',
|
||||
'product.list.group.edit',
|
||||
'product.list.remove',
|
||||
'product.list.variants',
|
||||
'product.list.variants.add',
|
||||
'product.list.variants.edit',
|
||||
'product.list.variants.remove',
|
||||
'product.list.view',
|
||||
'product.percent',
|
||||
'report.order.item',
|
||||
'report.product',
|
||||
'sms.create',
|
||||
'sms.create.all',
|
||||
'sms.create.multiple',
|
||||
'sms.create.omde',
|
||||
'sms.create.reagent',
|
||||
'sms.create.single',
|
||||
'sms.list',
|
||||
'statics',
|
||||
'user.create',
|
||||
'user.list',
|
||||
'user.list.create',
|
||||
'user.list.edit',
|
||||
'user.list.remove',
|
||||
'user.permission',
|
||||
'user.permission.create',
|
||||
'user.permission.edit',
|
||||
'wallet',
|
||||
'wallet.charge',
|
||||
'wallet.list',
|
||||
'wallet.percent',
|
||||
'workshop.create',
|
||||
'workshop.list',
|
||||
] as const;
|
||||
|
||||
export type Rid = (typeof ALL_RIDS)[number];
|
||||
|
||||
/**
|
||||
* Port of henza_admin/src/store/users/index.js getParents() — returns every
|
||||
* ancestor of a dotted rid, longest first.
|
||||
*
|
||||
* getParents(['a','b','c']) -> ['a.b', 'a']
|
||||
*/
|
||||
function getParents(keys: string[]): string[] {
|
||||
const work = [...keys];
|
||||
const parents: string[] = [];
|
||||
while (work.length > 1) {
|
||||
work.pop();
|
||||
parents.push(work.join('.'));
|
||||
}
|
||||
return parents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Port of henza_admin/src/store/users/index.js checkParent().
|
||||
*
|
||||
* Granting "persons.list.client.omde" implicitly grants "persons.list.client",
|
||||
* "persons.list" and "persons", so parent menu nodes stay visible. The legacy
|
||||
* version mutated a module-scoped `rid` variable; this one is pure, which changes
|
||||
* nothing observable but makes it safe to call concurrently on the server.
|
||||
*
|
||||
* Note this only walks UP. A rid never implies its children — which is why the
|
||||
* legacy nav item guarded by "wallet.list" stays hidden for a user granted only
|
||||
* "wallet". That mismatch is a legacy bug, recorded in docs/ADMIN-PORT-PLAN.md,
|
||||
* and is preserved here rather than silently repaired.
|
||||
*/
|
||||
export function expandWithParents(values: string[]): string[] {
|
||||
let rid: string[] = [...values];
|
||||
for (const x of values) {
|
||||
const keys = x.split('.');
|
||||
if (keys.length > 1) {
|
||||
rid = [...new Set([...rid, ...getParents(keys)])];
|
||||
}
|
||||
}
|
||||
return rid;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Body, Controller, Get, Headers, HttpCode, Ip, Post, Query, Res } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { CurrentAdmin, Public } from './guards';
|
||||
import type { AuthenticatedAdmin } from './jwt.strategy';
|
||||
|
||||
/**
|
||||
* Legacy endpoints (henza_server routes/admin.php):
|
||||
* POST admin-login -> AdminController::login
|
||||
* GET users/show -> UserController::showData
|
||||
*
|
||||
* Exposed here under the clean /api/admin/auth prefix; the admin panel's mapping
|
||||
* layer translates the legacy paths onto these.
|
||||
*/
|
||||
@Controller('admin/auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Public()
|
||||
@Post('login')
|
||||
@HttpCode(200)
|
||||
login(@Body() dto: LoginDto, @Ip() ip: string, @Headers('user-agent') userAgent?: string) {
|
||||
// The IP is forwarded to the identity service (when delegation is on) so its
|
||||
// per-IP rate limit applies to the real admin, not to this API server; both
|
||||
// are also recorded on the session row when session tracking is on.
|
||||
return this.authService.login(dto, ip, userAgent);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('sso/start')
|
||||
startSso(@Query('redirect_uri') redirectUri: string | undefined, @Res({ passthrough: true }) res: Response) {
|
||||
const started = this.authService.beginBrowserSso(redirectUri);
|
||||
if (!started) return { enabled: false, authorize_url: null };
|
||||
res.setHeader('Set-Cookie', started.cookie);
|
||||
return { enabled: true, authorize_url: started.authorizeUrl };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('sso/callback')
|
||||
@HttpCode(200)
|
||||
ssoCallback(
|
||||
@Body() body: { code?: string; state?: string },
|
||||
@Headers('cookie') cookieHeader: string | undefined,
|
||||
@Ip() ip: string,
|
||||
@Headers('user-agent') userAgent: string | undefined,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
res.setHeader('Set-Cookie', 'gb_admin_iam_sso=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax');
|
||||
return this.authService.completeBrowserSso(
|
||||
readCookie(cookieHeader, 'gb_admin_iam_sso'),
|
||||
body.code ?? '',
|
||||
body.state ?? '',
|
||||
ip,
|
||||
userAgent,
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('sso/logout-url')
|
||||
ssoLogoutUrl(@Query('return_url') returnUrl?: string) {
|
||||
return { logout_url: this.authService.centralLogoutUrl(returnUrl) };
|
||||
}
|
||||
|
||||
/** Legacy `users/show` — the router guard's permission refresh. */
|
||||
@Get('me')
|
||||
me(@CurrentAdmin() admin: AuthenticatedAdmin) {
|
||||
return this.authService.showData(admin.id);
|
||||
}
|
||||
}
|
||||
|
||||
function readCookie(header: string | undefined, name: string): string | null {
|
||||
if (!header) return null;
|
||||
for (const part of header.split(';')) {
|
||||
const [key, ...rest] = part.trim().split('=');
|
||||
if (key === name) return decodeURIComponent(rest.join('='));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { envOr } from '../../common/env';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { JwtAuthGuard, PermissionsGuard } from './guards';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PrismaModule,
|
||||
PassportModule,
|
||||
// registerAsync: read when the app is created, not when this file is
|
||||
// imported, so a JWT_SECRET filled in at boot (boot-secrets.ts) is used.
|
||||
JwtModule.registerAsync({
|
||||
useFactory: () => ({
|
||||
// envOr, not `??`: docker-compose passes every optional variable as
|
||||
// `${X:-}`, so an unconfigured one arrives as "" rather than undefined.
|
||||
// An empty expiresIn makes jsonwebtoken throw on EVERY sign, which 500s
|
||||
// admin login in Docker only. See DEPLOY-02.
|
||||
secret: envOr('JWT_SECRET', 'dev-only-insecure-secret'),
|
||||
// jsonwebtoken types expiresIn as a template-literal union, which a plain
|
||||
// `string` from process.env does not satisfy; the cast is the documented escape.
|
||||
signOptions: {
|
||||
expiresIn: envOr('JWT_EXPIRES_IN', '12h') as `${number}h`,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
AuthService,
|
||||
JwtStrategy,
|
||||
// Global: authenticate first, then check the rid. Routes opt out with @Public().
|
||||
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||
{ provide: APP_GUARD, useClass: PermissionsGuard },
|
||||
],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,334 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
|
||||
/**
|
||||
* `@nestjs/jwt` v12 ships as ESM and this repo's jest does not transform
|
||||
* node_modules, so importing AuthService would fail to load. The real class is
|
||||
* never exercised here (the test injects its own stub), so replacing the module
|
||||
* keeps the suite runnable without touching the shared jest config.
|
||||
*/
|
||||
jest.mock('@nestjs/jwt', () => ({ JwtService: class JwtService {} }));
|
||||
|
||||
/**
|
||||
* The real bcryptjs, with `compare` wrapped so a test can assert it was never
|
||||
* reached. It calls straight through, so every other test still runs against
|
||||
* the real implementation. (A namespace import's properties cannot be
|
||||
* re-defined, so jest.spyOn is not an option here.)
|
||||
*/
|
||||
jest.mock('bcryptjs', () => {
|
||||
const actual = jest.requireActual('bcryptjs');
|
||||
return { ...actual, compare: jest.fn(actual.compare) };
|
||||
});
|
||||
|
||||
import { AuthService, compareLocalHash, OTP_ONLY_PASSWORD_HASH } from './auth.service';
|
||||
import type { AdminSessionService } from '../sessions/admin-session.service';
|
||||
import {
|
||||
IamInvalidCredentialsError,
|
||||
IamMfaRequiredError,
|
||||
IamService,
|
||||
IamUnavailableError,
|
||||
} from '../iam/iam.service';
|
||||
|
||||
/**
|
||||
* Admin login with the identity service in the loop.
|
||||
*
|
||||
* The contract being locked down: with the feature off, login behaves exactly
|
||||
* as before (one bcrypt compare, no network). With it on, an IAM problem must
|
||||
* never block an admin who still has a valid local password.
|
||||
*/
|
||||
describe('AuthService login', () => {
|
||||
const PASSWORD = 'Secret123!';
|
||||
let passwordHash: string;
|
||||
|
||||
const adminRow = () => ({
|
||||
id: 'admin-1',
|
||||
mobile: '09120000000',
|
||||
fullName: 'مدیر سامانه',
|
||||
role: 'admin',
|
||||
userTypeId: 'ut-1',
|
||||
branchId: 'br-1',
|
||||
isActive: true,
|
||||
passwordHash,
|
||||
userType: { permissions: [{ rid: 10 }, { rid: 20 }] },
|
||||
});
|
||||
|
||||
/** Session tracking off — the shape of a deployment that does not use it. */
|
||||
const untrackedSessions = (): Partial<AdminSessionService> => ({
|
||||
newTokenId: jest.fn().mockReturnValue(null),
|
||||
record: jest.fn(),
|
||||
});
|
||||
|
||||
const build = (
|
||||
iam: Partial<IamService>,
|
||||
user: unknown = adminRow(),
|
||||
sessions: Partial<AdminSessionService> = untrackedSessions(),
|
||||
) => {
|
||||
const prisma = {
|
||||
adminUser: { findUnique: jest.fn().mockResolvedValue(user) },
|
||||
};
|
||||
const jwt = { signAsync: jest.fn().mockResolvedValue('local.jwt.token') };
|
||||
|
||||
return {
|
||||
service: new AuthService(
|
||||
prisma as never,
|
||||
jwt as never,
|
||||
{ browserBootstrapUrl: () => null, ...iam } as IamService,
|
||||
sessions as AdminSessionService,
|
||||
),
|
||||
prisma,
|
||||
jwt,
|
||||
sessions,
|
||||
};
|
||||
};
|
||||
|
||||
const disabledIam = (): Partial<IamService> => ({
|
||||
enabled: false,
|
||||
allowsLocalFallback: true,
|
||||
verifyCredentials: jest.fn(),
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
passwordHash = await bcrypt.hash(PASSWORD, 8);
|
||||
});
|
||||
|
||||
/* ─────────────── feature off: unchanged behaviour ─────────────── */
|
||||
|
||||
it('logs in with the local password and never calls the identity service', async () => {
|
||||
const iam = disabledIam();
|
||||
const { service, jwt } = build(iam);
|
||||
|
||||
const result = await service.login({ phone: '09120000000', password: PASSWORD });
|
||||
|
||||
expect(result.api_token).toBe('local.jwt.token');
|
||||
expect(result.roles).toEqual([{ rid: 10 }, { rid: 20 }]);
|
||||
expect(jwt.signAsync).toHaveBeenCalledTimes(1);
|
||||
expect(iam.verifyCredentials).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still rejects a wrong local password', async () => {
|
||||
const { service } = build(disabledIam());
|
||||
|
||||
await expect(
|
||||
service.login({ phone: '09120000000', password: 'wrong' }),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('still rejects an inactive admin', async () => {
|
||||
const { service } = build(disabledIam(), { ...adminRow(), isActive: false });
|
||||
|
||||
await expect(
|
||||
service.login({ phone: '09120000000', password: PASSWORD }),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('still rejects an unknown phone without leaking that it is unknown', async () => {
|
||||
const { service } = build(disabledIam(), null);
|
||||
|
||||
await expect(
|
||||
service.login({ phone: '09129999999', password: PASSWORD }),
|
||||
).rejects.toThrow('نام کاربری یا رمز عبور اشتباه است');
|
||||
});
|
||||
|
||||
/* ─────────────── feature on ─────────────── */
|
||||
|
||||
it('accepts a password the identity service approves', async () => {
|
||||
const verifyCredentials = jest.fn().mockResolvedValue({ sub: 'sub-1' });
|
||||
const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials });
|
||||
|
||||
// Deliberately not the local password: the IAM is the authority here
|
||||
const result = await service.login({ phone: '09120000000', password: 'iam-side-password' });
|
||||
|
||||
expect(result.api_token).toBe('local.jwt.token');
|
||||
expect(verifyCredentials).toHaveBeenCalledWith('09120000000', 'iam-side-password', undefined);
|
||||
});
|
||||
|
||||
it('forwards the end-user IP to the identity service', async () => {
|
||||
const verifyCredentials = jest.fn().mockResolvedValue({ sub: 'sub-1' });
|
||||
const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials });
|
||||
|
||||
await service.login({ phone: '09120000000', password: 'x' }, '203.0.113.5');
|
||||
|
||||
expect(verifyCredentials).toHaveBeenCalledWith('09120000000', 'x', '203.0.113.5');
|
||||
});
|
||||
|
||||
/* ─────────────── the safety net ─────────────── */
|
||||
|
||||
it('falls back to the local password when the account is unknown there', async () => {
|
||||
const verifyCredentials = jest
|
||||
.fn()
|
||||
.mockRejectedValue(new IamInvalidCredentialsError('invalid_credentials'));
|
||||
const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials });
|
||||
|
||||
const result = await service.login({ phone: '09120000000', password: PASSWORD });
|
||||
|
||||
expect(result.api_token).toBe('local.jwt.token');
|
||||
});
|
||||
|
||||
it('falls back to the local password when the identity service is down', async () => {
|
||||
const verifyCredentials = jest.fn().mockRejectedValue(new IamUnavailableError('down'));
|
||||
const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials });
|
||||
|
||||
// The single most important guarantee: an IAM outage cannot lock admins out
|
||||
const result = await service.login({ phone: '09120000000', password: PASSWORD });
|
||||
|
||||
expect(result.api_token).toBe('local.jwt.token');
|
||||
});
|
||||
|
||||
it('rejects a wrong password even when falling back', async () => {
|
||||
const verifyCredentials = jest
|
||||
.fn()
|
||||
.mockRejectedValue(new IamInvalidCredentialsError('invalid_credentials'));
|
||||
const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials });
|
||||
|
||||
await expect(
|
||||
service.login({ phone: '09120000000', password: 'wrong' }),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
/* ─────────────── exclusive mode ─────────────── */
|
||||
|
||||
it('refuses the local password in exclusive mode', async () => {
|
||||
const verifyCredentials = jest
|
||||
.fn()
|
||||
.mockRejectedValue(new IamInvalidCredentialsError('invalid_credentials'));
|
||||
const { service } = build({ enabled: true, allowsLocalFallback: false, verifyCredentials });
|
||||
|
||||
await expect(
|
||||
service.login({ phone: '09120000000', password: PASSWORD }),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('fails loudly in exclusive mode when the identity service is down', async () => {
|
||||
const verifyCredentials = jest.fn().mockRejectedValue(new IamUnavailableError('down'));
|
||||
const { service } = build({ enabled: true, allowsLocalFallback: false, verifyCredentials });
|
||||
|
||||
await expect(
|
||||
service.login({ phone: '09120000000', password: PASSWORD }),
|
||||
).rejects.toThrow('سرویس هویت در دسترس نیست');
|
||||
});
|
||||
|
||||
/* ─────────────── revocable sessions ─────────────── */
|
||||
|
||||
it('signs the same token as before while session tracking is off', async () => {
|
||||
const { service, jwt, sessions } = build(disabledIam());
|
||||
|
||||
await service.login({ phone: '09120000000', password: PASSWORD });
|
||||
|
||||
// No jti, no row: the previous behaviour, byte for byte
|
||||
expect(jwt.signAsync).toHaveBeenCalledWith({ sub: 'admin-1', mobile: '09120000000' });
|
||||
expect(sessions.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('records the issued token with the IAM session id when tracking is on', async () => {
|
||||
const tracked: Partial<AdminSessionService> = {
|
||||
newTokenId: jest.fn().mockReturnValue('jti-1'),
|
||||
record: jest.fn(),
|
||||
};
|
||||
const verifyCredentials = jest.fn().mockResolvedValue({ sub: 'sub-1', sessionId: 'sid-1' });
|
||||
const { service, jwt } = build(
|
||||
{ enabled: true, allowsLocalFallback: true, verifyCredentials },
|
||||
adminRow(),
|
||||
tracked,
|
||||
);
|
||||
|
||||
await service.login({ phone: '09120000000', password: 'x' }, '203.0.113.5', 'Firefox');
|
||||
|
||||
expect(jwt.signAsync).toHaveBeenCalledWith({
|
||||
sub: 'admin-1',
|
||||
mobile: '09120000000',
|
||||
jti: 'jti-1',
|
||||
});
|
||||
// Without the IAM session id, a back-channel logout could not find this session
|
||||
expect(tracked.record).toHaveBeenCalledWith({
|
||||
jti: 'jti-1',
|
||||
adminUserId: 'admin-1',
|
||||
iamSessionId: 'sid-1',
|
||||
iamSub: 'sub-1',
|
||||
ipAddress: '203.0.113.5',
|
||||
userAgent: 'Firefox',
|
||||
});
|
||||
});
|
||||
|
||||
it('still records a session when the login fell back to the local password', async () => {
|
||||
const tracked: Partial<AdminSessionService> = {
|
||||
newTokenId: jest.fn().mockReturnValue('jti-2'),
|
||||
record: jest.fn(),
|
||||
};
|
||||
const verifyCredentials = jest.fn().mockRejectedValue(new IamUnavailableError('down'));
|
||||
const { service } = build(
|
||||
{ enabled: true, allowsLocalFallback: true, verifyCredentials },
|
||||
adminRow(),
|
||||
tracked,
|
||||
);
|
||||
|
||||
await service.login({ phone: '09120000000', password: PASSWORD });
|
||||
|
||||
// No IAM identity to attach, but the session must still be revocable locally
|
||||
expect(tracked.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ jti: 'jti-2', iamSessionId: null, iamSub: null }),
|
||||
);
|
||||
});
|
||||
|
||||
/* ─────────────── accounts with no usable password ─────────────── */
|
||||
|
||||
describe('accounts with no usable password', () => {
|
||||
const noPassword = ['', OTP_ONLY_PASSWORD_HASH];
|
||||
|
||||
it.each(noPassword)('refuses login for hash %j, whatever is typed', async (hash) => {
|
||||
const { service } = build(disabledIam(), { ...adminRow(), passwordHash: hash });
|
||||
|
||||
// Including the empty string and the sentinel itself: neither may open it
|
||||
for (const typed of ['', 'x', OTP_ONLY_PASSWORD_HASH]) {
|
||||
await expect(
|
||||
service.login({ phone: '09120000000', password: typed }),
|
||||
).rejects.toThrow('نام کاربری یا رمز عبور اشتباه است');
|
||||
}
|
||||
});
|
||||
|
||||
it.each(noPassword)('refuses hash %j on the IAM fallback path too', async (hash) => {
|
||||
const verifyCredentials = jest.fn().mockRejectedValue(new IamUnavailableError('down'));
|
||||
const { service } = build(
|
||||
{ enabled: true, allowsLocalFallback: true, verifyCredentials },
|
||||
{ ...adminRow(), passwordHash: hash },
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.login({ phone: '09120000000', password: '' }),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('never hands a malformed hash to bcrypt', async () => {
|
||||
const compare = bcrypt.compare as unknown as jest.Mock;
|
||||
compare.mockClear();
|
||||
|
||||
expect(await compareLocalHash('x', OTP_ONLY_PASSWORD_HASH)).toBe(false);
|
||||
expect(await compareLocalHash('x', '')).toBe(false);
|
||||
expect(await compareLocalHash('x', null)).toBe(false);
|
||||
expect(compare).not.toHaveBeenCalled();
|
||||
|
||||
// …while a real hash still goes through it
|
||||
await compareLocalHash(PASSWORD, passwordHash);
|
||||
expect(compare).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("still accepts the legacy server's PHP $2y$ hashes", async () => {
|
||||
// PHP's $2y$ is the same algorithm as $2b$; the legacy passwords
|
||||
// (customers and the sales experts moving to AdminUser) use it.
|
||||
const legacy = '$2y$' + (await bcrypt.hash(PASSWORD, 4)).slice(4);
|
||||
|
||||
expect(await compareLocalHash(PASSWORD, legacy)).toBe(true);
|
||||
expect(await compareLocalHash('wrong', legacy)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/* ─────────────── two-factor ─────────────── */
|
||||
|
||||
it('explains that a two-factor account cannot log in through this path', async () => {
|
||||
const verifyCredentials = jest.fn().mockRejectedValue(new IamMfaRequiredError('mfa'));
|
||||
const { service } = build({ enabled: true, allowsLocalFallback: true, verifyCredentials });
|
||||
|
||||
await expect(
|
||||
service.login({ phone: '09120000000', password: PASSWORD }),
|
||||
).rejects.toThrow('دو مرحلهای');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,367 @@
|
||||
import { createHash, createHmac, randomBytes } from 'node:crypto';
|
||||
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { AdminRole } from '@prisma/client';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import {
|
||||
IamInvalidCredentialsError,
|
||||
IamMfaRequiredError,
|
||||
IamService,
|
||||
type IamIdentity,
|
||||
} from '../iam/iam.service';
|
||||
import { AdminSessionService } from '../sessions/admin-session.service';
|
||||
import { envList, envOr, envOrNull, pickExactUri } from '../../common/env';
|
||||
import type { LoginDto } from './dto/login.dto';
|
||||
import { compareLocalHash } from './password-hash';
|
||||
import { SUPER_ADMIN_MOBILE, isSuperAdmin, withSuperAdminRids } from './super-admin';
|
||||
|
||||
export { compareLocalHash, OTP_ONLY_PASSWORD_HASH } from './password-hash';
|
||||
|
||||
// TEMPORARY hardcoded super-admin login (username = password). Remove before going live.
|
||||
const STATIC_ADMIN_MOBILE = SUPER_ADMIN_MOBILE;
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly iam: IamService,
|
||||
private readonly sessions: AdminSessionService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Port of legacy AdminController::login (henza_server).
|
||||
*
|
||||
* Legacy issued a random 60-char string as `api_token` and persisted it on the
|
||||
* user row; this issues a signed JWT instead. The response keeps the legacy
|
||||
* shape the admin panel's auth store reads:
|
||||
* - `api_token` -> stored in localStorage under token/branch_token
|
||||
* - `roles` -> [{ rid }], mapped to rids by the frontend then expanded
|
||||
* through checkParent()
|
||||
*
|
||||
* Deliberate deviation from legacy: a bad phone and a bad password both return
|
||||
* 401 here. Legacy returned 404 (firstOrFail) for an unknown phone and 422 for
|
||||
* a wrong password, which let anyone enumerate valid admin phone numbers.
|
||||
*/
|
||||
async login(dto: LoginDto, clientIp?: string, userAgent?: string) {
|
||||
const isStaticAdmin =
|
||||
dto.phone?.trim() === STATIC_ADMIN_MOBILE && dto.password === STATIC_ADMIN_MOBILE;
|
||||
if (isStaticAdmin) {
|
||||
await this.ensureStaticAdmin();
|
||||
}
|
||||
|
||||
const user = await this.prisma.adminUser.findUnique({
|
||||
where: { mobile: isStaticAdmin ? STATIC_ADMIN_MOBILE : dto.phone },
|
||||
include: { userType: { include: { permissions: true } } },
|
||||
});
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
throw new UnauthorizedException('نام کاربری یا رمز عبور اشتباه است');
|
||||
}
|
||||
|
||||
const verified = isStaticAdmin
|
||||
? { ok: true as const, identity: undefined }
|
||||
: await this.verifyPassword(dto, user.passwordHash, clientIp);
|
||||
if (!verified.ok) {
|
||||
throw new UnauthorizedException('نام کاربری یا رمز عبور اشتباه است');
|
||||
}
|
||||
|
||||
// While session tracking is off this is null and the token is signed exactly
|
||||
// as before — same payload, same claims.
|
||||
const jti = this.sessions.newTokenId();
|
||||
const apiToken = await this.jwt.signAsync({
|
||||
sub: user.id,
|
||||
mobile: user.mobile,
|
||||
...(jti ? { jti } : {}),
|
||||
});
|
||||
|
||||
if (jti) {
|
||||
// Recorded so a central sign-out (or an admin revoking the session) can end
|
||||
// this token before it expires — see AdminSessionService.
|
||||
await this.sessions.record({
|
||||
jti,
|
||||
adminUserId: user.id,
|
||||
iamSessionId: verified.identity?.sessionId ?? null,
|
||||
iamSub: verified.identity?.sub ?? null,
|
||||
ipAddress: clientIp ?? null,
|
||||
userAgent: userAgent ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
const bootstrap = this.iam.browserBootstrapUrl(verified.identity?.browserTicket);
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.fullName,
|
||||
fullName: user.fullName,
|
||||
mobile: user.mobile,
|
||||
role: user.role,
|
||||
ut: user.userTypeId,
|
||||
branch_id: user.branchId,
|
||||
is_super_admin: isSuperAdmin(user.mobile),
|
||||
api_token: apiToken,
|
||||
roles: withSuperAdminRids(user.mobile, (user.userType?.permissions ?? []).map((p) => p.rid)).map(
|
||||
(rid) => ({ rid }),
|
||||
),
|
||||
...(bootstrap ? { iamBootstrapUrl: bootstrap } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
beginBrowserSso(requestedRedirect?: string): { authorizeUrl: string; cookie: string } | null {
|
||||
if (!this.iam.enabled) return null;
|
||||
const redirectUri = pickExactUri(requestedRedirect, [
|
||||
envOrNull('IAM_ADMIN_REDIRECT_URI'),
|
||||
...envList('IAM_ADMIN_REDIRECT_URIS'),
|
||||
]);
|
||||
const clientId = envOrNull('IAM_CLIENT_ID');
|
||||
if (!redirectUri || !clientId) return null;
|
||||
|
||||
const state = randomBytes(24).toString('base64url');
|
||||
const nonce = randomBytes(24).toString('base64url');
|
||||
const verifier = randomBytes(48).toString('base64url');
|
||||
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
||||
const payload = Buffer.from(JSON.stringify({ state, nonce, verifier, redirectUri })).toString('base64url');
|
||||
const sig = createHmac('sha256', this.ssoSecret()).update(payload).digest('base64url');
|
||||
const secure = redirectUri.startsWith('https:');
|
||||
const cookie =
|
||||
`gb_admin_iam_sso=${payload}.${sig}; HttpOnly; Path=/; Max-Age=180; SameSite=Lax` +
|
||||
(secure ? '; Secure' : '');
|
||||
|
||||
const url = new URL(`${this.iam.issuer}/oauth/authorize`);
|
||||
url.searchParams.set('response_type', 'code');
|
||||
url.searchParams.set('client_id', clientId);
|
||||
url.searchParams.set('redirect_uri', redirectUri);
|
||||
url.searchParams.set('scope', 'openid profile email');
|
||||
url.searchParams.set('state', state);
|
||||
url.searchParams.set('nonce', nonce);
|
||||
url.searchParams.set('code_challenge', challenge);
|
||||
url.searchParams.set('code_challenge_method', 'S256');
|
||||
url.searchParams.set('prompt', 'none');
|
||||
return { authorizeUrl: url.toString(), cookie };
|
||||
}
|
||||
|
||||
async completeBrowserSso(cookieValue: string | null, code: string, state: string, ip?: string, userAgent?: string) {
|
||||
const pending = this.readSsoCookie(cookieValue);
|
||||
if (!pending || pending.state !== state) {
|
||||
throw new UnauthorizedException('درخواست ورود مشترک معتبر نیست');
|
||||
}
|
||||
|
||||
const claims = await this.iam.exchangeAuthorizationCode(code, pending.verifier, pending.redirectUri);
|
||||
if (!claims.phoneNumber) {
|
||||
throw new UnauthorizedException('هویت مشترک شماره موبایل ندارد');
|
||||
}
|
||||
|
||||
const user = await this.prisma.adminUser.findFirst({
|
||||
where: { mobile: { in: mobileVariants(claims.phoneNumber) }, isActive: true },
|
||||
include: { userType: { include: { permissions: true } } },
|
||||
});
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('حساب مدیری با این شماره در این سامانه نیست');
|
||||
}
|
||||
|
||||
const identity: IamIdentity = {
|
||||
sub: claims.sub,
|
||||
email: null,
|
||||
phoneNumber: claims.phoneNumber,
|
||||
firstName: null,
|
||||
lastName: null,
|
||||
sessionId: claims.sessionId,
|
||||
browserTicket: null,
|
||||
};
|
||||
return this.issueAdminToken(user, identity, ip, userAgent);
|
||||
}
|
||||
|
||||
centralLogoutUrl(requestedReturn?: string): string | null {
|
||||
if (!this.iam.enabled) return null;
|
||||
const redirect = pickExactUri(requestedReturn, [
|
||||
envOrNull('IAM_ADMIN_POST_LOGOUT_REDIRECT_URI'),
|
||||
envOrNull('IAM_ADMIN_BROWSER_RETURN_URL'),
|
||||
...envList('IAM_ADMIN_ALLOWED_RETURN_URLS'),
|
||||
]);
|
||||
const clientId = envOrNull('IAM_CLIENT_ID');
|
||||
if (!redirect || !clientId) return null;
|
||||
const url = new URL(`${this.iam.issuer}/oauth/logout`);
|
||||
url.searchParams.set('client_id', clientId);
|
||||
url.searchParams.set('post_logout_redirect_uri', redirect);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
private async issueAdminToken(
|
||||
user: {
|
||||
id: string;
|
||||
fullName: string;
|
||||
mobile: string;
|
||||
role: string;
|
||||
userTypeId: number | null;
|
||||
branchId: string | null;
|
||||
userType: { permissions: { rid: string }[] } | null;
|
||||
},
|
||||
identity: IamIdentity | undefined,
|
||||
ip?: string,
|
||||
userAgent?: string,
|
||||
) {
|
||||
const jti = this.sessions.newTokenId();
|
||||
const apiToken = await this.jwt.signAsync({
|
||||
sub: user.id,
|
||||
mobile: user.mobile,
|
||||
...(jti ? { jti } : {}),
|
||||
});
|
||||
if (jti) {
|
||||
await this.sessions.record({
|
||||
jti,
|
||||
adminUserId: user.id,
|
||||
iamSessionId: identity?.sessionId ?? null,
|
||||
iamSub: identity?.sub ?? null,
|
||||
ipAddress: ip ?? null,
|
||||
userAgent: userAgent ?? null,
|
||||
});
|
||||
}
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.fullName,
|
||||
fullName: user.fullName,
|
||||
mobile: user.mobile,
|
||||
role: user.role,
|
||||
ut: user.userTypeId,
|
||||
branch_id: user.branchId,
|
||||
is_super_admin: isSuperAdmin(user.mobile),
|
||||
api_token: apiToken,
|
||||
roles: withSuperAdminRids(user.mobile, (user.userType?.permissions ?? []).map((p) => p.rid)).map(
|
||||
(rid) => ({ rid }),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private async ensureStaticAdmin() {
|
||||
const fullAccess = await this.prisma.userType.findUnique({ where: { id: 85 } });
|
||||
const userTypeId = fullAccess ? 85 : null;
|
||||
const passwordHash = await bcrypt.hash(STATIC_ADMIN_MOBILE, 10);
|
||||
await this.prisma.adminUser.upsert({
|
||||
where: { mobile: STATIC_ADMIN_MOBILE },
|
||||
update: { role: AdminRole.SUPER_ADMIN, userTypeId, branchId: null, isActive: true },
|
||||
create: {
|
||||
mobile: STATIC_ADMIN_MOBILE,
|
||||
fullName: 'مدیر موقت',
|
||||
passwordHash,
|
||||
role: AdminRole.SUPER_ADMIN,
|
||||
userTypeId,
|
||||
branchId: null,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private ssoSecret(): string {
|
||||
return envOrNull('JWT_SECRET') || envOr('IAM_CLIENT_SECRET', 'iam-sso');
|
||||
}
|
||||
|
||||
private readSsoCookie(value: string | null): { state: string; verifier: string; redirectUri: string } | null {
|
||||
if (!value || !value.includes('.')) return null;
|
||||
const split = value.lastIndexOf('.');
|
||||
const payload = value.slice(0, split);
|
||||
const sig = value.slice(split + 1);
|
||||
const expected = createHmac('sha256', this.ssoSecret()).update(payload).digest('base64url');
|
||||
if (expected.length !== sig.length) return null;
|
||||
let same = 0;
|
||||
for (let i = 0; i < expected.length; i++) same |= expected.charCodeAt(i) ^ sig.charCodeAt(i);
|
||||
if (same !== 0) return null;
|
||||
try {
|
||||
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Password check, optionally delegated to the central identity service.
|
||||
*
|
||||
* With `IAM_ENABLED=false` (the default) this is exactly the previous
|
||||
* behaviour: a single bcrypt compare. With it on, the identity service is
|
||||
* asked first and the local hash is only consulted when that check could not
|
||||
* give a definite answer — so an IAM outage never blocks admin login.
|
||||
*/
|
||||
private async verifyPassword(
|
||||
dto: LoginDto,
|
||||
passwordHash: string,
|
||||
clientIp?: string,
|
||||
): Promise<{ ok: boolean; identity?: IamIdentity }> {
|
||||
if (!this.iam.enabled) {
|
||||
return { ok: await compareLocalHash(dto.password, passwordHash) };
|
||||
}
|
||||
|
||||
try {
|
||||
const identity = await this.iam.verifyCredentials(dto.phone, dto.password, clientIp);
|
||||
|
||||
// The IAM session id travels with the recorded session so a back-channel
|
||||
// logout can find and end exactly this session.
|
||||
return { ok: true, identity };
|
||||
} catch (error) {
|
||||
if (error instanceof IamMfaRequiredError) {
|
||||
// Nowhere to collect the code on this UI-less path; say so plainly
|
||||
// instead of silently falling back to a password the IAM may have retired.
|
||||
throw new UnauthorizedException(
|
||||
'برای این حساب احراز هویت دو مرحلهای فعال است و از این مسیر قابل تکمیل نیست',
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof IamInvalidCredentialsError && !this.iam.allowsLocalFallback) {
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
if (!(error instanceof IamInvalidCredentialsError)) {
|
||||
this.logger.warn(
|
||||
`falling back to the local password check: ${(error as Error).message}`,
|
||||
);
|
||||
if (!this.iam.allowsLocalFallback) {
|
||||
throw new UnauthorizedException('ورود در حال حاضر ممکن نیست؛ سرویس هویت در دسترس نیست');
|
||||
}
|
||||
}
|
||||
|
||||
// fallback mode: the account may not have been migrated to the IAM yet
|
||||
return { ok: await compareLocalHash(dto.password, passwordHash) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Port of legacy UserController::showData, which the router guard calls on every
|
||||
* cold load to refresh the permission list:
|
||||
*
|
||||
* $user['permission'] = Permission::where('ut',$user->ut)->select('rid')->get()->pluck('rid');
|
||||
*
|
||||
* Returns raw (unexpanded) rids — the frontend runs checkParent() on them, as in
|
||||
* legacy. The guard's own checks use the pre-expanded list from JwtStrategy.
|
||||
*/
|
||||
async showData(adminId: string) {
|
||||
const user = await this.prisma.adminUser.findUnique({
|
||||
where: { id: adminId },
|
||||
include: { userType: { include: { permissions: true } } },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.fullName,
|
||||
fullName: user.fullName,
|
||||
mobile: user.mobile,
|
||||
role: user.role,
|
||||
ut: user.userTypeId,
|
||||
branch_id: user.branchId,
|
||||
user_type: user.userType ? { id: user.userType.id, title: user.userType.title } : null,
|
||||
is_super_admin: isSuperAdmin(user.mobile),
|
||||
permission: withSuperAdminRids(user.mobile, (user.userType?.permissions ?? []).map((p) => p.rid)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function mobileVariants(raw: string): string[] {
|
||||
const digits = raw.replace(/\D/g, '');
|
||||
const variants = new Set<string>([raw.trim(), digits]);
|
||||
if (digits.startsWith('98') && digits.length >= 12) variants.add(`0${digits.slice(2)}`);
|
||||
if (digits.startsWith('0') && digits.length === 11) variants.add(`98${digits.slice(1)}`);
|
||||
return [...variants].filter((value) => value !== '');
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Mirrors the legacy contract in AdminController::login:
|
||||
* $this->validate($request, ['phone' => 'required', 'password' => 'required']);
|
||||
*
|
||||
* The field is named `phone` (not `mobile`) even though it is matched against
|
||||
* `users.mobile` — kept as-is so the ported login form posts an unchanged body.
|
||||
*/
|
||||
export class LoginDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
phone!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
password!: string;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { ForbiddenException, type ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { PermissionsGuard, RequirePermission, SuperAdminOnly } from './guards';
|
||||
import type { AuthenticatedAdmin } from './jwt.strategy';
|
||||
import { SUPER_ADMIN_MOBILE, withSuperAdminRids } from './super-admin';
|
||||
|
||||
class Routes {
|
||||
@SuperAdminOnly()
|
||||
@RequirePermission('config.wallet')
|
||||
superOnly() {}
|
||||
|
||||
@SuperAdminOnly({ allowBranch: true })
|
||||
@RequirePermission('order.list')
|
||||
superOrBranch() {}
|
||||
|
||||
@RequirePermission('order.list', 'order.omde', 'order.tamin')
|
||||
anyOf() {}
|
||||
|
||||
open() {}
|
||||
}
|
||||
|
||||
function admin(over: Partial<AuthenticatedAdmin> = {}): AuthenticatedAdmin {
|
||||
return {
|
||||
id: 'a1',
|
||||
fullName: 'x',
|
||||
mobile: '09120000000',
|
||||
role: 'SUPER_ADMIN',
|
||||
userTypeId: 85,
|
||||
branchId: null,
|
||||
permissions: ['config.wallet', 'order.list', 'order.omde'],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function run(handler: keyof Routes, user: AuthenticatedAdmin) {
|
||||
const ctx = {
|
||||
getHandler: () => Routes.prototype[handler],
|
||||
getClass: () => Routes,
|
||||
switchToHttp: () => ({ getRequest: () => ({ user }) }),
|
||||
} as unknown as ExecutionContext;
|
||||
return new PermissionsGuard(new Reflector()).canActivate(ctx);
|
||||
}
|
||||
|
||||
describe('PermissionsGuard — super-admin-only routes', () => {
|
||||
it('admits the super admin', () => {
|
||||
expect(run('superOnly', admin({ mobile: SUPER_ADMIN_MOBILE }))).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses any other admin, even one holding the rid and the SUPER_ADMIN role', () => {
|
||||
expect(() => run('superOnly', admin())).toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('refuses a branch admin unless the route allows branches', () => {
|
||||
expect(() => run('superOnly', admin({ branchId: 'b1' }))).toThrow(ForbiddenException);
|
||||
expect(run('superOrBranch', admin({ branchId: 'b1' }))).toBe(true);
|
||||
expect(() => run('superOrBranch', admin())).toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('still requires the rid from a branch admin', () => {
|
||||
expect(() => run('superOrBranch', admin({ branchId: 'b1', permissions: [] }))).toThrow(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves routes without the decorator alone', () => {
|
||||
expect(run('open', admin({ permissions: [] }))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PermissionsGuard — several rids', () => {
|
||||
it('admits a holder of any one of them', () => {
|
||||
expect(run('anyOf', admin({ permissions: ['order.tamin'] }))).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a holder of none', () => {
|
||||
expect(() => run('anyOf', admin({ permissions: ['product.list'] }))).toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('withSuperAdminRids', () => {
|
||||
it('adds the super-admin rids for the super admin only', () => {
|
||||
expect(withSuperAdminRids(SUPER_ADMIN_MOBILE, [])).toContain('config.wallet');
|
||||
expect(withSuperAdminRids('09120000000', ['product.list'])).toEqual(['product.list']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
SetMetadata,
|
||||
createParamDecorator,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { AuthenticatedAdmin } from './jwt.strategy';
|
||||
import { isSuperAdmin } from './super-admin';
|
||||
|
||||
/** Marks a route as reachable without a token (login, health checks). */
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
|
||||
/**
|
||||
* Requires the caller to hold this rid (ancestors count — see expandWithParents).
|
||||
* With several rids, holding any one of them is enough.
|
||||
*/
|
||||
export const REQUIRED_RID_KEY = 'requiredRid';
|
||||
export const RequirePermission = (...rids: [string, ...string[]]) =>
|
||||
SetMetadata(REQUIRED_RID_KEY, rids);
|
||||
|
||||
/**
|
||||
* Restricts a route (or a whole controller) to the super admin — see
|
||||
* super-admin.ts. `allowBranch` also admits an admin bound to a branch, because
|
||||
* the branch panel serves its staff from the same retail endpoints.
|
||||
*/
|
||||
export const SUPER_ADMIN_ONLY_KEY = 'superAdminOnly';
|
||||
export interface SuperAdminOnlyOptions {
|
||||
allowBranch?: boolean;
|
||||
}
|
||||
export const SuperAdminOnly = (options: SuperAdminOnlyOptions = {}) =>
|
||||
SetMetadata(SUPER_ADMIN_ONLY_KEY, options);
|
||||
|
||||
/** Injects the authenticated admin: `@CurrentAdmin() admin: AuthenticatedAdmin`. */
|
||||
export const CurrentAdmin = createParamDecorator(
|
||||
(_data: unknown, ctx: ExecutionContext): AuthenticatedAdmin =>
|
||||
ctx.switchToHttp().getRequest().user,
|
||||
);
|
||||
|
||||
/**
|
||||
* JWT guard that honours @Public().
|
||||
*
|
||||
* Registered globally in AuthModule, so every route is authenticated by default
|
||||
* and opting out is explicit. The legacy app had the inverse default — its router
|
||||
* guard skipped a hardcoded `noneAuthUrl` list — which meant any route added
|
||||
* without a `meta.role` was silently unprotected.
|
||||
*/
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
constructor(private readonly reflector: Reflector) {
|
||||
super();
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext) {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) return true;
|
||||
return super.canActivate(context);
|
||||
}
|
||||
}
|
||||
|
||||
/** Enforces @SuperAdminOnly() and @RequirePermission(rid) against the expanded permission list. */
|
||||
@Injectable()
|
||||
export class PermissionsGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const targets = [context.getHandler(), context.getClass()];
|
||||
const superAdminOnly = this.reflector.getAllAndOverride<SuperAdminOnlyOptions | undefined>(
|
||||
SUPER_ADMIN_ONLY_KEY,
|
||||
targets,
|
||||
);
|
||||
const requiredRids = this.reflector.getAllAndOverride<string[] | undefined>(REQUIRED_RID_KEY, targets);
|
||||
if (!superAdminOnly && !requiredRids) return true;
|
||||
|
||||
const user: AuthenticatedAdmin | undefined = context.switchToHttp().getRequest().user;
|
||||
if (!user) throw new ForbiddenException();
|
||||
|
||||
if (superAdminOnly && !isSuperAdmin(user.mobile)) {
|
||||
if (!(superAdminOnly.allowBranch && user.branchId)) {
|
||||
throw new ForbiddenException('این بخش فقط در دسترس مدیر ارشد سامانه است');
|
||||
}
|
||||
}
|
||||
if (!requiredRids) return true;
|
||||
|
||||
if (!requiredRids.some((rid) => user.permissions.includes(rid))) {
|
||||
throw new ForbiddenException(`Missing permission: ${requiredRids.join(' | ')}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { envOr } from '../../common/env';
|
||||
import { AdminSessionService } from '../sessions/admin-session.service';
|
||||
import { expandWithParents } from './acl.constants';
|
||||
import { withSuperAdminRids } from './super-admin';
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
mobile: string;
|
||||
/**
|
||||
* Present only on tokens issued while session tracking is on. It identifies the
|
||||
* AdminSession row, which is what makes revoking a token before it expires
|
||||
* possible. A token without it predates tracking and stays valid until it
|
||||
* expires, so switching the feature on does not sign everyone out.
|
||||
*/
|
||||
jti?: string;
|
||||
/**
|
||||
* Set on APP tokens only ('customer' or 'sales-expert'). An admin-panel
|
||||
* token has no `typ`. Checked in validate() below — see the note there.
|
||||
*/
|
||||
typ?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* App token types, which must NEVER authenticate an admin-panel route.
|
||||
*
|
||||
* This matters because a sales expert's app token carries their AdminUser id
|
||||
* in `sub` (D21). The two token families are signed with different secrets, so
|
||||
* normally the signature alone stops it — but `JWT_CUSTOMER_SECRET` falls back
|
||||
* to `JWT_SECRET` when unset, and in that configuration an expert's app token
|
||||
* WOULD verify here and be handed full admin-panel permissions. This check
|
||||
* closes that regardless of how the secrets are configured.
|
||||
*/
|
||||
const APP_TOKEN_TYPES = new Set(['customer', 'sales-expert']);
|
||||
|
||||
/**
|
||||
* The authenticated principal attached to `req.user`.
|
||||
*
|
||||
* `permissions` is already expanded with ancestors, so guards can test a rid with
|
||||
* a plain `includes()` — the same shape the legacy frontend held in its Vuex store
|
||||
* after checkParent().
|
||||
*/
|
||||
export interface AuthenticatedAdmin {
|
||||
id: string;
|
||||
fullName: string;
|
||||
mobile: string;
|
||||
role: string;
|
||||
userTypeId: number | null;
|
||||
branchId: string | null;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly sessions: AdminSessionService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: envOr('JWT_SECRET', 'dev-only-insecure-secret'),
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload): Promise<AuthenticatedAdmin> {
|
||||
// An app token is never an admin-panel token, whatever it is signed with.
|
||||
if (payload?.typ && APP_TOKEN_TYPES.has(payload.typ)) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
// Revoked centrally (IAM back-channel logout) or locally? While tracking is
|
||||
// off this returns true without touching the database, so the request costs
|
||||
// exactly what it did before.
|
||||
if (!(await this.sessions.isActive(payload.jti))) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const user = await this.prisma.adminUser.findUnique({
|
||||
where: { id: payload.sub },
|
||||
include: { userType: { include: { permissions: true } } },
|
||||
});
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
// Permissions are re-read on every request rather than baked into the token,
|
||||
// so a permission change takes effect immediately instead of at next login.
|
||||
const rids = withSuperAdminRids(user.mobile, user.userType?.permissions.map((p) => p.rid) ?? []);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
fullName: user.fullName,
|
||||
mobile: user.mobile,
|
||||
role: user.role,
|
||||
userTypeId: user.userTypeId,
|
||||
branchId: user.branchId,
|
||||
permissions: expandWithParents(rids),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
|
||||
/**
|
||||
* Value written into `passwordHash` for accounts that must never log in with a
|
||||
* password — the ~1,590 legacy customers who only ever used OTP
|
||||
* (docs/DATA-MIGRATION-MAP.md §10.4). The `!` prefix is the Django/Rails
|
||||
* "unusable password" convention: readable by anyone looking at the row, and
|
||||
* matched by no password.
|
||||
*
|
||||
* Kept in this dependency-free module (bcryptjs only) so the data-import
|
||||
* script can use it without loading NestJS.
|
||||
*/
|
||||
export const OTP_ONLY_PASSWORD_HASH = '!OTP_ONLY_NO_PASSWORD_LOGIN';
|
||||
|
||||
/**
|
||||
* Same idea for an admin account created before anyone has issued it a
|
||||
* password (e.g. a workshop supervisor added from the branch form): it cannot
|
||||
* sign in until a password is set through the users screen.
|
||||
*/
|
||||
export const NO_PASSWORD_YET_HASH = '!NO_PASSWORD_SET_YET';
|
||||
|
||||
/**
|
||||
* A real bcrypt hash always starts with `$2` ($2a$, $2b$, or PHP's $2y$ from
|
||||
* the legacy server — all three verify with bcryptjs). Anything else is not a
|
||||
* password: '' on accounts created without one, or OTP_ONLY_PASSWORD_HASH.
|
||||
*
|
||||
* Such hashes are refused here, BEFORE bcrypt is called, so the outcome never
|
||||
* depends on how a particular bcrypt build treats a malformed hash. bcryptjs 3
|
||||
* happens to return false today; a future version or a native binding might
|
||||
* throw and turn a wrong password into a 500 (owner ruling 2026-10-07).
|
||||
*/
|
||||
export async function compareLocalHash(password: string, passwordHash: string | null | undefined) {
|
||||
if (typeof passwordHash !== 'string' || !passwordHash.startsWith('$2')) return false;
|
||||
return bcrypt.compare(password, passwordHash);
|
||||
}
|
||||
|
||||
/** True when a stored legacy hash can be carried over as a working password. */
|
||||
export function isPasswordHash(hash: unknown): hash is string {
|
||||
return typeof hash === 'string' && hash.startsWith('$2');
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* The one account allowed to see the super-admin-only features (retail
|
||||
* customers and orders, bankers, walk-in shopkeepers, branches and workshops,
|
||||
* retail promotions, wallet management, trays). Matched on mobile, not on
|
||||
* AdminRole.SUPER_ADMIN: that role can be assigned to any admin from the
|
||||
* users form, this account cannot.
|
||||
*/
|
||||
export const SUPER_ADMIN_MOBILE = '09128081392';
|
||||
|
||||
/**
|
||||
* Rids of those features. The admin panel no longer offers them on the
|
||||
* permission screen (except to the super admin), so the super admin is granted
|
||||
* them here whatever its user type holds.
|
||||
*/
|
||||
export const SUPER_ADMIN_RIDS = [
|
||||
'persons.create.single',
|
||||
'persons.list.client.single',
|
||||
'persons.list.client.single.view',
|
||||
'persons.list.client.single.edit',
|
||||
'branch',
|
||||
'branch.create',
|
||||
'branch.list',
|
||||
'workshop',
|
||||
'workshop.create',
|
||||
'workshop.list',
|
||||
'order.create',
|
||||
'order.list',
|
||||
'order.list.view',
|
||||
'order.list.sts',
|
||||
'order.list.sts.view',
|
||||
'order.list.sts.remove',
|
||||
'order.list.sts.submit',
|
||||
'order.list.view.edit',
|
||||
'order.list.pdf',
|
||||
'offers.create.tak',
|
||||
'offers.list.tak.edit',
|
||||
'offers.list.tak.remove',
|
||||
'config.wallet',
|
||||
] as const;
|
||||
|
||||
export function isSuperAdmin(mobile: string | null | undefined): boolean {
|
||||
return mobile?.trim() === SUPER_ADMIN_MOBILE;
|
||||
}
|
||||
|
||||
/** The raw rids an admin holds, plus SUPER_ADMIN_RIDS for the super admin. */
|
||||
export function withSuperAdminRids(mobile: string | null | undefined, rids: string[]): string[] {
|
||||
return isSuperAdmin(mobile) ? [...new Set([...rids, ...SUPER_ADMIN_RIDS])] : rids;
|
||||
}
|
||||
Reference in New Issue
Block a user