feat: shared login with session cookie
This commit is contained in:
1 parent
159b549bf7
commit
73295653ee
8 files changed
+215
-9
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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; }
|
||||
}
|
||||
@@ -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).*)"] };
|
||||
Generated
+29
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user