diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 64df33f35..df82299a8 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -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 }, + ); } } diff --git a/apps/edr-freight-api/src/main.ts b/apps/edr-freight-api/src/main.ts index 9efc7fa21..e20d11044 100644 --- a/apps/edr-freight-api/src/main.ts +++ b/apps/edr-freight-api/src/main.ts @@ -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", diff --git a/apps/edr-freight-api/src/modules/auth/login-audience.middleware.spec.ts b/apps/edr-freight-api/src/modules/auth/login-audience.middleware.spec.ts new file mode 100644 index 000000000..2303c3ef2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/login-audience.middleware.spec.ts @@ -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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/auth/login-audience.middleware.ts b/apps/edr-freight-api/src/modules/auth/login-audience.middleware.ts new file mode 100644 index 000000000..3af58d02c --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/login-audience.middleware.ts @@ -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 = { + 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(); + } +} diff --git a/apps/edr-freight-web/backoffice/src/auth/http.ts b/apps/edr-freight-web/backoffice/src/auth/http.ts index 82ae8e808..c59144706 100644 --- a/apps/edr-freight-web/backoffice/src/auth/http.ts +++ b/apps/edr-freight-web/backoffice/src/auth/http.ts @@ -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; }); diff --git a/apps/edr-freight-web/backoffice/src/shared/services/axiosInstance.ts b/apps/edr-freight-web/backoffice/src/shared/services/axiosInstance.ts index d0e7f5866..a14c6b87f 100644 --- a/apps/edr-freight-web/backoffice/src/shared/services/axiosInstance.ts +++ b/apps/edr-freight-web/backoffice/src/shared/services/axiosInstance.ts @@ -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; }); diff --git a/apps/edr-freight-web/portal/src/utils/api.ts b/apps/edr-freight-web/portal/src/utils/api.ts index a316d9520..1ba9ec8db 100644 --- a/apps/edr-freight-web/portal/src/utils/api.ts +++ b/apps/edr-freight-web/portal/src/utils/api.ts @@ -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; }); diff --git a/e2e/freight/cypress/e2e/api/health-and-auth.cy.ts b/e2e/freight/cypress/e2e/api/health-and-auth.cy.ts index c183a11df..462ab8a73 100644 --- a/e2e/freight/cypress/e2e/api/health-and-auth.cy.ts +++ b/e2e/freight/cypress/e2e/api/health-and-auth.cy.ts @@ -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"); }); }); diff --git a/e2e/freight/cypress/e2e/flows/cross-app.cy.ts b/e2e/freight/cypress/e2e/flows/cross-app.cy.ts index fe60170ba..225fbe11c 100644 --- a/e2e/freight/cypress/e2e/flows/cross-app.cy.ts +++ b/e2e/freight/cypress/e2e/flows/cross-app.cy.ts @@ -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}` }, diff --git a/e2e/freight/cypress/e2e/flows/import-utils.ts b/e2e/freight/cypress/e2e/flows/import-utils.ts index 0ab5af12a..9f9b53688 100644 --- a/e2e/freight/cypress/e2e/flows/import-utils.ts +++ b/e2e/freight/cypress/e2e/flows/import-utils.ts @@ -45,9 +45,11 @@ export function db(sql: string, params: unknown[] = []) { /** Bearer token for a staff/customer account (portal users use the demo pwd). */ export function tokenFor(email: string): Cypress.Chainable { - 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( diff --git a/e2e/freight/cypress/fixtures/seed-users.sql b/e2e/freight/cypress/fixtures/seed-users.sql index 6f68040c0..6c097ddcc 100644 --- a/e2e/freight/cypress/fixtures/seed-users.sql +++ b/e2e/freight/cypress/fixtures/seed-users.sql @@ -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 ────────────────────────────────────────────────────────────── diff --git a/e2e/freight/cypress/support/commands.ts b/e2e/freight/cypress/support/commands.ts index 47eacfc7a..43566075b 100644 --- a/e2e/freight/cypress/support/commands.ts +++ b/e2e/freight/cypress/support/commands.ts @@ -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 { +function apiLogin( + email: string, + pass?: string, + app: "backoffice" | "portal" = "backoffice", +): Cypress.Chainable { return cy - .request("POST", `${apiUrl()}/api/auth/login`, { - email, - password: pass ?? password(), + .request({ + 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; + apiLogin( + email: string, + pass?: string, + app?: "backoffice" | "portal", + ): Chainable; /** Cached programmatic staff session (default ceo@edr.local). */ loginBackoffice(email?: string, pass?: string): Chainable; /** Cached programmatic customer session (default user@gmail.com). */