feat: authentication

This commit is contained in:
Nicolas Meienberger
2025-10-02 18:47:25 +02:00
parent 7f79fd7628
commit 1e7530cc09
11 changed files with 642 additions and 6 deletions

View File

@@ -3,9 +3,11 @@ import "dotenv/config";
const envSchema = type({
NODE_ENV: type.enumerated("development", "production", "test").default("development"),
SESSION_SECRET: "string?",
}).pipe((s) => ({
__prod__: s.NODE_ENV === "production",
environment: s.NODE_ENV,
sessionSecret: s.SESSION_SECRET || "change-me-in-production-please",
}));
const parseConfig = (env: unknown) => {

View File

@@ -17,3 +17,24 @@ export const volumesTable = sqliteTable("volumes_table", {
});
export type Volume = typeof volumesTable.$inferSelect;
export const usersTable = sqliteTable("users_table", {
id: int().primaryKey({ autoIncrement: true }),
username: text().notNull().unique(),
passwordHash: text("password_hash").notNull(),
createdAt: int("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
});
export type User = typeof usersTable.$inferSelect;
export const sessionsTable = sqliteTable("sessions_table", {
id: text().primaryKey(),
userId: int("user_id")
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
expiresAt: int("expires_at", { mode: "timestamp" }).notNull(),
createdAt: int("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
});
export type Session = typeof sessionsTable.$inferSelect;

View File

@@ -5,6 +5,8 @@ import { serveStatic } from "hono/bun";
import { logger as honoLogger } from "hono/logger";
import { openAPIRouteHandler } from "hono-openapi";
import { runDbMigrations } from "./db/db";
import { authController } from "./modules/auth/auth.controller";
import { requireAuth } from "./modules/auth/auth.middleware";
import { driverController } from "./modules/driver/driver.controller";
import { startup } from "./modules/lifecycle/startup";
import { volumeController } from "./modules/volumes/volume.controller";
@@ -35,7 +37,8 @@ const app = new Hono()
.get("*", serveStatic({ root: "./assets/frontend" }))
.get("healthcheck", (c) => c.json({ status: "ok" }))
.basePath("/api/v1")
.route("/volumes", volumeController);
.route("/auth", authController)
.route("/volumes", volumeController.use(requireAuth));
app.get("/openapi.json", generalDescriptor(app));
app.get("/docs", scalarDescriptor);

View File

@@ -0,0 +1,79 @@
import { arktypeValidator } from "@hono/arktype-validator";
import { Hono } from "hono";
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
import { getMeDto, loginBodySchema, loginDto, logoutDto, registerBodySchema, registerDto } from "./auth.dto";
import { authService } from "./auth.service";
const COOKIE_NAME = "session_id";
const COOKIE_OPTIONS = {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax" as const,
path: "/",
};
export const authController = new Hono()
.post("/register", registerDto, arktypeValidator("json", registerBodySchema), async (c) => {
const body = c.req.valid("json");
try {
const { user } = await authService.register(body.username, body.password);
return c.json(
{
message: "User registered successfully",
user: { id: user.id, username: user.username },
},
201,
);
} catch (error) {
return c.json({ message: error instanceof Error ? error.message : "Registration failed" }, 400);
}
})
.post("/login", loginDto, arktypeValidator("json", loginBodySchema), async (c) => {
const body = c.req.valid("json");
try {
const { sessionId, user, expiresAt } = await authService.login(body.username, body.password);
setCookie(c, COOKIE_NAME, sessionId, {
...COOKIE_OPTIONS,
expires: expiresAt,
});
return c.json({
message: "Login successful",
user: { id: user.id, username: user.username },
});
} catch (error) {
return c.json({ message: error instanceof Error ? error.message : "Login failed" }, 401);
}
})
.post("/logout", logoutDto, async (c) => {
const sessionId = getCookie(c, COOKIE_NAME);
if (sessionId) {
await authService.logout(sessionId);
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
}
return c.json({ message: "Logout successful" });
})
.get("/me", getMeDto, async (c) => {
const sessionId = getCookie(c, COOKIE_NAME);
if (!sessionId) {
return c.json({ message: "Not authenticated" }, 401);
}
const session = await authService.verifySession(sessionId);
if (!session) {
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
return c.json({ message: "Not authenticated" }, 401);
}
return c.json({
user: session.user,
});
});

View File

@@ -0,0 +1,97 @@
import { type } from "arktype";
import { describeRoute, resolver } from "hono-openapi";
// Validation schemas
export const loginBodySchema = type({
username: "string>0",
password: "string>7",
});
export const registerBodySchema = type({
username: "string>2",
password: "string>7",
});
const loginResponseSchema = type({
message: "string",
user: type({
id: "string",
username: "string",
}),
});
export const loginDto = describeRoute({
description: "Login with username and password",
operationId: "login",
tags: ["Auth"],
responses: {
200: {
description: "Login successful",
content: {
"application/json": {
schema: resolver(loginResponseSchema),
},
},
},
401: {
description: "Invalid credentials",
},
},
});
export const registerDto = describeRoute({
description: "Register a new user",
operationId: "register",
tags: ["Auth"],
responses: {
201: {
description: "User created successfully",
content: {
"application/json": {
schema: resolver(loginResponseSchema),
},
},
},
400: {
description: "Invalid request or username already exists",
},
},
});
export const logoutDto = describeRoute({
description: "Logout current user",
operationId: "logout",
tags: ["Auth"],
responses: {
200: {
description: "Logout successful",
content: {
"application/json": {
schema: resolver(type({ message: "string" })),
},
},
},
},
});
export const getMeDto = describeRoute({
description: "Get current authenticated user",
operationId: "getMe",
tags: ["Auth"],
responses: {
200: {
description: "Current user information",
content: {
"application/json": {
schema: resolver(loginResponseSchema),
},
},
},
401: {
description: "Not authenticated",
},
},
});
export type LoginBody = typeof loginBodySchema.infer;
export type RegisterBody = typeof registerBodySchema.infer;

View File

@@ -0,0 +1,63 @@
import { deleteCookie, getCookie } from "hono/cookie";
import { createMiddleware } from "hono/factory";
import { authService } from "./auth.service";
const COOKIE_NAME = "session_id";
const COOKIE_OPTIONS = {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax" as const,
path: "/",
};
declare module "hono" {
interface ContextVariableMap {
user: {
id: number;
username: string;
};
}
}
/**
* Middleware to require authentication
* Verifies the session cookie and attaches user to context
*/
export const requireAuth = createMiddleware(async (c, next) => {
const sessionId = getCookie(c, COOKIE_NAME);
if (!sessionId) {
return c.json({ message: "Authentication required" }, 401);
}
const session = await authService.verifySession(sessionId);
if (!session) {
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
return c.json({ message: "Invalid or expired session" }, 401);
}
c.set("user", session.user);
await next();
});
/**
* Middleware to optionally attach user if authenticated
* Does not block the request if not authenticated
*/
export const optionalAuth = createMiddleware(async (c, next) => {
const sessionId = getCookie(c, COOKIE_NAME);
if (sessionId) {
const session = await authService.verifySession(sessionId);
if (session) {
c.set("user", session.user);
} else {
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
}
}
await next();
});

View File

@@ -0,0 +1,123 @@
import { eq } from "drizzle-orm";
import { db } from "../../db/db";
import { sessionsTable, usersTable } from "../../db/schema";
import { logger } from "../../utils/logger";
const SESSION_DURATION = 1000 * 60 * 60 * 24 * 30; // 30 days
export class AuthService {
/**
* Register a new user with username and password
*/
async register(username: string, password: string) {
const [existingUser] = await db.select().from(usersTable).where(eq(usersTable.username, username));
if (existingUser) {
throw new Error("Username already exists");
}
const passwordHash = await Bun.password.hash(password, {
algorithm: "argon2id",
memoryCost: 19456,
timeCost: 2,
});
const [user] = await db.insert(usersTable).values({ username, passwordHash }).returning();
if (!user) {
throw new Error("User registration failed");
}
logger.info(`User registered: ${username}`);
return { user: { id: user.id, username: user.username, createdAt: user.createdAt } };
}
/**
* Login user with username and password
*/
async login(username: string, password: string) {
const [user] = await db.select().from(usersTable).where(eq(usersTable.username, username));
if (!user) {
throw new Error("Invalid credentials");
}
const isValid = await Bun.password.verify(password, user.passwordHash);
if (!isValid) {
throw new Error("Invalid credentials");
}
const sessionId = crypto.randomUUID();
const expiresAt = new Date(Date.now() + SESSION_DURATION);
await db.insert(sessionsTable).values({
id: sessionId,
userId: user.id,
expiresAt,
});
logger.info(`User logged in: ${username}`);
return {
sessionId,
user: { id: user.id, username: user.username },
expiresAt,
};
}
/**
* Logout user by deleting their session
*/
async logout(sessionId: string) {
await db.delete(sessionsTable).where(eq(sessionsTable.id, sessionId));
logger.info(`User logged out: session ${sessionId}`);
}
/**
* Verify a session and return the associated user
*/
async verifySession(sessionId: string) {
const [session] = await db
.select({
session: sessionsTable,
user: usersTable,
})
.from(sessionsTable)
.innerJoin(usersTable, eq(sessionsTable.userId, usersTable.id))
.where(eq(sessionsTable.id, sessionId));
if (!session) {
return null;
}
if (session.session.expiresAt < new Date()) {
await db.delete(sessionsTable).where(eq(sessionsTable.id, sessionId));
return null;
}
return {
user: {
id: session.user.id,
username: session.user.username,
},
session: {
id: session.session.id,
expiresAt: session.session.expiresAt,
},
};
}
/**
* Clean up expired sessions
*/
async cleanupExpiredSessions() {
const result = await db.delete(sessionsTable).where(eq(sessionsTable.expiresAt, new Date())).returning();
if (result.length > 0) {
logger.info(`Cleaned up ${result.length} expired sessions`);
}
}
}
export const authService = new AuthService();