init
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { DatabaseContext } from "../db/database";
|
||||
import { sessions, users } from "../db/schema";
|
||||
|
||||
export async function createSession(context: DatabaseContext, userId: string, expiresAt: string) {
|
||||
const session = {
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt
|
||||
};
|
||||
|
||||
await context.db.insert(sessions).values(session);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function findSessionUser(context: DatabaseContext, sessionId: string) {
|
||||
const [row] = await context.db
|
||||
.select({ session: sessions, user: users })
|
||||
.from(sessions)
|
||||
.innerJoin(users, eq(sessions.userId, users.id))
|
||||
.where(eq(sessions.id, sessionId));
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (new Date(row.session.expiresAt).getTime() <= Date.now()) {
|
||||
await deleteSession(context, sessionId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.user.id,
|
||||
username: row.user.username,
|
||||
name: row.user.name,
|
||||
role: row.user.role as "customer" | "employee"
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteSession(context: DatabaseContext, sessionId: string) {
|
||||
await context.db.delete(sessions).where(eq(sessions.id, sessionId));
|
||||
}
|
||||
|
||||
export async function deleteUserSessions(context: DatabaseContext, userId: string) {
|
||||
await context.db.delete(sessions).where(eq(sessions.userId, userId));
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import type { AppWithContext } from "../app";
|
||||
import { buildApp } from "../app";
|
||||
|
||||
let app: AppWithContext;
|
||||
|
||||
function firstHeader(value: string | string[] | undefined) {
|
||||
return Array.isArray(value) ? value[0] : value ?? "";
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
app = await buildApp(":memory:");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe("auth routes", () => {
|
||||
test("returns null current user when logged out", async () => {
|
||||
const response = await app.inject({ method: "GET", url: "/api/me" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().user).toBeNull();
|
||||
});
|
||||
|
||||
test("logs in seeded customer by username", async () => {
|
||||
const response = await app.inject({ method: "POST", url: "/api/login", payload: { username: "john-smith" } });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().user.username).toBe("john-smith");
|
||||
expect(firstHeader(response.headers["set-cookie"])).toContain("orderops_session=");
|
||||
});
|
||||
|
||||
test("returns current user from session cookie", async () => {
|
||||
const login = await app.inject({ method: "POST", url: "/api/login", payload: { username: "john-smith" } });
|
||||
const cookie = firstHeader(login.headers["set-cookie"]);
|
||||
const response = await app.inject({ method: "GET", url: "/api/me", headers: { cookie } });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().user.role).toBe("customer");
|
||||
});
|
||||
|
||||
test("logs out and clears cookie", async () => {
|
||||
const login = await app.inject({ method: "POST", url: "/api/login", payload: { username: "john-smith" } });
|
||||
const cookie = firstHeader(login.headers["set-cookie"]);
|
||||
const response = await app.inject({ method: "POST", url: "/api/logout", headers: { cookie } });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(firstHeader(response.headers["set-cookie"])).toContain("Max-Age=0");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { FastifyPluginAsync } from "fastify";
|
||||
import type { DatabaseContext } from "../db/database";
|
||||
import { createSession, deleteSession } from "./auth-repository";
|
||||
import { clearSessionCookie, createSessionCookie, readSessionCookie } from "./session-cookie";
|
||||
import { getCurrentUser } from "./current-user";
|
||||
import { findUserByUsername } from "../users/user-repository";
|
||||
|
||||
interface AuthRouteOptions {
|
||||
db: DatabaseContext;
|
||||
}
|
||||
|
||||
interface LoginBody {
|
||||
username: string;
|
||||
}
|
||||
|
||||
export const registerAuthRoutes: FastifyPluginAsync<AuthRouteOptions> = async (app, options) => {
|
||||
app.get("/me", async (request) => ({ user: await getCurrentUser(options.db, request.headers.cookie) }));
|
||||
|
||||
app.post<{ Body: LoginBody }>("/login", {
|
||||
schema: {
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["username"],
|
||||
properties: {
|
||||
username: { type: "string", minLength: 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}, async (request, reply) => {
|
||||
const user = await findUserByUsername(options.db, request.body.username.trim());
|
||||
|
||||
if (!user) {
|
||||
return reply.code(401).send({ message: "Invalid username." });
|
||||
}
|
||||
|
||||
const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7).toISOString();
|
||||
const session = await createSession(options.db, user.id, expiresAt);
|
||||
|
||||
reply.header("set-cookie", createSessionCookie(session.id, expiresAt));
|
||||
|
||||
return { user };
|
||||
});
|
||||
|
||||
app.post("/logout", async (request, reply) => {
|
||||
const sessionId = readSessionCookie(request.headers.cookie);
|
||||
|
||||
if (sessionId) {
|
||||
await deleteSession(options.db, sessionId);
|
||||
}
|
||||
|
||||
reply.header("set-cookie", clearSessionCookie());
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { AuthUser } from "@orderops/shared";
|
||||
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||
import type { DatabaseContext } from "../db/database";
|
||||
import { findSessionUser } from "./auth-repository";
|
||||
import { readSessionCookie } from "./session-cookie";
|
||||
|
||||
export async function getCurrentUser(context: DatabaseContext, cookieHeader?: string) {
|
||||
const sessionId = readSessionCookie(cookieHeader);
|
||||
|
||||
if (!sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return findSessionUser(context, sessionId);
|
||||
}
|
||||
|
||||
export async function requireEmployeeUser(context: DatabaseContext, request: FastifyRequest, reply: FastifyReply): Promise<AuthUser | null> {
|
||||
const user = await getCurrentUser(context, request.headers.cookie);
|
||||
|
||||
if (!user) {
|
||||
await reply.code(401).send({ message: "Authentication required." });
|
||||
return null;
|
||||
}
|
||||
|
||||
if (user.role !== "employee") {
|
||||
await reply.code(403).send({ message: "Employee access required." });
|
||||
return null;
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
const sessionCookieName = "orderops_session";
|
||||
|
||||
export function readSessionCookie(cookieHeader?: string) {
|
||||
if (!cookieHeader) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const cookie of cookieHeader.split(";")) {
|
||||
const [name, ...valueParts] = cookie.trim().split("=");
|
||||
|
||||
if (name === sessionCookieName) {
|
||||
return valueParts.join("=") || null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createSessionCookie(sessionId: string, expiresAt: string) {
|
||||
return `${sessionCookieName}=${sessionId}; Path=/; HttpOnly; SameSite=Lax; Expires=${new Date(expiresAt).toUTCString()}`;
|
||||
}
|
||||
|
||||
export function clearSessionCookie() {
|
||||
return `${sessionCookieName}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
|
||||
}
|
||||
Reference in New Issue
Block a user