Add maintenance and trip history components, admin session management, and session activity tracking
This commit is contained in:
+86
-7
@@ -1,6 +1,7 @@
|
||||
import { compareSync } from "bcryptjs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { jwtVerify, SignJWT } from "jose";
|
||||
import { cookies } from "next/headers";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { db, initDatabase } from "./db";
|
||||
import { getAuthSecret } from "./env";
|
||||
@@ -8,6 +9,7 @@ import type { SessionUser, UserRole } from "./types";
|
||||
|
||||
type SessionPayload = {
|
||||
id: number;
|
||||
sid: string;
|
||||
exp: number;
|
||||
};
|
||||
|
||||
@@ -26,8 +28,35 @@ function getSecretKey() {
|
||||
return new TextEncoder().encode(getAuthSecret());
|
||||
}
|
||||
|
||||
export async function signSession(user: SessionUser) {
|
||||
return new SignJWT({ id: user.id })
|
||||
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`)
|
||||
@@ -35,7 +64,14 @@ export async function signSession(user: SessionUser) {
|
||||
}
|
||||
|
||||
export async function setSessionCookie(user: SessionUser) {
|
||||
const token = await signSession(user);
|
||||
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,
|
||||
@@ -48,7 +84,22 @@ export async function setSessionCookie(user: SessionUser) {
|
||||
}
|
||||
|
||||
export async function clearSessionCookie() {
|
||||
(await cookies()).delete(SESSION_COOKIE);
|
||||
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) {
|
||||
@@ -93,18 +144,46 @@ export async function getSessionUser(): Promise<SessionUser | 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 id, name, email, role FROM users WHERE id = ? AND active = 1")
|
||||
.get(Number(payload.id)) as
|
||||
.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");
|
||||
|
||||
Reference in New Issue
Block a user