mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
refactor: unify backend and frontend servers (#3)
* refactor: unify backend and frontend servers * refactor: correct paths for openapi & drizzle * refactor: move api-client to client * fix: drizzle paths * chore: fix linting issues * fix: form reset issue
This commit is contained in:
141
app/server/modules/auth/auth.controller.ts
Normal file
141
app/server/modules/auth/auth.controller.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { validator } from "hono-openapi";
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
||||
import {
|
||||
changePasswordBodySchema,
|
||||
changePasswordDto,
|
||||
getMeDto,
|
||||
getStatusDto,
|
||||
loginBodySchema,
|
||||
loginDto,
|
||||
logoutDto,
|
||||
registerBodySchema,
|
||||
registerDto,
|
||||
type ChangePasswordDto,
|
||||
type GetMeDto,
|
||||
type GetStatusDto,
|
||||
type LoginDto,
|
||||
type LogoutDto,
|
||||
type RegisterDto,
|
||||
} from "./auth.dto";
|
||||
import { authService } from "./auth.service";
|
||||
import { toMessage } from "../../utils/errors";
|
||||
|
||||
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, validator("json", registerBodySchema), async (c) => {
|
||||
const body = c.req.valid("json");
|
||||
|
||||
try {
|
||||
const { user, sessionId } = await authService.register(body.username, body.password);
|
||||
|
||||
setCookie(c, COOKIE_NAME, sessionId, {
|
||||
...COOKIE_OPTIONS,
|
||||
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
|
||||
});
|
||||
|
||||
return c.json<RegisterDto>(
|
||||
{
|
||||
success: true,
|
||||
message: "User registered successfully",
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
hasDownloadedResticPassword: user.hasDownloadedResticPassword,
|
||||
},
|
||||
},
|
||||
201,
|
||||
);
|
||||
} catch (error) {
|
||||
return c.json<RegisterDto>({ success: false, message: toMessage(error) }, 400);
|
||||
}
|
||||
})
|
||||
.post("/login", loginDto, validator("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: new Date(expiresAt),
|
||||
});
|
||||
|
||||
return c.json<LoginDto>({
|
||||
success: true,
|
||||
message: "Login successful",
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
hasDownloadedResticPassword: user.hasDownloadedResticPassword,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return c.json<LoginDto>({ success: false, message: toMessage(error) }, 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<LogoutDto>({ success: true });
|
||||
})
|
||||
.get("/me", getMeDto, async (c) => {
|
||||
const sessionId = getCookie(c, COOKIE_NAME);
|
||||
|
||||
if (!sessionId) {
|
||||
return c.json<GetMeDto>({ success: false, 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<GetMeDto>({
|
||||
success: true,
|
||||
user: session.user,
|
||||
message: "Authenticated",
|
||||
});
|
||||
})
|
||||
.get("/status", getStatusDto, async (c) => {
|
||||
const hasUsers = await authService.hasUsers();
|
||||
return c.json<GetStatusDto>({ hasUsers });
|
||||
})
|
||||
.post("/change-password", changePasswordDto, validator("json", changePasswordBodySchema), async (c) => {
|
||||
const sessionId = getCookie(c, COOKIE_NAME);
|
||||
|
||||
if (!sessionId) {
|
||||
return c.json<ChangePasswordDto>({ success: false, message: "Not authenticated" }, 401);
|
||||
}
|
||||
|
||||
const session = await authService.verifySession(sessionId);
|
||||
|
||||
if (!session) {
|
||||
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
|
||||
return c.json<ChangePasswordDto>({ success: false, message: "Not authenticated" }, 401);
|
||||
}
|
||||
|
||||
const body = c.req.valid("json");
|
||||
|
||||
try {
|
||||
await authService.changePassword(session.user.id, body.currentPassword, body.newPassword);
|
||||
return c.json<ChangePasswordDto>({ success: true, message: "Password changed successfully" });
|
||||
} catch (error) {
|
||||
return c.json<ChangePasswordDto>({ success: false, message: toMessage(error) }, 400);
|
||||
}
|
||||
});
|
||||
153
app/server/modules/auth/auth.dto.ts
Normal file
153
app/server/modules/auth/auth.dto.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
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",
|
||||
success: "boolean",
|
||||
user: type({
|
||||
id: "number",
|
||||
username: "string",
|
||||
hasDownloadedResticPassword: "boolean",
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
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),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export type LoginDto = typeof loginResponseSchema.infer;
|
||||
|
||||
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),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export type RegisterDto = typeof loginResponseSchema.infer;
|
||||
|
||||
const logoutResponseSchema = type({
|
||||
success: "boolean",
|
||||
});
|
||||
|
||||
export const logoutDto = describeRoute({
|
||||
description: "Logout current user",
|
||||
operationId: "logout",
|
||||
tags: ["Auth"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Logout successful",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(logoutResponseSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export type LogoutDto = typeof logoutResponseSchema.infer;
|
||||
|
||||
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),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export type GetMeDto = typeof loginResponseSchema.infer;
|
||||
|
||||
const statusResponseSchema = type({
|
||||
hasUsers: "boolean",
|
||||
});
|
||||
|
||||
export const getStatusDto = describeRoute({
|
||||
description: "Get authentication system status",
|
||||
operationId: "getStatus",
|
||||
tags: ["Auth"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Authentication system status",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(statusResponseSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export type GetStatusDto = typeof statusResponseSchema.infer;
|
||||
|
||||
export const changePasswordBodySchema = type({
|
||||
currentPassword: "string>0",
|
||||
newPassword: "string>7",
|
||||
});
|
||||
|
||||
const changePasswordResponseSchema = type({
|
||||
success: "boolean",
|
||||
message: "string",
|
||||
});
|
||||
|
||||
export const changePasswordDto = describeRoute({
|
||||
description: "Change current user password",
|
||||
operationId: "changePassword",
|
||||
tags: ["Auth"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Password changed successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(changePasswordResponseSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export type ChangePasswordDto = typeof changePasswordResponseSchema.infer;
|
||||
|
||||
export type LoginBody = typeof loginBodySchema.infer;
|
||||
export type RegisterBody = typeof registerBodySchema.infer;
|
||||
export type ChangePasswordBody = typeof changePasswordBodySchema.infer;
|
||||
64
app/server/modules/auth/auth.middleware.ts
Normal file
64
app/server/modules/auth/auth.middleware.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
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;
|
||||
hasDownloadedResticPassword: boolean;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
179
app/server/modules/auth/auth.service.ts
Normal file
179
app/server/modules/auth/auth.service.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { eq, lt } 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);
|
||||
|
||||
if (existingUser) {
|
||||
throw new Error("Admin user 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}`);
|
||||
const sessionId = crypto.randomUUID();
|
||||
const expiresAt = new Date(Date.now() + SESSION_DURATION).getTime();
|
||||
|
||||
await db.insert(sessionsTable).values({
|
||||
id: sessionId,
|
||||
userId: user.id,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
createdAt: user.createdAt,
|
||||
hasDownloadedResticPassword: user.hasDownloadedResticPassword,
|
||||
},
|
||||
sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).getTime();
|
||||
|
||||
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,
|
||||
hasDownloadedResticPassword: user.hasDownloadedResticPassword,
|
||||
},
|
||||
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 < Date.now()) {
|
||||
await db.delete(sessionsTable).where(eq(sessionsTable.id, sessionId));
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: session.user.id,
|
||||
username: session.user.username,
|
||||
hasDownloadedResticPassword: session.user.hasDownloadedResticPassword,
|
||||
},
|
||||
session: {
|
||||
id: session.session.id,
|
||||
expiresAt: session.session.expiresAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired sessions
|
||||
*/
|
||||
async cleanupExpiredSessions() {
|
||||
const result = await db.delete(sessionsTable).where(lt(sessionsTable.expiresAt, Date.now())).returning();
|
||||
if (result.length > 0) {
|
||||
logger.info(`Cleaned up ${result.length} expired sessions`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any users exist in the system
|
||||
*/
|
||||
async hasUsers(): Promise<boolean> {
|
||||
const [user] = await db.select({ id: usersTable.id }).from(usersTable).limit(1);
|
||||
return !!user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change password for a user
|
||||
*/
|
||||
async changePassword(userId: number, currentPassword: string, newPassword: string) {
|
||||
const [user] = await db.select().from(usersTable).where(eq(usersTable.id, userId));
|
||||
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
const isValid = await Bun.password.verify(currentPassword, user.passwordHash);
|
||||
|
||||
if (!isValid) {
|
||||
throw new Error("Current password is incorrect");
|
||||
}
|
||||
|
||||
const newPasswordHash = await Bun.password.hash(newPassword, {
|
||||
algorithm: "argon2id",
|
||||
memoryCost: 19456,
|
||||
timeCost: 2,
|
||||
});
|
||||
|
||||
await db.update(usersTable).set({ passwordHash: newPasswordHash }).where(eq(usersTable.id, userId));
|
||||
|
||||
logger.info(`Password changed for user: ${user.username}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const authService = new AuthService();
|
||||
Reference in New Issue
Block a user