v1.5: Selbst gehostete Auth (Passwort + Passkeys), Postgres statt Supabase
Fundament: db/migrations/0001_init.sql (users/sessions/passkey_credentials/ webauthn_challenges + Inhaltstabellen ohne RLS), db/migrate.mjs (idempotenter Migrationsrunner), lib/db (pg-Pool + query-Wrapper), lib/auth/password.ts (Node-crypto.scrypt, keine neue Abhängigkeit), lib/auth/session.ts (opake DB-Session-Tokens, Sliding-Refresh in proxy.ts). Passkeys: @simplewebauthn/server + /browser ersetzen Supabase's experimentelles WebAuthn-API (dafür gab es bisher kein eigenes Credential-Schema); RP-Konfiguration kommt jetzt direkt aus APP_URL statt über scripts/configure-passkeys.mjs und die Supabase-Management-API. scripts/bootstrap-admin.mjs legt bei leerer users-Tabelle automatisch einen Admin mit zufälligem Passwort an und hängt die Zugangsdaten an ZUGANGSDATEN.md an (idempotent, via predev/prestart-Hooks aufgerufen). Bugfix im Zuge des Umbaus: "mind. 1 Passkey nötig, um Passwort-Login zu deaktivieren" wurde bisher nur clientseitig geprüft – jetzt zusätzlich serverseitig in PATCH /api/account. E-Mail-Änderung im eigenen Konto läuft jetzt direkt nach Passwort-Bestätigung statt über eine Supabase-Bestätigungsmail (kein SMTP-Server vorhanden). Rest der Anwendung (Gerichte/Essenszeiten/Kennzeichnung/Vorschläge/ Benutzerverwaltung/Branding) nutzt noch den alten Supabase-Client – folgt in weiteren Commits.
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { query, queryOne } from "@/lib/db";
|
||||
import { verifyPassword } from "@/lib/auth/password";
|
||||
import { requireSession, UnauthorizedError } from "@/lib/auth/session";
|
||||
import { isSameOrigin } from "@/lib/origin";
|
||||
|
||||
// Ändert die E-Mail-Adresse direkt nach Passwort-Bestätigung – ohne
|
||||
// Bestätigungsmail, da kein SMTP-Server betrieben wird.
|
||||
export async function PATCH(request: Request) {
|
||||
if (!isSameOrigin(request)) {
|
||||
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
|
||||
}
|
||||
try {
|
||||
const session = await requireSession();
|
||||
const { email, password } = (await request.json().catch(() => ({}))) as {
|
||||
email?: string;
|
||||
password?: string;
|
||||
};
|
||||
if (!email || !password) {
|
||||
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await queryOne<{ password_hash: string }>(
|
||||
"select password_hash from users where id = $1",
|
||||
[session.user.id]
|
||||
);
|
||||
if (!user || !verifyPassword(password, user.password_hash)) {
|
||||
return NextResponse.json({ error: "wrong_password" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
await query("update users set email = $1 where id = $2", [
|
||||
email.trim().toLowerCase(),
|
||||
session.user.id,
|
||||
]);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && /unique/i.test(err.message)) {
|
||||
return NextResponse.json({ error: "email_used" }, { status: 409 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) {
|
||||
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { query, queryOne } from "@/lib/db";
|
||||
import { hashPassword, verifyPassword } from "@/lib/auth/password";
|
||||
import { requireSession, UnauthorizedError } from "@/lib/auth/session";
|
||||
import { isSameOrigin } from "@/lib/origin";
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
if (!isSameOrigin(request)) {
|
||||
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
|
||||
}
|
||||
try {
|
||||
const session = await requireSession();
|
||||
const { currentPassword, newPassword } = (await request.json().catch(() => ({}))) as {
|
||||
currentPassword?: string;
|
||||
newPassword?: string;
|
||||
};
|
||||
if (!currentPassword || !newPassword || newPassword.length < 8) {
|
||||
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await queryOne<{ password_hash: string }>(
|
||||
"select password_hash from users where id = $1",
|
||||
[session.user.id]
|
||||
);
|
||||
if (!user || !verifyPassword(currentPassword, user.password_hash)) {
|
||||
return NextResponse.json({ error: "wrong_password" }, { status: 401 });
|
||||
}
|
||||
|
||||
await query("update users set password_hash = $1 where id = $2", [
|
||||
hashPassword(newPassword),
|
||||
session.user.id,
|
||||
]);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) {
|
||||
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { query, queryOne } from "@/lib/db";
|
||||
import { requireSession, UnauthorizedError } from "@/lib/auth/session";
|
||||
import { isSameOrigin } from "@/lib/origin";
|
||||
|
||||
// Nimmt bewusst NUR full_name/password_auth_enabled aus dem Body an –
|
||||
// ersetzt den früheren Postgres-Column-Grant, der self-service-Updates
|
||||
// auf "role"/"email" verhindert hat. Ein generisches "update the whole
|
||||
// body"-Endpoint würde diese Absicherung wieder aufheben.
|
||||
export async function PATCH(request: Request) {
|
||||
if (!isSameOrigin(request)) {
|
||||
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
|
||||
}
|
||||
try {
|
||||
const session = await requireSession();
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
full_name?: string;
|
||||
password_auth_enabled?: boolean;
|
||||
};
|
||||
|
||||
if (body.password_auth_enabled === false) {
|
||||
const passkeys = await queryOne<{ count: string }>(
|
||||
"select count(*)::int as count from passkey_credentials where user_id = $1",
|
||||
[session.user.id]
|
||||
);
|
||||
if (!passkeys || Number(passkeys.count) === 0) {
|
||||
return NextResponse.json({ error: "needs_passkey" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (body.full_name !== undefined) {
|
||||
const name = body.full_name.trim();
|
||||
if (!name) return NextResponse.json({ error: "name_required" }, { status: 400 });
|
||||
await query("update users set full_name = $1 where id = $2", [name, session.user.id]);
|
||||
}
|
||||
if (body.password_auth_enabled !== undefined) {
|
||||
await query("update users set password_auth_enabled = $1 where id = $2", [
|
||||
body.password_auth_enabled,
|
||||
session.user.id,
|
||||
]);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) {
|
||||
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { queryOne } from "@/lib/db";
|
||||
import { verifyPassword } from "@/lib/auth/password";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { isSameOrigin } from "@/lib/origin";
|
||||
import type { Role } from "@/lib/types";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isSameOrigin(request)) {
|
||||
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
|
||||
}
|
||||
|
||||
const { email, password } = (await request.json().catch(() => ({}))) as {
|
||||
email?: string;
|
||||
password?: string;
|
||||
};
|
||||
if (!email || !password) {
|
||||
return NextResponse.json({ error: "invalid_credentials" }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await queryOne<{
|
||||
id: string;
|
||||
password_hash: string;
|
||||
role: Role;
|
||||
password_auth_enabled: boolean;
|
||||
}>(
|
||||
"select id, password_hash, role, password_auth_enabled from users where email = $1",
|
||||
[email.trim().toLowerCase()]
|
||||
);
|
||||
|
||||
if (!user || !verifyPassword(password, user.password_hash)) {
|
||||
return NextResponse.json({ error: "invalid_credentials" }, { status: 401 });
|
||||
}
|
||||
if (!user.password_auth_enabled) {
|
||||
return NextResponse.json({ error: "password_disabled" }, { status: 403 });
|
||||
}
|
||||
|
||||
await createSession(user.id);
|
||||
return NextResponse.json({ role: user.role });
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { query } from "@/lib/db";
|
||||
import { requireSession, UnauthorizedError } from "@/lib/auth/session";
|
||||
import { isSameOrigin } from "@/lib/origin";
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
if (!isSameOrigin(request)) {
|
||||
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
|
||||
}
|
||||
try {
|
||||
const session = await requireSession();
|
||||
const { id } = await params;
|
||||
// user_id-Filter stellt sicher, dass nur eigene Credentials gelöscht
|
||||
// werden können.
|
||||
await query("delete from passkey_credentials where id = $1 and user_id = $2", [
|
||||
id,
|
||||
session.user.id,
|
||||
]);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) {
|
||||
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { generateAuthenticationOptions } from "@simplewebauthn/server";
|
||||
import { rpID } from "@/lib/auth/webauthn-config";
|
||||
import { storeChallenge } from "@/lib/auth/webauthn-challenges";
|
||||
|
||||
// Passwortloser "usernameless"-Login: kein allowCredentials, der Browser
|
||||
// fragt den Nutzer, welchen registrierten Passkey er verwenden möchte.
|
||||
// user_id ist beim Speichern der Challenge noch unbekannt.
|
||||
export async function POST() {
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: rpID(),
|
||||
userVerification: "preferred",
|
||||
});
|
||||
await storeChallenge(options.challenge, "authentication", null);
|
||||
return NextResponse.json(options);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { verifyAuthenticationResponse } from "@simplewebauthn/server";
|
||||
import type {
|
||||
AuthenticationResponseJSON,
|
||||
AuthenticatorTransportFuture,
|
||||
} from "@simplewebauthn/server";
|
||||
import { query, queryOne } from "@/lib/db";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { origin, rpID } from "@/lib/auth/webauthn-config";
|
||||
import { consumeChallenge } from "@/lib/auth/webauthn-challenges";
|
||||
import { isSameOrigin } from "@/lib/origin";
|
||||
import type { Role } from "@/lib/types";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isSameOrigin(request)) {
|
||||
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = (await request.json()) as { response: AuthenticationResponseJSON };
|
||||
const challenge = extractChallengeFromClientData(body.response);
|
||||
const pending = await consumeChallenge(challenge, "authentication");
|
||||
if (!pending) {
|
||||
return NextResponse.json({ error: "challenge_expired" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Passkey-Login ist unabhängig von password_auth_enabled – dieses Flag
|
||||
// betrifft ausschließlich den Passwort-Login.
|
||||
const stored = await queryOne<{
|
||||
user_id: string;
|
||||
public_key: Buffer;
|
||||
counter: string;
|
||||
transports: AuthenticatorTransportFuture[];
|
||||
role: Role;
|
||||
}>(
|
||||
`select pc.user_id, pc.public_key, pc.counter, pc.transports, u.role
|
||||
from passkey_credentials pc join users u on u.id = pc.user_id
|
||||
where pc.credential_id = $1`,
|
||||
[body.response.id]
|
||||
);
|
||||
if (!stored) {
|
||||
return NextResponse.json({ error: "unknown_credential" }, { status: 400 });
|
||||
}
|
||||
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response: body.response,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: origin(),
|
||||
expectedRPID: rpID(),
|
||||
credential: {
|
||||
id: body.response.id,
|
||||
publicKey: new Uint8Array(stored.public_key),
|
||||
counter: Number(stored.counter),
|
||||
transports: stored.transports,
|
||||
},
|
||||
});
|
||||
|
||||
if (!verification.verified) {
|
||||
return NextResponse.json({ error: "verification_failed" }, { status: 400 });
|
||||
}
|
||||
|
||||
await query(
|
||||
"update passkey_credentials set counter = $1, last_used_at = now() where credential_id = $2",
|
||||
[verification.authenticationInfo.newCounter, body.response.id]
|
||||
);
|
||||
|
||||
await createSession(stored.user_id);
|
||||
return NextResponse.json({ role: stored.role });
|
||||
}
|
||||
|
||||
function extractChallengeFromClientData(response: AuthenticationResponseJSON): string {
|
||||
const json = JSON.parse(
|
||||
Buffer.from(response.response.clientDataJSON, "base64url").toString("utf8")
|
||||
) as { challenge: string };
|
||||
return json.challenge;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { generateRegistrationOptions } from "@simplewebauthn/server";
|
||||
import type { AuthenticatorTransportFuture } from "@simplewebauthn/server";
|
||||
import { query } from "@/lib/db";
|
||||
import { requireSession, UnauthorizedError } from "@/lib/auth/session";
|
||||
import { rpID, rpName } from "@/lib/auth/webauthn-config";
|
||||
import { storeChallenge } from "@/lib/auth/webauthn-challenges";
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const session = await requireSession();
|
||||
|
||||
const existing = await query<{
|
||||
credential_id: string;
|
||||
transports: AuthenticatorTransportFuture[];
|
||||
}>("select credential_id, transports from passkey_credentials where user_id = $1", [
|
||||
session.user.id,
|
||||
]);
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: rpName(),
|
||||
rpID: rpID(),
|
||||
userID: new TextEncoder().encode(session.user.id),
|
||||
userName: session.user.email,
|
||||
userDisplayName: session.user.full_name || session.user.email,
|
||||
attestationType: "none",
|
||||
excludeCredentials: existing.map((c) => ({
|
||||
id: c.credential_id,
|
||||
transports: c.transports,
|
||||
})),
|
||||
authenticatorSelection: {
|
||||
residentKey: "preferred",
|
||||
userVerification: "preferred",
|
||||
},
|
||||
});
|
||||
|
||||
await storeChallenge(options.challenge, "registration", session.user.id);
|
||||
return NextResponse.json(options);
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) {
|
||||
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { verifyRegistrationResponse } from "@simplewebauthn/server";
|
||||
import type { RegistrationResponseJSON } from "@simplewebauthn/server";
|
||||
import { query } from "@/lib/db";
|
||||
import { requireSession, UnauthorizedError } from "@/lib/auth/session";
|
||||
import { origin, rpID } from "@/lib/auth/webauthn-config";
|
||||
import { consumeChallenge } from "@/lib/auth/webauthn-challenges";
|
||||
import { isSameOrigin } from "@/lib/origin";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isSameOrigin(request)) {
|
||||
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
|
||||
}
|
||||
try {
|
||||
const session = await requireSession();
|
||||
const body = (await request.json()) as {
|
||||
response: RegistrationResponseJSON;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
const challenge = extractChallengeFromClientData(body.response);
|
||||
const pending = await consumeChallenge(challenge, "registration");
|
||||
if (!pending || pending.userId !== session.user.id) {
|
||||
return NextResponse.json({ error: "challenge_expired" }, { status: 400 });
|
||||
}
|
||||
|
||||
const verification = await verifyRegistrationResponse({
|
||||
response: body.response,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: origin(),
|
||||
expectedRPID: rpID(),
|
||||
});
|
||||
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
return NextResponse.json({ error: "verification_failed" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { credential } = verification.registrationInfo;
|
||||
await query(
|
||||
`insert into passkey_credentials (user_id, credential_id, public_key, counter, transports, name)
|
||||
values ($1, $2, $3, $4, $5, $6)`,
|
||||
[
|
||||
session.user.id,
|
||||
credential.id,
|
||||
Buffer.from(credential.publicKey),
|
||||
credential.counter,
|
||||
credential.transports ?? [],
|
||||
body.name ?? null,
|
||||
]
|
||||
);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) {
|
||||
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// clientDataJSON ist base64url-kodiertes JSON mit dem Feld "challenge" –
|
||||
// darüber wird die zugehörige, serverseitig gespeicherte Challenge
|
||||
// gefunden, ohne sie zusätzlich im Request-Body mitschicken zu müssen.
|
||||
function extractChallengeFromClientData(response: RegistrationResponseJSON): string {
|
||||
const json = JSON.parse(
|
||||
Buffer.from(response.response.clientDataJSON, "base64url").toString("utf8")
|
||||
) as { challenge: string };
|
||||
return json.challenge;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { query } from "@/lib/db";
|
||||
import { requireSession, UnauthorizedError } from "@/lib/auth/session";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await requireSession();
|
||||
const rows = await query<{
|
||||
id: string;
|
||||
name: string | null;
|
||||
created_at: string;
|
||||
last_used_at: string | null;
|
||||
}>(
|
||||
`select id, name, created_at, last_used_at from passkey_credentials
|
||||
where user_id = $1 order by created_at`,
|
||||
[session.user.id]
|
||||
);
|
||||
return NextResponse.json(rows);
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) {
|
||||
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
+32
-43
@@ -3,67 +3,48 @@
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { startAuthentication } from "@simplewebauthn/browser";
|
||||
import { formatMessage } from "@/lib/i18n/shared";
|
||||
import { useDict } from "@/components/i18n/LocaleProvider";
|
||||
import LanguageSwitcher from "@/components/i18n/LanguageSwitcher";
|
||||
import type { Dict } from "@/lib/i18n/shared";
|
||||
|
||||
function authErrorMessage(dict: Dict, message: string): string {
|
||||
if (/invalid login credentials/i.test(message))
|
||||
return dict.login.errorInvalidCredentials;
|
||||
if (/email not confirmed/i.test(message))
|
||||
return dict.login.errorEmailNotConfirmed;
|
||||
if (/cancelled|aborted|NotAllowedError/i.test(message))
|
||||
function authErrorMessage(dict: Dict, code: string): string {
|
||||
if (code === "invalid_credentials") return dict.login.errorInvalidCredentials;
|
||||
if (code === "password_disabled") return dict.login.errorPasswordDisabled;
|
||||
if (/cancelled|aborted|NotAllowedError/i.test(code))
|
||||
return dict.login.errorPasskeyCancelled;
|
||||
return formatMessage(dict.login.errorGeneric, { message });
|
||||
return formatMessage(dict.login.errorGeneric, { message: code });
|
||||
}
|
||||
|
||||
function redirectPath(role: string): string {
|
||||
return role === "admin" ? "/admin" : "/intern";
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const supabase = createClient();
|
||||
const dict = useDict();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function redirectByRole(userId: string) {
|
||||
const { data: profile } = await supabase
|
||||
.from("profiles")
|
||||
.select("role")
|
||||
.eq("id", userId)
|
||||
.single();
|
||||
router.replace(profile?.role === "admin" ? "/admin" : "/intern");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function signInWithPassword(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
if (error || !data.user) {
|
||||
setError(authErrorMessage(dict, error?.message ?? "Unbekannter Fehler"));
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setError(authErrorMessage(dict, data.error ?? "unknown"));
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: profile } = await supabase
|
||||
.from("profiles")
|
||||
.select("role, password_auth_enabled")
|
||||
.eq("id", data.user.id)
|
||||
.single();
|
||||
if (profile?.password_auth_enabled === false) {
|
||||
await supabase.auth.signOut();
|
||||
setError(dict.login.errorPasswordDisabled);
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
router.replace(profile?.role === "admin" ? "/admin" : "/intern");
|
||||
router.replace(redirectPath(data.role));
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
@@ -71,15 +52,23 @@ export default function LoginPage() {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const { data, error } = await supabase.auth.signInWithPasskey();
|
||||
if (error || !data?.user) {
|
||||
setError(
|
||||
authErrorMessage(dict, error?.message ?? "Passkey-Anmeldung fehlgeschlagen")
|
||||
);
|
||||
const optionsRes = await fetch("/api/auth/passkeys/login-options", { method: "POST" });
|
||||
const options = await optionsRes.json();
|
||||
const response = await startAuthentication({ optionsJSON: options });
|
||||
|
||||
const verifyRes = await fetch("/api/auth/passkeys/login-verify", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ response }),
|
||||
});
|
||||
const data = await verifyRes.json().catch(() => ({}));
|
||||
if (!verifyRes.ok) {
|
||||
setError(authErrorMessage(dict, data.error ?? "unknown"));
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
await redirectByRole(data.user.id);
|
||||
router.replace(redirectPath(data.role));
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setError(authErrorMessage(dict, err instanceof Error ? err.message : String(err)));
|
||||
setBusy(false);
|
||||
|
||||
+2
-3
@@ -1,13 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { destroySession } from "@/lib/auth/session";
|
||||
import { isSameOrigin } from "@/lib/origin";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isSameOrigin(request)) {
|
||||
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
|
||||
}
|
||||
const supabase = await createClient();
|
||||
await supabase.auth.signOut();
|
||||
await destroySession();
|
||||
// Relative Weiterleitung: bei einer absoluten URL aus request.url landet
|
||||
// sonst die Server-Bindung (0.0.0.0) statt des aufgerufenen Hosts im
|
||||
// Location-Header.
|
||||
|
||||
Reference in New Issue
Block a user