diff --git a/README.md b/README.md index 57d54ed..70c14ca 100644 --- a/README.md +++ b/README.md @@ -31,11 +31,9 @@ Ironmount is an easy to use web interface to manage your remote storage and moun ### Coming soon -- 🔐  User authentication and role management -- 💾  Automated backups and snapshots with encryption, strategies 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) -- 🔀  Storage sharding and replication for high availability and performance +- User authentication +- Automated backups with encryption and retention policies +- Integration with cloud storage providers (e.g. AWS S3, Google Drive, Dropbox) ## Installation diff --git a/apps/server/drizzle/0005_simple_alice.sql b/apps/server/drizzle/0005_simple_alice.sql new file mode 100644 index 0000000..fe8967b --- /dev/null +++ b/apps/server/drizzle/0005_simple_alice.sql @@ -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`); \ No newline at end of file diff --git a/apps/server/drizzle/meta/0005_snapshot.json b/apps/server/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..3d5db1c --- /dev/null +++ b/apps/server/drizzle/meta/0005_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index ecb66bb..89f6717 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1758961535488, "tag": "0004_wealthy_tomas", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1759416698274, + "tag": "0005_simple_alice", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/server/src/core/config.ts b/apps/server/src/core/config.ts index ca45c4a..033827e 100644 --- a/apps/server/src/core/config.ts +++ b/apps/server/src/core/config.ts @@ -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) => { diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts index 8918f18..fe53132 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -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; diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index fa7badf..4de0d46 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -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); diff --git a/apps/server/src/modules/auth/auth.controller.ts b/apps/server/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000..33f13ea --- /dev/null +++ b/apps/server/src/modules/auth/auth.controller.ts @@ -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, + }); + }); diff --git a/apps/server/src/modules/auth/auth.dto.ts b/apps/server/src/modules/auth/auth.dto.ts new file mode 100644 index 0000000..8b45034 --- /dev/null +++ b/apps/server/src/modules/auth/auth.dto.ts @@ -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; diff --git a/apps/server/src/modules/auth/auth.middleware.ts b/apps/server/src/modules/auth/auth.middleware.ts new file mode 100644 index 0000000..7966630 --- /dev/null +++ b/apps/server/src/modules/auth/auth.middleware.ts @@ -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(); +}); diff --git a/apps/server/src/modules/auth/auth.service.ts b/apps/server/src/modules/auth/auth.service.ts new file mode 100644 index 0000000..822b635 --- /dev/null +++ b/apps/server/src/modules/auth/auth.service.ts @@ -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();