fix(auth): correct local cookie security configuration

This commit is contained in:
Schubert Ferenc 2026-07-11 15:50:15 +02:00
parent b584e60273
commit 155fdbb16a
67 changed files with 1003 additions and 62 deletions

View file

@ -2,32 +2,79 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { LogIn } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { AuthProvider, useAuth } from "@/components/auth";
import { BrandLogo } from "@/components/brand/brand-logo";
import { login } from "@/lib/api";
import { login, User } from "@/lib/api";
const schema = z.object({
email: z.string().email(),
password: z.string().min(8)
email: z.string().email("Bitte gib eine gueltige E-Mail-Adresse ein."),
password: z.string().min(1, "Bitte gib dein Passwort ein.")
});
type LoginForm = z.infer<typeof schema>;
type LoginError = { status: number; message: string };
function mapLoginError(error: unknown): LoginError {
const status = typeof error === "object" && error && "status" in error ? Number((error as { status?: number }).status) : 0;
switch (status) {
case 401:
return { status, message: "E-Mail-Adresse oder Passwort ist falsch." };
case 403:
return { status, message: "Dieses Benutzerkonto ist deaktiviert." };
case 422:
return { status, message: "Bitte pruefe deine Eingaben." };
case 500:
case 502:
return { status, message: "Der Anmeldedienst ist momentan nicht erreichbar." };
default:
return { status, message: "Anmeldung fehlgeschlagen. Bitte erneut versuchen." };
}
}
function LoginPanel() {
const router = useRouter();
const auth = useAuth();
const [loading, setLoading] = useState(false);
const [toast, setToast] = useState("");
const [success, setSuccess] = useState("");
const form = useForm<LoginForm>({
resolver: zodResolver(schema),
defaultValues: { email: "admin@schubamed.de", password: "" }
defaultValues: { email: "admin@schubamed.de", password: "" },
mode: "onSubmit"
});
async function onSubmit(values: LoginForm) {
const result = await login(values.email, values.password);
auth.setToken(result.access_token);
router.push("/dashboard");
useEffect(() => {
if (!toast) return;
const timeout = window.setTimeout(() => setToast(""), 5000);
return () => window.clearTimeout(timeout);
}, [toast]);
async function handleSubmit(values: LoginForm) {
setLoading(true);
setToast("");
setSuccess("");
try {
const result = await login(values.email, values.password);
const currentUser = (await auth.refreshUser()) ?? (result.user as User);
auth.setUser(currentUser);
auth.setToken("authenticated");
const target = currentUser.must_change_password ? "/profile/security" : "/dashboard";
setSuccess("Anmeldung erfolgreich");
window.location.replace(target);
} catch (error) {
const mapped = mapLoginError(error);
if (process.env.NODE_ENV !== "production") {
console.error("Login failed", error);
console.error("Login failed mapped", mapped.status, mapped.message);
}
setToast(mapped.message);
} finally {
setLoading(false);
}
}
return (
@ -38,7 +85,14 @@ function LoginPanel() {
<h1 className="mt-2 text-3xl font-semibold text-text">Anmelden</h1>
<p className="mt-3 text-sm leading-6 text-text-light">Sicherer Zugriff auf Atlas Workspace.</p>
</div>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-5">
<form
onSubmit={(event) => {
event.preventDefault();
void form.handleSubmit(handleSubmit)(event);
}}
className="space-y-5"
noValidate
>
<label className="block">
<span className="text-sm font-medium text-text">E-Mail</span>
<input
@ -46,6 +100,9 @@ function LoginPanel() {
className="mt-2 h-12 w-full rounded-lg border border-border bg-white px-4 outline-none transition focus:border-primary"
{...form.register("email")}
/>
{form.formState.errors.email && (
<p className="mt-2 text-sm text-danger">{form.formState.errors.email.message}</p>
)}
</label>
<label className="block">
<span className="text-sm font-medium text-text">Passwort</span>
@ -54,13 +111,19 @@ function LoginPanel() {
className="mt-2 h-12 w-full rounded-lg border border-border bg-white px-4 outline-none transition focus:border-primary"
{...form.register("password")}
/>
{form.formState.errors.password && (
<p className="mt-2 text-sm text-danger">{form.formState.errors.password.message}</p>
)}
</label>
{toast && <div className="rounded-lg border border-danger/30 bg-white px-4 py-3 text-sm text-danger shadow-soft">{toast}</div>}
{success && <div className="rounded-lg border border-success/30 bg-white px-4 py-3 text-sm text-success shadow-soft">{success}</div>}
<button
type="submit"
className="flex h-12 w-full items-center justify-center gap-2 rounded-lg bg-primary px-5 font-semibold text-white shadow-soft transition hover:bg-primary-dark"
disabled={loading}
className="flex h-12 w-full items-center justify-center gap-2 rounded-lg bg-primary px-5 font-semibold text-white shadow-soft transition hover:bg-primary-dark disabled:cursor-not-allowed disabled:opacity-60"
>
<LogIn className="h-5 w-5" />
Einloggen
{loading ? <span className="spinner" /> : <LogIn className="h-5 w-5" />}
<span>{loading ? "Anmeldung läuft…" : "Anmelden"}</span>
</button>
</form>
</section>

View file

@ -0,0 +1,32 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { AUTH_COOKIE_NAME, buildAuthCookieOptions } from "@/lib/auth";
const mercuryBase = `${process.env.MERCURY_INTERNAL_URL ?? "http://mercury-api:8000"}/api/v1`;
export async function POST(request: Request) {
const body = await request.json();
try {
const response = await fetch(`${mercuryBase}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
const data = await response.json();
if (!response.ok) {
return NextResponse.json(data, { status: response.status });
}
const cookieStore = await cookies();
cookieStore.set({
name: AUTH_COOKIE_NAME,
value: data.access_token,
...buildAuthCookieOptions(data.expires_in)
});
return NextResponse.json({ user: data.user, expires_in: data.expires_in });
} catch (error) {
return NextResponse.json(
{ detail: "Der Anmeldedienst ist momentan nicht erreichbar." },
{ status: 502 }
);
}
}

View file

@ -0,0 +1,13 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { AUTH_COOKIE_NAME, buildAuthCookieOptions } from "@/lib/auth";
export async function POST() {
const cookieStore = await cookies();
cookieStore.set({
name: AUTH_COOKIE_NAME,
value: "",
...buildAuthCookieOptions(0)
});
return NextResponse.json({ status: "ok" });
}

View file

@ -0,0 +1,18 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { AUTH_COOKIE_NAME } from "@/lib/auth";
const mercuryBase = `${process.env.MERCURY_INTERNAL_URL ?? "http://mercury-api:8000"}/api/v1`;
export async function GET() {
const cookieStore = await cookies();
const token = cookieStore.get(AUTH_COOKIE_NAME)?.value;
if (!token) {
return NextResponse.json({ detail: "Not authenticated" }, { status: 401 });
}
const response = await fetch(`${mercuryBase}/auth/me`, {
headers: { Authorization: `Bearer ${token}` }
});
const data = await response.json();
return NextResponse.json(data, { status: response.status });
}

View file

@ -0,0 +1,41 @@
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import { AUTH_COOKIE_NAME } from "@/lib/auth";
const mercuryBase = `${process.env.MERCURY_INTERNAL_URL ?? "http://mercury-api:8000"}/api/v1`;
async function forward(request: NextRequest, method: string, path: string[]) {
const cookieStore = await cookies();
const token = cookieStore.get(AUTH_COOKIE_NAME)?.value;
if (!token) {
return NextResponse.json({ detail: "Not authenticated" }, { status: 401 });
}
const url = new URL(`${mercuryBase}/${path.join("/")}${request.nextUrl.search}`);
const headers = new Headers(request.headers);
headers.set("Authorization", `Bearer ${token}`);
headers.delete("host");
headers.delete("cookie");
const init: RequestInit = { method, headers };
if (method !== "GET" && method !== "HEAD") {
init.body = await request.text();
}
const response = await fetch(url, init);
const contentType = response.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
return NextResponse.json(await response.json(), { status: response.status });
}
return new NextResponse(response.body, { status: response.status, headers: { "content-type": contentType } });
}
export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
return forward(request, "GET", (await params).path);
}
export async function POST(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
return forward(request, "POST", (await params).path);
}
export async function PUT(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
return forward(request, "PUT", (await params).path);
}
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
return forward(request, "DELETE", (await params).path);
}