mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 05:18:11 +00:00
feat(auth): add login audience middleware (EDRFREIGHT-415)
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
|
||||
import { LoginAudienceMiddleware } from './login-audience.middleware';
|
||||
|
||||
/**
|
||||
* Touches only the DataSource, so build off the prototype rather than
|
||||
* standing up a full Nest module — same pattern as
|
||||
* warehouses/receive-export-paid.spec.ts.
|
||||
*/
|
||||
function makeMiddleware(userType: string | undefined) {
|
||||
const query = jest.fn().mockResolvedValue(userType ? [{ userType }] : []);
|
||||
const middleware = Object.create(
|
||||
LoginAudienceMiddleware.prototype,
|
||||
) as LoginAudienceMiddleware;
|
||||
(middleware as unknown as { dataSource: unknown }).dataSource = { query };
|
||||
return middleware;
|
||||
}
|
||||
|
||||
function makeReq(clientApp: string | undefined, email = 'someone@example.com') {
|
||||
return {
|
||||
header: (name: string) =>
|
||||
name.toLowerCase() === 'x-client-app' ? clientApp : undefined,
|
||||
body: { email },
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe('LoginAudienceMiddleware', () => {
|
||||
it('rejects when the client app header is missing', async () => {
|
||||
const middleware = makeMiddleware('employee');
|
||||
const next = jest.fn();
|
||||
|
||||
await expect(
|
||||
middleware.use(makeReq(undefined), {} as any, next),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an unrecognized client app header', async () => {
|
||||
const middleware = makeMiddleware('employee');
|
||||
const next = jest.fn();
|
||||
|
||||
await expect(
|
||||
middleware.use(makeReq('mobile'), {} as any, next),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('rejects an employee account signing in through the portal client', async () => {
|
||||
const middleware = makeMiddleware('employee');
|
||||
const next = jest.fn();
|
||||
|
||||
await expect(
|
||||
middleware.use(makeReq('portal'), {} as any, next),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a customer account signing in through the backoffice client', async () => {
|
||||
const middleware = makeMiddleware('individual');
|
||||
const next = jest.fn();
|
||||
|
||||
await expect(
|
||||
middleware.use(makeReq('backoffice'), {} as any, next),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('allows an employee account through the backoffice client', async () => {
|
||||
const middleware = makeMiddleware('employee');
|
||||
const next = jest.fn();
|
||||
|
||||
await middleware.use(makeReq('backoffice'), {} as any, next);
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('allows a customer account through the portal client', async () => {
|
||||
const middleware = makeMiddleware('individual');
|
||||
const next = jest.fn();
|
||||
|
||||
await middleware.use(makeReq('portal'), {} as any, next);
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('lets an unknown identifier fall through to the login handler', async () => {
|
||||
const middleware = makeMiddleware(undefined);
|
||||
const next = jest.fn();
|
||||
|
||||
await middleware.use(makeReq('portal'), {} as any, next);
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { ForbiddenException, Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
|
||||
export const CLIENT_APP_HEADER = 'x-client-app';
|
||||
|
||||
// EUserType values from @tria-plc/api-common, duplicated here to avoid
|
||||
// pulling in the full enum just for this string comparison.
|
||||
const ALLOWED_USER_TYPES_BY_CLIENT: Record<string, string[]> = {
|
||||
backoffice: ['employee'],
|
||||
portal: ['individual', 'external_organization'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Blocks EDRFREIGHT-415: /auth/login and /auth/mfa-verify match credentials
|
||||
* against email/username/phone_number only (see vendor
|
||||
* findUserForLogin), with no check that the account's userType belongs on
|
||||
* the app that's asking. A backoffice (employee) client presenting a
|
||||
* customer's credentials — or vice versa — must not get a session.
|
||||
*/
|
||||
@Injectable()
|
||||
export class LoginAudienceMiddleware implements NestMiddleware {
|
||||
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
|
||||
|
||||
async use(req: Request, _res: Response, next: NextFunction) {
|
||||
const clientApp = req.header(CLIENT_APP_HEADER);
|
||||
const allowedUserTypes = clientApp
|
||||
? ALLOWED_USER_TYPES_BY_CLIENT[clientApp]
|
||||
: undefined;
|
||||
if (!allowedUserTypes) {
|
||||
throw new ForbiddenException(
|
||||
`Missing or unrecognized ${CLIENT_APP_HEADER} header`,
|
||||
);
|
||||
}
|
||||
|
||||
const identifier: unknown = req.body?.email;
|
||||
if (typeof identifier !== 'string' || !identifier) {
|
||||
// No identifier to look up — the vendor DTO validation rejects the
|
||||
// request on its own.
|
||||
return next();
|
||||
}
|
||||
|
||||
const [user] = await this.dataSource.query(
|
||||
`SELECT user_type AS "userType" FROM iam.users
|
||||
WHERE email = $1 OR username = $1 OR phone_number = $1 LIMIT 1`,
|
||||
[identifier],
|
||||
);
|
||||
|
||||
if (user && !allowedUserTypes.includes(user.userType)) {
|
||||
throw new ForbiddenException(
|
||||
`This account cannot sign in through the ${clientApp} application`,
|
||||
);
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user