Files
Moped-Tracking/src/lib/auth.ts
T

212 lines
6.4 KiB
TypeScript

import { compareSync } from "bcryptjs";
import { randomUUID } from "node:crypto";
import { jwtVerify, SignJWT } from "jose";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { db, initDatabase } from "./db";
import { getAuthSecret } from "./env";
import type { SessionUser, UserRole } from "./types";
type SessionPayload = {
id: number;
sid: string;
exp: number;
};
type AdminMfaPayload = {
uid: number;
purpose: "admin-mfa";
exp: number;
};
export const SESSION_COOKIE = "moped_session";
export const ADMIN_MFA_COOKIE = "moped_admin_mfa";
const SESSION_TTL_SECONDS = 60 * 60 * 24 * 30;
const ADMIN_MFA_TTL_SECONDS = 60 * 5;
function getSecretKey() {
return new TextEncoder().encode(getAuthSecret());
}
type SessionMetadata = {
userAgent?: string | null;
ipAddress?: string | null;
lastPath?: string | null;
};
function cleanMetadata(value: string | null | undefined, max: number) {
const cleaned = value?.replace(/[\u0000-\u001f]/g, "").trim();
return cleaned ? cleaned.slice(0, max) : null;
}
export async function createSessionToken(user: SessionUser, metadata: SessionMetadata = {}) {
initDatabase();
const sessionId = randomUUID();
const expiresAt = new Date(Date.now() + SESSION_TTL_SECONDS * 1000).toISOString();
db.prepare(
`INSERT INTO user_sessions
(id, user_id, user_agent, ip_address, last_path, expires_at)
VALUES (?, ?, ?, ?, ?, ?)`,
).run(
sessionId,
user.id,
cleanMetadata(metadata.userAgent, 500),
cleanMetadata(metadata.ipAddress, 100),
cleanMetadata(metadata.lastPath, 500),
expiresAt,
);
return new SignJWT({ id: user.id, sid: sessionId })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime(`${SESSION_TTL_SECONDS}s`)
.sign(getSecretKey());
}
export async function setSessionCookie(user: SessionUser) {
const requestHeaders = await headers();
const token = await createSessionToken(user, {
userAgent: requestHeaders.get("user-agent"),
ipAddress:
requestHeaders.get("x-forwarded-for")?.split(",")[0] ??
requestHeaders.get("x-real-ip"),
lastPath: "/",
});
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: SESSION_TTL_SECONDS,
path: "/",
priority: "high",
});
}
export async function clearSessionCookie() {
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE)?.value;
if (token) {
try {
const result = await jwtVerify(token, getSecretKey());
const payload = result.payload as unknown as SessionPayload;
if (typeof payload.sid === "string") {
initDatabase();
db.prepare("UPDATE user_sessions SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?")
.run(payload.sid);
}
} catch {
// The local cookie is removed even if it is already invalid.
}
}
cookieStore.delete(SESSION_COOKIE);
}
export async function setPendingAdminMfaCookie(userId: number) {
const token = await new SignJWT({ uid: userId, purpose: "admin-mfa" })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime(`${ADMIN_MFA_TTL_SECONDS}s`)
.sign(getSecretKey());
const cookieStore = await cookies();
cookieStore.set(ADMIN_MFA_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: ADMIN_MFA_TTL_SECONDS,
path: "/",
priority: "high",
});
}
export async function getPendingAdminMfaUserId() {
const token = (await cookies()).get(ADMIN_MFA_COOKIE)?.value;
if (!token) return null;
try {
const result = await jwtVerify(token, getSecretKey());
const payload = result.payload as unknown as AdminMfaPayload;
return Number.isSafeInteger(payload.uid) && payload.uid > 0 && payload.purpose === "admin-mfa"
? payload.uid
: null;
} catch {
return null;
}
}
export async function clearPendingAdminMfaCookie() {
(await cookies()).delete(ADMIN_MFA_COOKIE);
}
export async function getSessionUser(): Promise<SessionUser | null> {
const token = (await cookies()).get(SESSION_COOKIE)?.value;
if (!token) return null;
try {
const result = await jwtVerify(token, getSecretKey());
const payload = result.payload as unknown as SessionPayload;
if (typeof payload.sid !== "string" || !payload.sid) return null;
initDatabase();
const user = db
.prepare(
`SELECT u.id, u.name, u.email, u.role
FROM user_sessions s
JOIN users u ON u.id = s.user_id
WHERE s.id = ? AND s.user_id = ?
AND s.revoked_at IS NULL
AND datetime(s.expires_at) > CURRENT_TIMESTAMP
AND u.active = 1`,
)
.get(payload.sid, Number(payload.id)) as
| { id: number; name: string; email: string; role: UserRole }
| undefined;
if (!user) return null;
db.prepare(
`UPDATE user_sessions
SET last_seen_at = CURRENT_TIMESTAMP
WHERE id = ?
AND last_seen_at < datetime('now', '-30 seconds')`,
).run(payload.sid);
return user ?? null;
} catch {
return null;
}
}
export async function getCurrentSessionId() {
const token = (await cookies()).get(SESSION_COOKIE)?.value;
if (!token) return null;
try {
const result = await jwtVerify(token, getSecretKey());
const payload = result.payload as unknown as SessionPayload;
return typeof payload.sid === "string" && payload.sid ? payload.sid : null;
} catch {
return null;
}
}
export async function requireSessionUser() {
const user = await getSessionUser();
if (!user) redirect("/login");
return user;
}
export async function requireAdminUser() {
const user = await requireSessionUser();
if (user.role !== "admin") redirect("/?error=Nur%20f%C3%BCr%20Administratoren.");
return user;
}
export function authenticateUser(username: string, password: string): SessionUser | null {
initDatabase();
const users = db
.prepare(
"SELECT id, name, email, role, password_hash FROM users WHERE name = ? COLLATE NOCASE AND active = 1 ORDER BY id ASC",
)
.all(username.trim()) as (SessionUser & { password_hash: string })[];
if (users.length !== 1) return null;
const user = users[0];
if (!user || !compareSync(password, user.password_hash)) return null;
return { id: user.id, name: user.name, email: user.email, role: user.role };
}