Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 195 additions & 0 deletions app/api/2fa/disable/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import assert from "node:assert/strict";
import { mock, test, before } from "node:test";
import { NextRequest } from "next/server";
import bcrypt from "bcryptjs";

// ── Mutable state shared across mock closures ─────────────────────────────────
let mockSession: unknown = null;
let mockUser: unknown = null;
let capturedUpdateArgs: unknown = null;
let rateLimited = false;
let totpValid = false;
let recoveryConsumeResult: string | null = null;

// ── Register mocks synchronously BEFORE the route is imported ──────────────────
mock.module("next-auth", {
namedExports: {
getServerSession: () => Promise.resolve(mockSession),
},
});

mock.module("@/lib/auth", {
namedExports: { authOptions: {} },
});

mock.module("@/lib/rateLimit", {
namedExports: {
checkRateLimit: () => Promise.resolve(!rateLimited),
},
});

mock.module("@/lib/twoFactor", {
namedExports: {
verifyTotpCode: () => totpValid,
consumeRecoveryCode: () => recoveryConsumeResult,
},
});

mock.module("@/lib/prisma", {
defaultExport: {
user: {
findUnique: () => Promise.resolve(mockUser),
update: (args: unknown) => {
capturedUpdateArgs = args;
return Promise.resolve({ ...mockUser });

Check failure on line 44 in app/api/2fa/disable/route.test.ts

View workflow job for this annotation

GitHub Actions / ci

Spread types may only be created from object types.
},
},
},
namedExports: {
prisma: {
user: {
findUnique: () => Promise.resolve(mockUser),
update: (args: unknown) => {
capturedUpdateArgs = args;
return Promise.resolve({ ...mockUser });

Check failure on line 54 in app/api/2fa/disable/route.test.ts

View workflow job for this annotation

GitHub Actions / ci

Spread types may only be created from object types.
},
},
},
},
});

// eslint-disable-next-line @typescript-eslint/no-explicit-any
let POST: (...args: any[]) => Promise<Response>;

before(async () => {
const route = await import("@/app/api/2fa/disable/route");
POST = route.POST;
});

function makeReq(body: unknown) {
return new NextRequest("http://localhost/api/2fa/disable", {
method: "POST",
body: JSON.stringify(body),
});
}

function defaultUser() {
return {
id: "user-1",
email: "test@example.com",
password: bcrypt.hashSync("correct-password", 10),
totpSecret: "FAKE2FASECRET",
twoFactorEnabled: true,
recoveryCodes: "hash:ABC2345678",
};
}

test("returns 401 when unauthorized", async () => {
mockSession = null;
const res = await POST(makeReq({ password: "x", code: "123456" }));
assert.equal(res.status, 401);
assert.equal((await res.json()).error, "Unauthorized");
});

test("returns 429 when rate limited", async () => {
mockSession = { user: { id: "user-1" } };
rateLimited = true;
const res = await POST(makeReq({ code: "123456" }));
assert.equal(res.status, 429);
rateLimited = false;
});

test("returns 400 when the code is missing", async () => {
mockSession = { user: { id: "user-1" } };
mockUser = defaultUser();

const res = await POST(makeReq({ password: "correct-password" }));
assert.equal(res.status, 400);
assert.equal(
(await res.json()).error,
"Verification code is required"
);
});

test("returns 400 when 2FA is not enabled", async () => {
mockSession = { user: { id: "user-1" } };
mockUser = { ...defaultUser(), twoFactorEnabled: false };

const res = await POST(makeReq({ code: "123456" }));
assert.equal(res.status, 400);
assert.equal(
(await res.json()).error,
"Two-factor authentication is not enabled."
);
});

test("returns 400 when the password is missing", async () => {
mockSession = { user: { id: "user-1" } };
mockUser = defaultUser();

const res = await POST(makeReq({ code: "123456" }));
assert.equal(res.status, 400);
assert.equal((await res.json()).error, "Password is required");
});

test("returns 403 for an incorrect password", async () => {
mockSession = { user: { id: "user-1" } };
mockUser = defaultUser();

const res = await POST(makeReq({ password: "wrong-password", code: "123456" }));
assert.equal(res.status, 403);
assert.equal((await res.json()).error, "Incorrect password");
});

test("returns 400 when neither TOTP nor a recovery code matches", async () => {
mockSession = { user: { id: "user-1" } };
mockUser = defaultUser();
totpValid = false;
recoveryConsumeResult = null;

const res = await POST(makeReq({ password: "correct-password", code: "000000" }));
assert.equal(res.status, 400);
assert.equal((await res.json()).error, "Invalid verification code.");
});

test("disables 2FA after a valid TOTP code", async () => {
mockSession = { user: { id: "user-1" } };
mockUser = defaultUser();
totpValid = true;
recoveryConsumeResult = null;
capturedUpdateArgs = null;

const res = await POST(makeReq({ password: "correct-password", code: "123456" }));
assert.equal(res.status, 200);
assert.equal((await res.json()).success, true);

assert.deepEqual(capturedUpdateArgs, {
where: { id: "user-1" },
data: {
totpSecret: null,
twoFactorEnabled: false,
recoveryCodes: null,
},
});
});

test("disables 2FA after a valid recovery code", async () => {
mockSession = { user: { id: "user-1" } };
mockUser = defaultUser();
totpValid = false;
recoveryConsumeResult = "hash:XYZ9876543";
capturedUpdateArgs = null;

const res = await POST(makeReq({ password: "correct-password", code: "ABC2345678" }));
assert.equal(res.status, 200);
assert.equal((await res.json()).success, true);

assert.deepEqual(capturedUpdateArgs, {
where: { id: "user-1" },
data: {
totpSecret: null,
twoFactorEnabled: false,
recoveryCodes: null,
},
});
});
112 changes: 112 additions & 0 deletions app/api/2fa/disable/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import prisma from "@/lib/prisma";
import { checkRateLimit } from "@/lib/rateLimit";
import {
consumeRecoveryCode,
verifyTotpCode,
} from "@/lib/twoFactor";

export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const userId = session.user.id;

const allowed = await checkRateLimit(
`2fa-disable:${userId}`,
10,
15 * 60 * 1000
);
if (!allowed) {
return NextResponse.json(
{ error: "Too many attempts. Please try again later." },
{ status: 429 }
);
}

let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
}

const { password, code } = body as { password?: string; code?: string };
if (!code) {
return NextResponse.json(
{ error: "Verification code is required" },
{ status: 400 }
);
}

const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
password: true,
totpSecret: true,
twoFactorEnabled: true,
recoveryCodes: true,
},
});

if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}

if (!user.twoFactorEnabled) {
return NextResponse.json(
{ error: "Two-factor authentication is not enabled." },
{ status: 400 }
);
}

if (user.password) {
if (!password) {
return NextResponse.json(
{ error: "Password is required" },
{ status: 400 }
);
}

const isValid = await bcrypt.compare(password, user.password);
if (!isValid) {
return NextResponse.json(
{ error: "Incorrect password" },
{ status: 403 }
);
}
}

const isTotpValid =
user.totpSecret && verifyTotpCode(user.totpSecret, code);

if (!isTotpValid && consumeRecoveryCode(user.recoveryCodes, code) === null) {
return NextResponse.json(
{ error: "Invalid verification code." },
{ status: 400 }
);
}

await prisma.user.update({
where: { id: user.id },
data: {
totpSecret: null,
twoFactorEnabled: false,
recoveryCodes: null,
},
});

return NextResponse.json({ success: true }, { status: 200 });
} catch (error) {
console.error("2FA disable error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
Loading
Loading