Skip to content

Commit

Permalink
Merge pull request #3197 from MTES-MCT/mod/better-impersonate
Browse files Browse the repository at this point in the history
Amélioration de l'impersonation
  • Loading branch information
Riron authored Mar 28, 2024
2 parents dee587c + 3629ac2 commit ae4ef59
Show file tree
Hide file tree
Showing 13 changed files with 413 additions and 41 deletions.
20 changes: 0 additions & 20 deletions back/src/__tests__/auth.integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,26 +175,6 @@ describe("POST /login", () => {
process.env = OLD_ENV;
}, 10000);

it("should authenticate with invalid password if already connected user is admin", async () => {
const admin = await userFactory({ isAdmin: true });

// Login as admin
const adminLogin = await request
.post("/login")
.send(`email=${admin.email.toUpperCase()}`)
.send(`password=pass`);

// Then login as someone else (req.user is not null)
const user = await userFactory();
const login = await request
.post("/login")
.set("Cookie", adminLogin.header["set-cookie"])
.send(`email=${user.email.toUpperCase()}`)
.send(`password=invalidPwd`);

expect(login.header["set-cookie"]).toHaveLength(1);
});

it("should not authenticate with invalid password if connected user is not admin", async () => {
const nonAdmin = await userFactory({ isAdmin: false });

Expand Down
10 changes: 0 additions & 10 deletions back/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,6 @@ export const getLoginError = (username: string) => ({
}
});

export const ADMIN_IS_PERSONIFYING = "ADMIN_IS_PERSONIFYING";

// apart from logging the user in, we perform captcha verifications:
// - if user as performed less than FAILED_ATTEMPTS_BEFORE_CAPTCHA in the last FAILED_LOGIN_EXPIRATION seconds, perform as usual
// - if user as performed more failed attemps, check if captcha is correct
Expand Down Expand Up @@ -136,14 +134,6 @@ passport.use(
return done(null, { ...user, auth: AuthType.Session });
}

if (req.user?.isAdmin) {
return done(
null,
{ ...user, auth: AuthType.Session },
{ message: ADMIN_IS_PERSONIFYING }
);
}

// if password is not valid and user is not admin, set a redis count to require captcha after several failed login attemps
await setUserLoginFailed(username);

Expand Down
124 changes: 124 additions & 0 deletions back/src/common/middlewares/__tests__/impersonate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { Request, Response, NextFunction } from "express";
import { impersonateMiddleware } from "../impersonate";
import { prisma } from "@td/prisma";

jest.mock("@td/prisma", () => ({
prisma: {
user: {
findUniqueOrThrow: jest.fn()
}
}
}));

describe("impersonateMiddleware", () => {
let req: Request;
let res: Response;
let next: NextFunction;

beforeEach(() => {
req = { session: {} } as Request;
res = {} as Response;
next = jest.fn();
});

afterEach(() => {
jest.clearAllMocks();
});

it("should not impersonate if user is not connected", async () => {
await impersonateMiddleware(req, res, next);

expect(next).toHaveBeenCalled();
expect(req.user).toBeUndefined();
});

it("should not impersonate if user has no session impersonation details", async () => {
req.user = {
id: "original-user-id",
name: "Original User",
isAdmin: false,
auth: "SESSION"
} as any;

await impersonateMiddleware(req, res, next);

expect(next).toHaveBeenCalled();
expect(req.user!.id).toBe("original-user-id");
});

it("should not impersonate if user is not admin", async () => {
req.session = {
impersonatedUserId: "impersonated-user-id",
impersonationStartsAt: Date.now() - (60 * 60 * 1000 + 1), // Exceeds 1 hour
warningMessage: "Impersonation warning"
} as any;

req.user = {
id: "original-user-id",
name: "Original User",
isAdmin: false,
auth: "SESSION"
} as any;

await impersonateMiddleware(req, res, next);

expect(next).toHaveBeenCalled();
expect(req.user!.id).toBe("original-user-id");
});

it("should not impersonate if impersonation duration has exceeded", async () => {
req.session = {
impersonatedUserId: "impersonated-user-id",
impersonationStartsAt: Date.now() - (60 * 60 * 1000 + 1), // Exceeds 1 hour
warningMessage: "Impersonation warning"
} as any;

req.user = {
id: "original-user-id",
name: "Original User",
isAdmin: true,
auth: "SESSION"
} as any;

await impersonateMiddleware(req, res, next);

expect(next).toHaveBeenCalled();
expect(req.session.impersonatedUserId).toBeUndefined();
expect(req.session.impersonationStartsAt).toBeUndefined();
expect(req.session.warningMessage).toBeUndefined();
expect(req.user!.id).toBe("original-user-id");
});

it("should impersonate if impersonation is active and duration is within limit", async () => {
const impersonatedUser = {
id: "impersonated-user-id",
name: "Impersonated User",
isAdmin: false
};
const originalUser = {
id: "original-user-id",
name: "Original User",
isAdmin: true,
auth: "SESSION"
};

req.session = {
impersonatedUserId: "impersonated-user-id",
impersonationStartsAt: Date.now(),
warningMessage: "Impersonation warning"
} as any;
req.user = originalUser as any;

jest
.spyOn(prisma.user, "findUniqueOrThrow")
.mockResolvedValueOnce(impersonatedUser as any);

await impersonateMiddleware(req, res, next);

expect(next).toHaveBeenCalled();
expect(req.session.impersonatedUserId).toBe("impersonated-user-id");
expect(req.session.impersonationStartsAt).toBeDefined();
expect(req.session.warningMessage).toBe("Impersonation warning");
expect(req.user).toEqual({ ...impersonatedUser, auth: originalUser.auth });
});
});
30 changes: 30 additions & 0 deletions back/src/common/middlewares/impersonate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { prisma } from "@td/prisma";
import { Request, Response, NextFunction } from "express";

export async function impersonateMiddleware(
req: Request,
res: Response,
next: NextFunction
) {
if (
req.session.impersonatedUserId &&
req.session.impersonationStartsAt &&
req.user?.isAdmin
) {
const maxDuration = 60 * 60 * 1000; // 1 hour
if (Date.now() - req.session.impersonationStartsAt > maxDuration) {
delete req.session.impersonatedUserId;
delete req.session.impersonationStartsAt;
delete req.session.warningMessage;
return next();
}

const impersonatedUser = await prisma.user.findUniqueOrThrow({
where: { id: req.session.impersonatedUserId }
});

req.user = { ...impersonatedUser, auth: req.user.auth };
}

return next();
}
18 changes: 17 additions & 1 deletion back/src/companies/resolvers/CompanySearchPrivate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,23 @@ const companySearchPrivateResolvers: CompanySearchPrivateResolvers = {
.findUnique({
where: whereSiretOrVatNumber(parent as CompanyBaseIdentifiers)
})
.workerCertification()
.workerCertification(),
users: async (parent, _, ctx) => {
// Only admins can retrieve users through this API. This is used for impersonation
if (!ctx.user?.isAdmin || !parent.trackdechetsId) {
return [];
}

const associations = await prisma.companyAssociation.findMany({
where: { companyId: parent.trackdechetsId },
include: { user: true }
});

return associations.map(association => ({
...association.user,
role: association.role
}));
}
};

export default companySearchPrivateResolvers;
66 changes: 66 additions & 0 deletions back/src/routers/__tests__/auth-router.integration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import request from "supertest";
import { app } from "../../server";
import { userFactory } from "../../__tests__/factories";
import { logIn } from "../../__tests__/auth.helper";
import { resetDatabase } from "../../../integration-tests/helper";

describe("Auth Router", () => {
afterEach(resetDatabase);

describe("POST /impersonate", () => {
it("should return 404 if user is logged in", async () => {
const user = await userFactory();
const { sessionCookie } = await logIn(app, user.email, "pass");

const response = await request(app)
.post("/impersonate")
.set("Cookie", sessionCookie)
.set("Content-Type", "application/json")
.send({ email: "[email protected]" });

expect(response.status).toBe(404);
});

it("should return 404 if user is not admin", async () => {
const user = await userFactory();
const { sessionCookie } = await logIn(app, user.email, "pass");

const response = await request(app)
.post("/impersonate")
.set("Cookie", sessionCookie)
.set("Content-Type", "application/json")
.send({ email: "[email protected]" });

expect(response.status).toBe(404);
});

it("should return 400 if user is admin and impersonated user is not found", async () => {
const user = await userFactory({ isAdmin: true });
const { sessionCookie } = await logIn(app, user.email, "pass");

const response = await request(app)
.post("/impersonate")
.set("Cookie", sessionCookie)
.set("Content-Type", "application/json")
.send({ email: "[email protected]" });

expect(response.status).toBe(400);
expect(response.text).toBe("Unknown email");
});

it("should redirect if user is admin and user is found", async () => {
const impersonatedUser = await userFactory();

const user = await userFactory({ isAdmin: true });
const { sessionCookie } = await logIn(app, user.email, "pass");

const response = await request(app)
.post("/impersonate")
.set("Cookie", sessionCookie)
.set("Content-Type", "application/json")
.send({ email: impersonatedUser.email });

expect(response.status).toBe(302);
});
});
});
43 changes: 36 additions & 7 deletions back/src/routers/auth-router.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Router } from "express";
import passport from "passport";
import querystring from "querystring";
import { ADMIN_IS_PERSONIFYING } from "../auth";
import { prisma } from "@td/prisma";
import { z } from "zod";
import nocache from "../common/middlewares/nocache";
import { rateLimiterMiddleware } from "../common/middlewares/rateLimiter";
import { storeUserSessionsId } from "../common/redis/users";
Expand Down Expand Up @@ -42,12 +43,6 @@ authRouter.post(
);
}
req.logIn(user, () => {
if (info?.message === ADMIN_IS_PERSONIFYING) {
// when personifying a user account we reduce the session duration to 1 hour and display a message
const oneHourInMs = 3600000;
req.session.cookie.maxAge = oneHourInMs;
req.session.warningMessage = `Attention, vous êtes actuellement connecté avec le compte utilisateur ${user.email} pour une durée de 1 heure.`;
}
storeUserSessionsId(user.id, req.session.id);
const returnTo = req.body.returnTo || "/";
return res.redirect(`${UI_BASE_URL}${returnTo}`);
Expand All @@ -69,3 +64,37 @@ authRouter.post("/logout", (req, res, next) => {
res.redirect(UI_BASE_URL);
});
});

authRouter.post<{ email: string }>("/impersonate", async (req, res) => {
if (!req.user?.isAdmin) {
return res.status(404).send();
}

const parsedBody = z
.object({
email: z.string().email()
})
.parse(req.body);

const impersonatedUser = await prisma.user.findUnique({
where: { email: parsedBody.email },
select: { id: true }
});

if (!impersonatedUser) {
return res.status(400).send("Unknown email");
}

req.session.impersonatedUserId = impersonatedUser.id;
req.session.impersonationStartsAt = Date.now();
req.session.warningMessage = `Attention, vous êtes actuellement connecté avec le compte utilisateur ${parsedBody.email} pour une durée de 1 heure.`;

return res.redirect(UI_BASE_URL);
});

authRouter.delete("/impersonate", (req, res) => {
delete req.session.impersonatedUserId;
delete req.session.impersonationStartsAt;
delete req.session.warningMessage;
return res.status(200).send();
});
3 changes: 3 additions & 0 deletions back/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { ErrorCode, UserInputError } from "./common/errors";
import errorHandler from "./common/middlewares/errorHandler";
import { graphqlBatchLimiterMiddleware } from "./common/middlewares/graphqlBatchLimiter";
import { graphqlBodyParser } from "./common/middlewares/graphqlBodyParser";
import { impersonateMiddleware } from "./common/middlewares/impersonate";
import loggingMiddleware from "./common/middlewares/loggingMiddleware";
import { rateLimiterMiddleware } from "./common/middlewares/rateLimiter";
import { timeoutMiddleware } from "./common/middlewares/timeout";
Expand Down Expand Up @@ -267,6 +268,8 @@ app.use(authRouter);
app.use(oauth2Router);
app.use(oidcRouter);

app.use(impersonateMiddleware);

app.get("/ping", (_, res) => res.send("Pong!"));

app.get("/ip", (req, res) => {
Expand Down
2 changes: 2 additions & 0 deletions back/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export type Nullable<T> = {
declare module "express-session" {
interface SessionData {
warningMessage?: string;
impersonatedUserId?: string;
impersonationStartsAt?: number;
}
}

Expand Down
Loading

0 comments on commit ae4ef59

Please sign in to comment.