mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
feat: authentication
This commit is contained in:
@@ -31,11 +31,9 @@ Ironmount is an easy to use web interface to manage your remote storage and moun
|
|||||||
|
|
||||||
### Coming soon
|
### Coming soon
|
||||||
|
|
||||||
- 🔐 User authentication and role management
|
- User authentication
|
||||||
- 💾 Automated backups and snapshots with encryption, strategies and retention policies
|
- Automated backups with encryption and retention policies
|
||||||
- 🔄 Re-exporting your mounts to other protocols (e.g. mount an NFS server as an SMB share with fine-grained permissions)
|
- Integration with cloud storage providers (e.g. AWS S3, Google Drive, Dropbox)
|
||||||
- ☁️ Integration with cloud storage providers (e.g. AWS S3, Google Drive, Dropbox)
|
|
||||||
- 🔀 Storage sharding and replication for high availability and performance
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|||||||
17
apps/server/drizzle/0005_simple_alice.sql
Normal file
17
apps/server/drizzle/0005_simple_alice.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
CREATE TABLE `sessions_table` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`user_id` integer NOT NULL,
|
||||||
|
`expires_at` integer NOT NULL,
|
||||||
|
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users_table`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `users_table` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`username` text NOT NULL,
|
||||||
|
`password_hash` text NOT NULL,
|
||||||
|
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `users_table_username_unique` ON `users_table` (`username`);
|
||||||
226
apps/server/drizzle/meta/0005_snapshot.json
Normal file
226
apps/server/drizzle/meta/0005_snapshot.json
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "75f0aac0-aa63-4577-bfb6-4638a008935f",
|
||||||
|
"prevId": "0b087a68-fbc6-4647-a6dc-e6322a3d4ee3",
|
||||||
|
"tables": {
|
||||||
|
"sessions_table": {
|
||||||
|
"name": "sessions_table",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"sessions_table_user_id_users_table_id_fk": {
|
||||||
|
"name": "sessions_table_user_id_users_table_id_fk",
|
||||||
|
"tableFrom": "sessions_table",
|
||||||
|
"tableTo": "users_table",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users_table": {
|
||||||
|
"name": "users_table",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_table_username_unique": {
|
||||||
|
"name": "users_table_username_unique",
|
||||||
|
"columns": [
|
||||||
|
"username"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"volumes_table": {
|
||||||
|
"name": "volumes_table",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"name": "path",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'unmounted'"
|
||||||
|
},
|
||||||
|
"last_error": {
|
||||||
|
"name": "last_error",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_health_check": {
|
||||||
|
"name": "last_health_check",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"name": "config",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"auto_remount": {
|
||||||
|
"name": "auto_remount",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"volumes_table_name_unique": {
|
||||||
|
"name": "volumes_table_name_unique",
|
||||||
|
"columns": [
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,13 @@
|
|||||||
"when": 1758961535488,
|
"when": 1758961535488,
|
||||||
"tag": "0004_wealthy_tomas",
|
"tag": "0004_wealthy_tomas",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1759416698274,
|
||||||
|
"tag": "0005_simple_alice",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -3,9 +3,11 @@ import "dotenv/config";
|
|||||||
|
|
||||||
const envSchema = type({
|
const envSchema = type({
|
||||||
NODE_ENV: type.enumerated("development", "production", "test").default("development"),
|
NODE_ENV: type.enumerated("development", "production", "test").default("development"),
|
||||||
|
SESSION_SECRET: "string?",
|
||||||
}).pipe((s) => ({
|
}).pipe((s) => ({
|
||||||
__prod__: s.NODE_ENV === "production",
|
__prod__: s.NODE_ENV === "production",
|
||||||
environment: s.NODE_ENV,
|
environment: s.NODE_ENV,
|
||||||
|
sessionSecret: s.SESSION_SECRET || "change-me-in-production-please",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const parseConfig = (env: unknown) => {
|
const parseConfig = (env: unknown) => {
|
||||||
|
|||||||
@@ -17,3 +17,24 @@ export const volumesTable = sqliteTable("volumes_table", {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type Volume = typeof volumesTable.$inferSelect;
|
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;
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { serveStatic } from "hono/bun";
|
|||||||
import { logger as honoLogger } from "hono/logger";
|
import { logger as honoLogger } from "hono/logger";
|
||||||
import { openAPIRouteHandler } from "hono-openapi";
|
import { openAPIRouteHandler } from "hono-openapi";
|
||||||
import { runDbMigrations } from "./db/db";
|
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 { driverController } from "./modules/driver/driver.controller";
|
||||||
import { startup } from "./modules/lifecycle/startup";
|
import { startup } from "./modules/lifecycle/startup";
|
||||||
import { volumeController } from "./modules/volumes/volume.controller";
|
import { volumeController } from "./modules/volumes/volume.controller";
|
||||||
@@ -35,7 +37,8 @@ const app = new Hono()
|
|||||||
.get("*", serveStatic({ root: "./assets/frontend" }))
|
.get("*", serveStatic({ root: "./assets/frontend" }))
|
||||||
.get("healthcheck", (c) => c.json({ status: "ok" }))
|
.get("healthcheck", (c) => c.json({ status: "ok" }))
|
||||||
.basePath("/api/v1")
|
.basePath("/api/v1")
|
||||||
.route("/volumes", volumeController);
|
.route("/auth", authController)
|
||||||
|
.route("/volumes", volumeController.use(requireAuth));
|
||||||
|
|
||||||
app.get("/openapi.json", generalDescriptor(app));
|
app.get("/openapi.json", generalDescriptor(app));
|
||||||
app.get("/docs", scalarDescriptor);
|
app.get("/docs", scalarDescriptor);
|
||||||
|
|||||||
79
apps/server/src/modules/auth/auth.controller.ts
Normal file
79
apps/server/src/modules/auth/auth.controller.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
97
apps/server/src/modules/auth/auth.dto.ts
Normal file
97
apps/server/src/modules/auth/auth.dto.ts
Normal 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;
|
||||||
63
apps/server/src/modules/auth/auth.middleware.ts
Normal file
63
apps/server/src/modules/auth/auth.middleware.ts
Normal 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();
|
||||||
|
});
|
||||||
123
apps/server/src/modules/auth/auth.service.ts
Normal file
123
apps/server/src/modules/auth/auth.service.ts
Normal 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();
|
||||||
Reference in New Issue
Block a user