Extend TOTP 2FA to LDAP accounts; disable now requires a code, not a password
2FA was previously local-accounts-only, gated on the assumption that LDAP already has its own MFA story — that's not actually guaranteed (depends on what's behind the directory), so app-level TOTP is now available for LDAP accounts too, as a second factor independent of whatever the directory does or doesn't enforce. The login route's LDAP branch now checks user.totpEnabled the same way the local branch already did, routing through the same login-challenge flow before minting a session. This forced a change to disabling 2FA: it used to require the current password, but an LDAP-provisioned user has passwordHash: null — there's no password to check. Disable now requires a live TOTP code or a recovery code instead (same "prove you still hold the factor" idea, just checking the right thing), which works identically for local and LDAP accounts. Verified: re-ran the full local-account Playwright flow with the new code-based disable (still passes end-to-end). For the LDAP path — no real LDAP server available to log in through — flipped a throwaway test account to authSource="ldap"/passwordHash=null directly in the DB and exercised the setup/confirm/disable logic functions directly (same technique used earlier in this session for testing mail ingestion without a live IMAP server): setup no longer rejects it, confirm and disable both work correctly with no password present. Test account fully deleted after. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GteWhnWKTmnXcsd5jx6H7u
This commit is contained in:
1 parent
4ecb0e7698
commit
1a31f72dd8
5 files changed
+42
-29
No files matched your search
@@ -10,14 +10,10 @@ export default async function AccountSettingsPage() {
|
|||||||
<h1 className="mb-1 font-display text-xl font-bold tracking-tight">Аккаунт</h1>
|
<h1 className="mb-1 font-display text-xl font-bold tracking-tight">Аккаунт</h1>
|
||||||
<p className="mb-6 text-sm text-text-muted">{session?.user.email}</p>
|
<p className="mb-6 text-sm text-text-muted">{session?.user.email}</p>
|
||||||
<ChangePasswordForm />
|
<ChangePasswordForm />
|
||||||
{session?.user.authSource === "local" ? (
|
{session && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<TwoFactorSettings initialEnabled={session.user.totpEnabled} />
|
<TwoFactorSettings initialEnabled={session.user.totpEnabled} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<p className="mt-4 rounded-md border border-border bg-surface-hover px-3 py-2 text-sm text-text-muted">
|
|
||||||
Двухфакторная аутентификация недоступна для LDAP-аккаунтов — вход защищён на уровне домена.
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function TwoFactorSettings({ initialEnabled }: { initialEnabled: boolean
|
|||||||
const [code, setCode] = useState("");
|
const [code, setCode] = useState("");
|
||||||
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
|
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [password, setPassword] = useState("");
|
const [disableCode, setDisableCode] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ export function TwoFactorSettings({ initialEnabled }: { initialEnabled: boolean
|
|||||||
setQrDataUrl(null);
|
setQrDataUrl(null);
|
||||||
setSecret(null);
|
setSecret(null);
|
||||||
setCode("");
|
setCode("");
|
||||||
setPassword("");
|
setDisableCode("");
|
||||||
setError(null);
|
setError(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ export function TwoFactorSettings({ initialEnabled }: { initialEnabled: boolean
|
|||||||
const res = await fetch("/api/auth/totp/disable", {
|
const res = await fetch("/api/auth/totp/disable", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ password }),
|
body: JSON.stringify({ code: disableCode }),
|
||||||
});
|
});
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -164,12 +164,11 @@ export function TwoFactorSettings({ initialEnabled }: { initialEnabled: boolean
|
|||||||
<form onSubmit={disable2fa} className="card p-4">
|
<form onSubmit={disable2fa} className="card p-4">
|
||||||
<p className="mb-3 text-sm font-semibold">Отключить 2FA</p>
|
<p className="mb-3 text-sm font-semibold">Отключить 2FA</p>
|
||||||
<label className="mb-3 block text-sm">
|
<label className="mb-3 block text-sm">
|
||||||
<span className="mb-1 block font-medium text-text-muted">Текущий пароль</span>
|
<span className="mb-1 block font-medium text-text-muted">Код из приложения или резервный код</span>
|
||||||
<input
|
<input
|
||||||
required
|
required
|
||||||
type="password"
|
value={disableCode}
|
||||||
value={password}
|
onChange={(e) => setDisableCode(e.target.value)}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -57,6 +57,15 @@ export async function POST(request: Request) {
|
|||||||
defaultRole: ldapSettings?.defaultRole ?? "agent",
|
defaultRole: ldapSettings?.defaultRole ?? "agent",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// App-level 2FA is independent of whatever the directory itself does —
|
||||||
|
// useful when the directory doesn't enforce MFA, or as defense in depth
|
||||||
|
// when it does. Same challenge flow as the local-password branch below.
|
||||||
|
if (user.totpEnabled) {
|
||||||
|
const challengeToken = await createLoginChallenge(user.id);
|
||||||
|
await setLoginChallengeCookie(challengeToken);
|
||||||
|
return NextResponse.json({ ok: true, totpRequired: true });
|
||||||
|
}
|
||||||
|
|
||||||
const token = await createSession(user.id);
|
const token = await createSession(user.id);
|
||||||
await setSessionCookie(token);
|
await setSessionCookie(token);
|
||||||
|
|
||||||
@@ -79,8 +88,6 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: "Invalid email or password" }, { status: 401 });
|
return NextResponse.json({ error: "Invalid email or password" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2FA only ever applies to local password accounts (see api/auth/totp/setup) —
|
|
||||||
// an LDAP login never reaches this branch at all, it returns above.
|
|
||||||
if (user.totpEnabled) {
|
if (user.totpEnabled) {
|
||||||
const challengeToken = await createLoginChallenge(user.id);
|
const challengeToken = await createLoginChallenge(user.id);
|
||||||
await setLoginChallengeCookie(challengeToken);
|
await setLoginChallengeCookie(challengeToken);
|
||||||
|
|||||||
@@ -2,15 +2,22 @@ export const runtime = "nodejs";
|
|||||||
|
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, and, isNull } from "drizzle-orm";
|
||||||
import { requireSession } from "@/lib/auth/require";
|
import { requireSession } from "@/lib/auth/require";
|
||||||
import { verifyPassword } from "@/lib/auth/password";
|
|
||||||
import { db } from "@/lib/db/client";
|
import { db } from "@/lib/db/client";
|
||||||
import { users, totpRecoveryCodes } from "@/lib/db/schema";
|
import { users, totpRecoveryCodes } from "@/lib/db/schema";
|
||||||
|
import { decryptTotpSecret, verifyTotpCode, hashRecoveryCode } from "@/lib/auth/totp";
|
||||||
|
|
||||||
const schema = z.object({ password: z.string().min(1) });
|
const schema = z.object({ code: z.string().min(6).max(16) });
|
||||||
|
|
||||||
/** Requires the current password (not a TOTP code) — matches change-password's confirmation pattern, and means a stolen live session alone still can't turn off 2FA. */
|
/**
|
||||||
|
* Requires a live TOTP code or a recovery code — not the account password —
|
||||||
|
* so this works the same for local and LDAP-authenticated accounts (an
|
||||||
|
* LDAP account has no passwordHash to check here at all). Proving you still
|
||||||
|
* hold the second factor is what stops a stolen live session alone from
|
||||||
|
* turning 2FA off; a recovery code works too since losing your authenticator
|
||||||
|
* app shouldn't permanently lock you out of disabling it.
|
||||||
|
*/
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const { session, response } = await requireSession();
|
const { session, response } = await requireSession();
|
||||||
if (!session) return response;
|
if (!session) return response;
|
||||||
@@ -21,13 +28,23 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const user = await db.query.users.findFirst({ where: eq(users.id, session.user.id) });
|
const user = await db.query.users.findFirst({ where: eq(users.id, session.user.id) });
|
||||||
if (!user?.passwordHash) {
|
if (!user?.totpEnabled || !user.totpSecret) {
|
||||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
return NextResponse.json({ error: "2FA не включена" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const ok = await verifyPassword(user.passwordHash, parsed.data.password);
|
const code = parsed.data.code.trim();
|
||||||
if (!ok) {
|
let verified = /^\d{6}$/.test(code) && (await verifyTotpCode(decryptTotpSecret(user.totpSecret), code));
|
||||||
return NextResponse.json({ error: "Неверный пароль" }, { status: 401 });
|
|
||||||
|
if (!verified) {
|
||||||
|
const codeHash = hashRecoveryCode(code);
|
||||||
|
const match = await db.query.totpRecoveryCodes.findFirst({
|
||||||
|
where: and(eq(totpRecoveryCodes.userId, user.id), eq(totpRecoveryCodes.codeHash, codeHash), isNull(totpRecoveryCodes.usedAt)),
|
||||||
|
});
|
||||||
|
verified = Boolean(match);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verified) {
|
||||||
|
return NextResponse.json({ error: "Неверный код" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.update(users).set({ totpSecret: null, totpEnabled: false }).where(eq(users.id, user.id));
|
await db.update(users).set({ totpSecret: null, totpEnabled: false }).where(eq(users.id, user.id));
|
||||||
|
|||||||
@@ -20,12 +20,6 @@ export async function POST() {
|
|||||||
|
|
||||||
const user = await db.query.users.findFirst({ where: eq(users.id, session.user.id) });
|
const user = await db.query.users.findFirst({ where: eq(users.id, session.user.id) });
|
||||||
if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 });
|
if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||||
if (user.authSource !== "local") {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "2FA доступна только для локальных аккаунтов — вход через LDAP защищён на уровне домена" },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const secret = generateTotpSecret();
|
const secret = generateTotpSecret();
|
||||||
await db.update(users).set({ totpSecret: encryptTotpSecret(secret), totpEnabled: false }).where(eq(users.id, user.id));
|
await db.update(users).set({ totpSecret: encryptTotpSecret(secret), totpEnabled: false }).where(eq(users.id, user.id));
|
||||||
|
|||||||
Reference in new issue
Block a user