mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat(auth): add login audience middleware (EDRFREIGHT-415)
This commit is contained in:
@@ -2,6 +2,7 @@ import {
|
||||
MiddlewareConsumer,
|
||||
Module,
|
||||
OnApplicationBootstrap,
|
||||
RequestMethod,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
|
||||
@@ -101,6 +102,7 @@ import { InterchangeDocumentsModule } from "./modules/interchange-documents/inte
|
||||
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
|
||||
import { AiModule } from "./modules/ai/ai.module";
|
||||
import { LoggerMiddleware } from "./logger.middleware";
|
||||
import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -240,6 +242,7 @@ import { LoggerMiddleware } from "./logger.middleware";
|
||||
// MarshallingDemoTrainsSeeder,
|
||||
ApprovedFirstLastMileDemoBookingsSeeder,
|
||||
PaidImportExportMileDemoSeeder,
|
||||
LoginAudienceMiddleware,
|
||||
],
|
||||
})
|
||||
export class AppModule implements OnApplicationBootstrap {
|
||||
@@ -328,5 +331,9 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer.apply(LoggerMiddleware).forRoutes("*");
|
||||
consumer.apply(LoginAudienceMiddleware).forRoutes(
|
||||
{ path: "auth/login", method: RequestMethod.POST },
|
||||
{ path: "auth/mfa-verify", method: RequestMethod.POST },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,9 @@ async function bootstrap() {
|
||||
"Accept",
|
||||
"Authorization",
|
||||
"X-Requested-With",
|
||||
// Which freight frontend is calling — /auth/login uses this to reject
|
||||
// cross-audience credentials (EDRFREIGHT-415).
|
||||
"X-Client-App",
|
||||
// IAM context headers required by @tria-plc/api-common's JwtGuard
|
||||
"organization-unit-id",
|
||||
"delegator-position-id",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,10 @@ api.interceptors.request.use((config) => {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// Tells the backend which app is asking, so /auth/login can reject
|
||||
// cross-audience credentials (EDRFREIGHT-415).
|
||||
config.headers["X-Client-App"] = "backoffice";
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@ axiosInstance.interceptors.request.use((config) => {
|
||||
}
|
||||
// X-Requested-With prevents CSRF via browser-native form/fetch without custom headers
|
||||
config.headers["X-Requested-With"] = "XMLHttpRequest";
|
||||
// Tells the backend which app is asking, so /auth/login can reject
|
||||
// cross-audience credentials (EDRFREIGHT-415).
|
||||
config.headers["X-Client-App"] = "backoffice";
|
||||
return config;
|
||||
});
|
||||
|
||||
|
||||
@@ -39,6 +39,9 @@ client.interceptors.request.use((config) => {
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
// Tells the backend which app is asking, so /auth/login can reject
|
||||
// cross-audience credentials (EDRFREIGHT-415).
|
||||
config.headers["X-Client-App"] = "portal";
|
||||
return config;
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ describe("freight-api: health + auth contract", () => {
|
||||
method: "POST",
|
||||
url: `${api()}/api/auth/login`,
|
||||
body: { email: "nobody@edr.local", password: "wrong-password" },
|
||||
headers: { "x-client-app": "backoffice" },
|
||||
failOnStatusCode: false,
|
||||
})
|
||||
.its("status")
|
||||
@@ -50,8 +51,8 @@ describe("freight-api: health + auth contract", () => {
|
||||
|
||||
it("demo portal users are seeded", () => {
|
||||
// DemoUsersSeeder hardcodes this password (staff users use DEFAULT_PASSWORD)
|
||||
cy.apiLogin("user@gmail.com", Cypress.env("demoPassword"));
|
||||
cy.apiLogin("user2@gmail.com", Cypress.env("demoPassword"));
|
||||
cy.apiLogin("user@gmail.com", Cypress.env("demoPassword"), "portal");
|
||||
cy.apiLogin("user2@gmail.com", Cypress.env("demoPassword"), "portal");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ describe("flow: customer and staff see the same world", () => {
|
||||
.its("status")
|
||||
.should("eq", 200);
|
||||
});
|
||||
cy.apiLogin("user@gmail.com", Cypress.env("demoPassword")).then(({ token }) => {
|
||||
cy.apiLogin("user@gmail.com", Cypress.env("demoPassword"), "portal").then(({ token }) => {
|
||||
cy.request({
|
||||
url: `${api()}/api/me`,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
|
||||
@@ -45,9 +45,11 @@ export function db<T = Row>(sql: string, params: unknown[] = []) {
|
||||
|
||||
/** Bearer token for a staff/customer account (portal users use the demo pwd). */
|
||||
export function tokenFor(email: string): Cypress.Chainable<string> {
|
||||
const pass =
|
||||
email.endsWith("@gmail.com") ? (Cypress.env("demoPassword") as string) : undefined;
|
||||
return cy.apiLogin(email, pass).then(({ token }) => cy.wrap(token, { log: false }));
|
||||
const isPortalUser = email.endsWith("@gmail.com");
|
||||
const pass = isPortalUser ? (Cypress.env("demoPassword") as string) : undefined;
|
||||
return cy
|
||||
.apiLogin(email, pass, isPortalUser ? "portal" : "backoffice")
|
||||
.then(({ token }) => cy.wrap(token, { log: false }));
|
||||
}
|
||||
|
||||
export function apiPost(
|
||||
|
||||
@@ -55,26 +55,29 @@ where not exists (
|
||||
);
|
||||
|
||||
-- ── Users ────────────────────────────────────────────────────────────────────
|
||||
-- user_type matters now: EDRFREIGHT-415's /auth/login audience gate rejects an
|
||||
-- employee-typed account on the portal client and vice versa, so the two demo
|
||||
-- "customer" accounts must actually be typed 'individual', not 'employee'.
|
||||
insert into iam.users (id, email, username, name, status, is_active, has_set_password, user_type)
|
||||
select gen_random_uuid(), v.email, v.username, jsonb_build_object('en', v.display),
|
||||
'accepted', true, true, 'employee'
|
||||
'accepted', true, true, v.user_type
|
||||
from (values
|
||||
('linestaff@edr.local', 'linestaff', 'linestaff'),
|
||||
('chief@edr.local', 'chief', 'chief'),
|
||||
('director@edr.local', 'director', 'director'),
|
||||
('ceo@edr.local', 'ceo', 'ceo'),
|
||||
('marketer@edr.local', 'marketer', 'marketer'),
|
||||
('operation@edr.local', 'operation', 'operation'),
|
||||
('gl-et@edr.local', 'gl_et', 'gl_et'),
|
||||
('gl-dj@edr.local', 'gl_dj', 'gl_dj'),
|
||||
('user@gmail.com', 'user', 'Demo User 1'),
|
||||
('user2@gmail.com', 'user2', 'Demo User 2'),
|
||||
('linestaff@edr.local', 'linestaff', 'linestaff', 'employee'),
|
||||
('chief@edr.local', 'chief', 'chief', 'employee'),
|
||||
('director@edr.local', 'director', 'director', 'employee'),
|
||||
('ceo@edr.local', 'ceo', 'ceo', 'employee'),
|
||||
('marketer@edr.local', 'marketer', 'marketer', 'employee'),
|
||||
('operation@edr.local', 'operation', 'operation', 'employee'),
|
||||
('gl-et@edr.local', 'gl_et', 'gl_et', 'employee'),
|
||||
('gl-dj@edr.local', 'gl_dj', 'gl_dj', 'employee'),
|
||||
('user@gmail.com', 'user', 'Demo User 1', 'individual'),
|
||||
('user2@gmail.com', 'user2', 'Demo User 2', 'individual'),
|
||||
-- Full-authority operator the corridor specs drive the customs/GL steps with
|
||||
-- (t1-close, risk, gate pass, second duty, import release). Nothing in the
|
||||
-- repo seeded it before — the suite silently relied on a hand-made row that
|
||||
-- only existed in a long-lived dev database, so a fresh stack failed 7 specs.
|
||||
('superadmin@tria.com', 'superadmin', 'Super Admin')
|
||||
) v(email, username, display)
|
||||
('superadmin@tria.com', 'superadmin', 'Super Admin', 'employee')
|
||||
) v(email, username, display, user_type)
|
||||
where not exists (select 1 from iam.users u where u.email = v.email);
|
||||
|
||||
-- ── Credentials ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -21,11 +21,17 @@ export interface LoginBody {
|
||||
const apiUrl = () => Cypress.env("apiUrl") as string;
|
||||
const password = () => Cypress.env("defaultPassword") as string;
|
||||
|
||||
function apiLogin(email: string, pass?: string): Cypress.Chainable<LoginBody> {
|
||||
function apiLogin(
|
||||
email: string,
|
||||
pass?: string,
|
||||
app: "backoffice" | "portal" = "backoffice",
|
||||
): Cypress.Chainable<LoginBody> {
|
||||
return cy
|
||||
.request<LoginBody>("POST", `${apiUrl()}/api/auth/login`, {
|
||||
email,
|
||||
password: pass ?? password(),
|
||||
.request<LoginBody>({
|
||||
method: "POST",
|
||||
url: `${apiUrl()}/api/auth/login`,
|
||||
body: { email, password: pass ?? password() },
|
||||
headers: { "x-client-app": app },
|
||||
})
|
||||
.then((response) => {
|
||||
expect(response.status).to.eq(201);
|
||||
@@ -38,7 +44,7 @@ function sessionFor(app: "backoffice" | "portal", email: string, pass?: string)
|
||||
cy.session(
|
||||
[app, email],
|
||||
() => {
|
||||
apiLogin(email, pass).then(({ token, refreshToken }) => {
|
||||
apiLogin(email, pass, app).then(({ token, refreshToken }) => {
|
||||
cy.setCookie("auth-token", token);
|
||||
cy.setCookie("refresh-token", refreshToken);
|
||||
});
|
||||
@@ -60,7 +66,11 @@ function sessionFor(app: "backoffice" | "portal", email: string, pass?: string)
|
||||
);
|
||||
}
|
||||
|
||||
Cypress.Commands.add("apiLogin", (email: string, pass?: string) => apiLogin(email, pass));
|
||||
Cypress.Commands.add(
|
||||
"apiLogin",
|
||||
(email: string, pass?: string, app: "backoffice" | "portal" = "backoffice") =>
|
||||
apiLogin(email, pass, app),
|
||||
);
|
||||
|
||||
Cypress.Commands.add("loginBackoffice", (email = "ceo@edr.local", pass?: string) => {
|
||||
sessionFor("backoffice", email, pass);
|
||||
@@ -260,7 +270,11 @@ declare global {
|
||||
namespace Cypress {
|
||||
interface Chainable {
|
||||
/** POST /api/auth/login, returns the flattened token body. */
|
||||
apiLogin(email: string, pass?: string): Chainable<LoginBody>;
|
||||
apiLogin(
|
||||
email: string,
|
||||
pass?: string,
|
||||
app?: "backoffice" | "portal",
|
||||
): Chainable<LoginBody>;
|
||||
/** Cached programmatic staff session (default ceo@edr.local). */
|
||||
loginBackoffice(email?: string, pass?: string): Chainable<void>;
|
||||
/** Cached programmatic customer session (default user@gmail.com). */
|
||||
|
||||
Reference in New Issue
Block a user