Skip to content

Commit

Permalink
Merge pull request #89 from PermanentOrg/per-9583_add_delete_mfa_meth…
Browse files Browse the repository at this point in the history
…od_endpoint

Add /idpuser/disable-two-factor endpoint
  • Loading branch information
liam-lloyd authored Jun 14, 2024
2 parents 3891de3 + 3685e2e commit b64cc68
Show file tree
Hide file tree
Showing 6 changed files with 210 additions and 2 deletions.
134 changes: 132 additions & 2 deletions packages/api/src/idpuser/controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import { fusionAuthClient } from "../fusionauth";
import type {
TwoFactorRequestResponse,
SendEnableCodeRequest,
CreateTwoFactorMethodRequest,
SendDisableCodeRequest,
DisableTwoFactorRequest,
} from "./models";

interface TwoFactorRequest {
Expand Down Expand Up @@ -311,7 +313,7 @@ describe("POST /idpuser/enable-two-factor", () => {
await loadFixtures();
(verifyUserAuthentication as jest.Mock).mockImplementation(
(req: Request, __, next: NextFunction) => {
(req.body as TwoFactorRequest).emailFromAuthToken =
(req.body as CreateTwoFactorMethodRequest).emailFromAuthToken =
"test@permanent.org";
next();
}
Expand Down Expand Up @@ -350,7 +352,8 @@ describe("POST /idpuser/enable-two-factor", () => {
test("should return a 400 status if the email from the auth token is not an email", async () => {
(verifyUserAuthentication as jest.Mock).mockImplementation(
(req: Request, __, next: NextFunction) => {
(req.body as TwoFactorRequest).emailFromAuthToken = "not_an_email";
(req.body as CreateTwoFactorMethodRequest).emailFromAuthToken =
"not_an_email";
next();
}
);
Expand Down Expand Up @@ -568,3 +571,130 @@ describe("idpuser/send-disable-code", () => {
.expect(500);
});
});

describe("/idpuser/disable-two-factor", () => {
const agent = request(app);

const loadFixtures = async (): Promise<void> => {
await db.sql("fixtures.create_test_accounts");
};

const clearDatabase = async (): Promise<void> => {
await db.query("TRUNCATE account CASCADE;");
};

beforeEach(async () => {
(verifyUserAuthentication as jest.Mock).mockImplementation(
(req: Request, __, next: NextFunction) => {
(req.body as DisableTwoFactorRequest).emailFromAuthToken =
"test@permanent.org";
next();
}
);

(fusionAuthClient.disableTwoFactor as jest.Mock).mockResolvedValue({
wasSuccessful: () => true,
});

await loadFixtures();
});

afterEach(async () => {
await clearDatabase();
jest.restoreAllMocks();
});

test("should return 200 status if the request is successful", async () => {
await agent
.post("/api/v2/idpuser/disable-two-factor")
.send({ methodId: "test_method_id", code: "test_code" })
.expect(200);
});

test("should return 401 status if the request is not authenticated", async () => {
(verifyUserAuthentication as jest.Mock).mockImplementation(
(_: Request, __, next: NextFunction) => {
next(createError.Unauthorized("Invalid token"));
}
);
await agent
.post("/api/v2/idpuser/disable-two-factor")
.send({ methodId: "test_method_id", code: "test_code" })
.expect(401);
});

test("should return 400 status if emailFromAuthToken is missing", async () => {
(verifyUserAuthentication as jest.Mock).mockImplementation(
(_: Request, __, next: NextFunction) => {
next();
}
);
await agent
.post("/api/v2/idpuser/disable-two-factor")
.send({ methodId: "test_method_id", code: "test_code" })
.expect(400);
});

test("should return 400 status if emailFromAuthToken is not an email", async () => {
(verifyUserAuthentication as jest.Mock).mockImplementation(
(req: Request, __, next: NextFunction) => {
(req.body as DisableTwoFactorRequest).emailFromAuthToken =
"not_an_email";
next();
}
);
await agent
.post("/api/v2/idpuser/disable-two-factor")
.send({ methodId: "test_method_id", code: "test_code" })
.expect(400);
});

test("should return 400 status if methodId is missing", async () => {
await agent
.post("/api/v2/idpuser/disable-two-factor")
.send({ code: "test_code" })
.expect(400);
});

test("should return 400 status if code is missing", async () => {
await agent
.post("/api/v2/idpuser/disable-two-factor")
.send({ methodId: "test_method_id" })
.expect(400);
});

test("should call the fusionauth client to disable the two factor method", async () => {
await agent
.post("/api/v2/idpuser/disable-two-factor")
.send({ methodId: "test_method_id", code: "test_code" })
.expect(200);
expect(fusionAuthClient.disableTwoFactor).toHaveBeenCalled();
});

test("should return status 404 if the user isn't in the database", async () => {
await db.query("TRUNCATE account CASCADE;");
await agent
.post("/api/v2/idpuser/disable-two-factor")
.send({ methodId: "test_method_id", code: "test_code" })
.expect(404);
});

test("should return status 500 if the fusionAuth call fails", async () => {
(fusionAuthClient.disableTwoFactor as jest.Mock).mockResolvedValue({
wasSucccessful: () => false,
exception: { code: "500", message: "test_message" },
} as unknown as ReturnType<typeof fusionAuthClient.disableTwoFactor>);
await agent
.post("/api/v2/idpuser/disable-two-factor")
.send({ methodId: "test_method_id", code: "test_code" })
.expect(500);
});

test("should return status 500 if the database call fails", async () => {
jest.spyOn(db, "sql").mockRejectedValueOnce(new Error("Database error"));
await agent
.post("/api/v2/idpuser/disable-two-factor")
.send({ methodId: "test_method_id", code: "test_code" })
.expect(500);
});
});
20 changes: 20 additions & 0 deletions packages/api/src/idpuser/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ import {
validateSendEnableCodeRequest,
validateSendDisableCodeRequest,
validateCreateTwoFactorMethodRequest,
validateDisableTwoFactorRequest,
} from "./validators";
import {
getTwoFactorMethods,
sendEnableCode,
addTwoFactorMethod,
sendDisableCode,
removeTwoFactorMethod,
} from "./service";

export const idpUserController = Router();
Expand Down Expand Up @@ -93,3 +95,21 @@ idpUserController.post(
}
}
);

idpUserController.post(
"/disable-two-factor",
verifyUserAuthentication,
async (req: Request, res: Response, next: NextFunction) => {
try {
validateDisableTwoFactorRequest(req.body);
await removeTwoFactorMethod(req.body);
res.send(200);
} catch (err) {
if (isValidationError(err)) {
res.status(400).json({ error: err });
return;
}
next(err);
}
}
);
6 changes: 6 additions & 0 deletions packages/api/src/idpuser/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ export interface SendDisableCodeRequest {
methodId: string;
}

export interface DisableTwoFactorRequest {
emailFromAuthToken: string;
methodId: string;
code: string;
}

export enum TwoFactorMethod {
Email = "email",
Sms = "sms",
Expand Down
27 changes: 27 additions & 0 deletions packages/api/src/idpuser/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type SendEnableCodeRequest,
type CreateTwoFactorMethodRequest,
type SendDisableCodeRequest,
type DisableTwoFactorRequest,
type TwoFactorRequestResponse,
} from "./models";
import { db } from "../database";
Expand Down Expand Up @@ -143,3 +144,29 @@ export const sendDisableCode = async (
);
}
};

export const removeTwoFactorMethod = async (
requestBody: DisableTwoFactorRequest
): Promise<void> => {
const fusionAuthUserIdResponse = await db.sql<{ subject: string }>(
"idpuser.queries.get_subject_by_email",
{
email: requestBody.emailFromAuthToken,
}
);
if (!fusionAuthUserIdResponse.rows[0]) {
throw createError.NotFound("User not found");
}

const fusionAuthResponse = await fusionAuthClient.disableTwoFactor(
fusionAuthUserIdResponse.rows[0].subject,
requestBody.methodId,
requestBody.code
);
if (!fusionAuthResponse.wasSuccessful()) {
throw createError(
parseInt(fusionAuthResponse.exception.code ?? "500", 10),
fusionAuthResponse.exception.message ?? "Unknown error"
);
}
};
16 changes: 16 additions & 0 deletions packages/api/src/idpuser/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
SendEnableCodeRequest,
SendDisableCodeRequest,
CreateTwoFactorMethodRequest,
DisableTwoFactorRequest,
} from "./models";

export function validateSendEnableCodeRequest(
Expand Down Expand Up @@ -55,3 +56,18 @@ export function validateSendDisableCodeRequest(
throw validation.error;
}
}

export function validateDisableTwoFactorRequest(
data: unknown
): asserts data is DisableTwoFactorRequest {
const validation = Joi.object()
.keys({
emailFromAuthToken: Joi.string().email().required(),
methodId: Joi.string().required(),
code: Joi.string().required(),
})
.validate(data);
if (validation.error) {
throw validation.error;
}
}
9 changes: 9 additions & 0 deletions packages/api/src/types/fusionauth.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,5 +78,14 @@ declare module "@fusionauth/typescript-client" {
exception: Error;
wasSuccessful: () => boolean;
}>;
public disableTwoFactor(
userId: string,
methodId: string,
code: string
): Promise<{
statusCode: number;
exception: Error;
wasSuccessful: () => boolean;
}>;
}
}

0 comments on commit b64cc68

Please sign in to comment.