feat: shared login with session cookie

This commit is contained in:
opencode committed 2026-10-18 10:29:32 +02:00
1 parent 159b549bf7
commit 73295653ee
8 files changed
+215 -9

No files matched your search

+50
View File
@@ -0,0 +1,50 @@
import { NextResponse } from "next/server";
import { verifyPassword, createSession, SESSION_COOKIE } from "../../../../lib/auth";
const WINDOW_MS = 60_000;
const MAX_ATTEMPTS = 10;
const attempts = new Map<string, { count: number; reset: number }>();
function rateLimited(key: string): boolean {
const now = Date.now();
const entry = attempts.get(key);
if (!entry || now > entry.reset) {
attempts.set(key, { count: 1, reset: now + WINDOW_MS });
return false;
}
entry.count += 1;
return entry.count > MAX_ATTEMPTS;
}
export async function POST(req: Request) {
const ip = req.headers.get("x-forwarded-for") ?? "local";
if (rateLimited(ip)) {
return NextResponse.json({ error: "Zu viele Versuche" }, { status: 429 });
}
let body: { user?: string; password?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Zugangsdaten ungültig" }, { status: 401 });
}
const appUser = process.env.APP_USER ?? "";
const appHash = process.env.APP_PASSWORD_HASH ?? "";
const ok =
typeof body.user === "string" &&
typeof body.password === "string" &&
body.user === appUser &&
(await verifyPassword(body.password, appHash));
if (!ok) {
return NextResponse.json({ error: "Zugangsdaten ungültig" }, { status: 401 });
}
const token = await createSession(appUser);
const res = NextResponse.json({ ok: true });
res.cookies.set(SESSION_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 30,
});
return res;
}
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { SESSION_COOKIE } from "../../../../lib/auth";
export async function POST() {
const res = NextResponse.json({ ok: true });
res.cookies.set(SESSION_COOKIE, "", { path: "/", maxAge: 0 });
return res;
}
export async function GET(req: Request) {
const res = NextResponse.redirect(new URL("/login", req.url));
res.cookies.set(SESSION_COOKIE, "", { path: "/", maxAge: 0 });
return res;
}
+59
View File
@@ -0,0 +1,59 @@
"use client";
import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
export default function LoginPage() {
const router = useRouter();
const [user, setUser] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function onSubmit(e: FormEvent) {
e.preventDefault();
setLoading(true);
setError("");
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user, password }),
});
if (res.ok) {
router.push("/");
router.refresh();
} else {
setError("Zugangsdaten ungültig");
}
} catch {
setError("Zugangsdaten ungültig");
} finally {
setLoading(false);
}
}
return (
<main style={{ maxWidth: 360, margin: "4rem auto", padding: "0 1rem" }}>
<h1>Anmelden</h1>
<form onSubmit={onSubmit}>
<label>
Benutzer
<input value={user} onChange={(e) => setUser(e.target.value)} autoComplete="username" />
</label>
<label>
Passwort
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
</label>
{error && <p role="alert">{error}</p>}
<button type="submit" disabled={loading}>
{loading ? "…" : "Anmelden"}
</button>
</form>
</main>
);
}
+23
View File
@@ -0,0 +1,23 @@
import bcrypt from "bcryptjs";
import { SignJWT, jwtVerify } from "jose";
const COOKIE = "gt_session";
export const SESSION_COOKIE = COOKIE;
export async function verifyPassword(plain: string, hash: string): Promise<boolean> {
if (!plain || !hash) return false;
return bcrypt.compare(plain, hash);
}
function secret(): Uint8Array {
const s = process.env.SESSION_SECRET ?? "";
if (s.length < 32) throw new Error("SESSION_SECRET zu kurz");
return new TextEncoder().encode(s);
}
export async function createSession(user: string): Promise<string> {
return new SignJWT({ user }).setProtectedHeader({ alg: "HS256" }).setExpirationTime("30d").sign(secret());
}
export async function verifySession(token: string): Promise<{ user: string } | null> {
try {
const { payload } = await jwtVerify(token, secret());
if (typeof payload.user !== "string") return null;
return { user: payload.user };
} catch { return null; }
}
+11
View File
@@ -0,0 +1,11 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { verifySession, SESSION_COOKIE } from "./lib/auth";
export async function middleware(req: NextRequest) {
const p = req.nextUrl.pathname;
if (p.startsWith("/login") || p.startsWith("/api/auth")) return NextResponse.next();
const token = req.cookies.get(SESSION_COOKIE)?.value;
if (token && (await verifySession(token))) return NextResponse.next();
return NextResponse.redirect(new URL("/login", req.url));
}
export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"] };
+29
View File
@@ -19,6 +19,8 @@
"@types/bcryptjs": "2.4.6",
"@types/node": "22.7.0",
"@types/nodemailer": "6.4.16",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"typescript": "5.6.3",
"vitest": "3.0.0"
}
@@ -1651,6 +1653,26 @@
"@types/node": "*"
}
},
"node_modules/@types/react": {
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
"integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.3.0"
}
},
"node_modules/@vitest/expect": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.0.tgz",
@@ -1880,6 +1902,13 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+11 -9
View File
@@ -8,20 +8,22 @@
"test": "vitest run"
},
"dependencies": {
"next": "15.3.0",
"react": "19.0.0",
"react-dom": "19.0.0",
"prisma": "6.5.0",
"@prisma/client": "6.5.0",
"nodemailer": "6.9.14",
"bcryptjs": "2.4.3",
"jose": "5.9.6"
"jose": "5.9.6",
"next": "15.3.0",
"nodemailer": "6.9.14",
"prisma": "6.5.0",
"react": "19.0.0",
"react-dom": "19.0.0"
},
"devDependencies": {
"typescript": "5.6.3",
"vitest": "3.0.0",
"@types/bcryptjs": "2.4.6",
"@types/node": "22.7.0",
"@types/nodemailer": "6.4.16",
"@types/bcryptjs": "2.4.6"
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"typescript": "5.6.3",
"vitest": "3.0.0"
}
}
+18
View File
@@ -0,0 +1,18 @@
import { describe, it, expect } from "vitest";
import bcrypt from "bcryptjs";
import { verifyPassword, createSession, verifySession } from "../lib/auth";
describe("auth", () => {
it("rejects wrong password", async () => {
const hash = await bcrypt.hash("richtig-geheim", 10);
expect(await verifyPassword("falsch", hash)).toBe(false);
});
it("roundtrips session", async () => {
process.env.SESSION_SECRET = "test-secret-mindestens-32-zeichen-lang!";
const token = await createSession("haushalt");
expect(await verifySession(token)).toEqual({ user: "haushalt" });
});
it("rejects tampered token", async () => {
process.env.SESSION_SECRET = "test-secret-mindestens-32-zeichen-lang!";
expect(await verifySession("kaputt.token.hier")).toBeNull();
});
});