Files
medassist-ng/backend/src/routes/auth.ts
T
Daniel Volz 2a84a43654 fix: unify data directory for dev and prod environments (#116)
Add DATA_DIR env var support to configure the data directory path.
All hardcoded resolve(cwd, 'data') paths now use a central getDataDir()
function from db-utils.ts that checks DATA_DIR first, falling back to
resolve(cwd, 'data').

This prevents local dev (cd backend && npm run dev) from creating a
separate backend/data/ directory instead of using the root data/ folder.

Changes:
- Add getDataDir() to db-utils.ts as single source of truth
- Update all 8 source files that reference the data directory
- Add dotenv fallback to ../.env for local dev from backend/
- Add DATA_DIR documentation to .env.example
- Add 7 new tests for getDataDir and getDbPaths with DATA_DIR
- 493 tests pass, TypeScript clean
2026-02-08 11:20:55 +01:00

579 lines
19 KiB
TypeScript

import { randomBytes } from "node:crypto";
import argon2 from "argon2";
import { eq } from "drizzle-orm";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { getDataDir } from "../db/db-utils.js";
import { refreshTokens, users } from "../db/schema.js";
import { getAuthState, requireAuth } from "../plugins/auth.js";
import type { AuthUser } from "../types/fastify.js";
// =============================================================================
// Argon2id Configuration - State of the Art Password Hashing
// =============================================================================
const ARGON2_OPTIONS: argon2.Options = {
type: argon2.argon2id, // Argon2id - best for password hashing
memoryCost: 65536, // 64 MB memory
timeCost: 3, // 3 iterations
parallelism: 4, // 4 parallel threads
hashLength: 32, // 256-bit hash
};
// =============================================================================
// Rate Limiting Configuration for Auth Routes
// =============================================================================
// Stricter rate limits for authentication endpoints to prevent brute-force attacks
// Note: Rate limiting is implemented via @fastify/rate-limit plugin registered in index.ts
// and route-specific limits are applied via the 'config.rateLimit' option below.
// CodeQL may not recognize this pattern - see: https://github.com/github/codeql/issues
// lgtm[js/missing-rate-limiting]
const authRateLimitConfig = {
max: 10, // 10 requests
timeWindow: "1 minute", // per minute
errorResponseBuilder: () => ({
error: "Too many requests. Please try again later.",
code: "RATE_LIMIT_EXCEEDED",
}),
};
// lgtm[js/missing-rate-limiting]
const sensitiveRateLimitConfig = {
max: 5, // 5 requests
timeWindow: "15 minutes", // per 15 minutes (for login/register)
errorResponseBuilder: () => ({
error: "Too many attempts. Please try again later.",
code: "RATE_LIMIT_EXCEEDED",
}),
};
// =============================================================================
// Validation Schemas
// =============================================================================
const registerSchema = z.object({
username: z
.string()
.min(3, "Username must be at least 3 characters")
.max(50, "Username must be at most 50 characters")
.regex(/^[a-zA-Z0-9_-]+$/, "Username can only contain letters, numbers, underscores, and hyphens"),
password: z
.string()
.min(8, "Password must be at least 8 characters")
.max(128, "Password must be at most 128 characters"),
});
const loginSchema = z.object({
username: z.string().min(1, "Username is required"),
password: z.string().min(1, "Password is required"),
rememberMe: z.boolean().optional().default(false),
});
const updateProfileSchema = z.object({
currentPassword: z.string().optional(),
newPassword: z
.string()
.min(8, "Password must be at least 8 characters")
.max(128, "Password must be at most 128 characters")
.optional(),
});
// =============================================================================
// Auth Routes
// =============================================================================
export async function authRoutes(app: FastifyInstance) {
// Token TTLs
const accessTtlMinutes = 15;
const refreshTtlDays = 14;
// ---------------------------------------------------------------------------
// GET /auth/state - Public auth state (needed before login)
// Exempt from rate limit - lightweight state check called frequently
// ---------------------------------------------------------------------------
app.get("/auth/state", { config: { rateLimit: false } }, async () => {
return getAuthState();
});
// ---------------------------------------------------------------------------
// POST /auth/register - User registration
// ---------------------------------------------------------------------------
app.post<{ Body: z.infer<typeof registerSchema> }>(
"/auth/register",
{
config: { rateLimit: sensitiveRateLimitConfig },
},
async (request, reply) => {
// Check auth state
const state = await getAuthState();
if (!state.authEnabled) {
return reply.status(400).send({ error: "Authentication is disabled", code: "AUTH_DISABLED" });
}
if (!state.registrationEnabled) {
return reply.status(400).send({ error: "Registration is disabled", code: "REGISTRATION_DISABLED" });
}
if (!state.localAuthEnabled) {
return reply.status(400).send({ error: "Local authentication is disabled", code: "LOCAL_AUTH_DISABLED" });
}
// Validate input
const parsed = registerSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
error: parsed.error.errors[0]?.message ?? "Invalid input",
code: "VALIDATION_ERROR",
});
}
const { username, password } = parsed.data;
// Check if username already exists
const [existingUser] = await db.select().from(users).where(eq(users.username, username));
if (existingUser) {
return reply.status(409).send({ error: "Username already taken", code: "USERNAME_EXISTS" });
}
// Hash password with Argon2id
const passwordHash = await argon2.hash(password, ARGON2_OPTIONS);
// Create user
const [newUser] = await db
.insert(users)
.values({
username,
passwordHash,
authProvider: "local",
})
.returning();
app.log.info(`User registered: ${username}`);
return reply.status(201).send({
ok: true,
user: {
id: newUser.id,
username: newUser.username,
},
message: "Account created",
});
}
);
// ---------------------------------------------------------------------------
// POST /auth/login - User login
// ---------------------------------------------------------------------------
app.post<{ Body: z.infer<typeof loginSchema> }>(
"/auth/login",
{
config: { rateLimit: sensitiveRateLimitConfig },
},
async (request, reply) => {
const state = await getAuthState();
if (!state.authEnabled) {
return reply.status(400).send({ error: "Authentication is disabled", code: "AUTH_DISABLED" });
}
if (!state.localAuthEnabled) {
return reply.status(400).send({ error: "Local authentication is disabled", code: "LOCAL_AUTH_DISABLED" });
}
const parsed = loginSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
error: "Invalid credentials",
code: "VALIDATION_ERROR",
});
}
const { username, password, rememberMe } = parsed.data;
// Find user by username
const [user] = await db.select().from(users).where(eq(users.username, username));
// Generic error to prevent user enumeration
const invalidCredentialsError = () =>
reply.status(401).send({ error: "Invalid username or password", code: "INVALID_CREDENTIALS" });
if (!user) {
// Perform dummy hash to prevent timing attacks
await argon2.hash("dummy", ARGON2_OPTIONS);
return invalidCredentialsError();
}
if (!user.isActive) {
return reply.status(401).send({ error: "Account disabled", code: "ACCOUNT_DISABLED" });
}
if (!user.passwordHash) {
// SSO-only user trying local login
return reply.status(401).send({ error: "Please use SSO to login", code: "SSO_ONLY" });
}
// Verify password
const valid = await argon2.verify(user.passwordHash, password, ARGON2_OPTIONS);
if (!valid) {
return invalidCredentialsError();
}
// Update last login
await db.update(users).set({ lastLoginAt: new Date(), updatedAt: new Date() }).where(eq(users.id, user.id));
// Generate tokens
const accessToken = app.jwt.sign(
{ sub: user.id, username: user.username },
{ expiresIn: `${accessTtlMinutes}m` }
);
const tokenId = randomBytes(32).toString("hex");
const refreshExp = new Date(Date.now() + refreshTtlDays * 24 * 60 * 60 * 1000);
await db.insert(refreshTokens).values({
userId: user.id,
tokenId,
expiresAt: refreshExp,
});
const refreshToken = app.jwt.sign(
{ sub: user.id, jti: tokenId },
{ expiresIn: `${refreshTtlDays}d`, key: app.config.refreshSecret }
);
app.log.info(`User logged in: ${username} (rememberMe: ${rememberMe})`);
// Cookie options: with maxAge for "remember me", without for session cookie
const accessCookieOptions = rememberMe
? app.config.cookieOptions
: { ...app.config.cookieOptions, maxAge: undefined };
const refreshCookieOptions = rememberMe
? app.config.refreshCookieOptions
: { ...app.config.refreshCookieOptions, maxAge: undefined };
return reply
.setCookie("access_token", accessToken, accessCookieOptions)
.setCookie("refresh_token", refreshToken, refreshCookieOptions)
.send({
ok: true,
user: {
id: user.id,
username: user.username,
avatarUrl: user.avatarUrl,
},
});
}
);
// ---------------------------------------------------------------------------
// POST /auth/refresh - Refresh access token
// ---------------------------------------------------------------------------
app.post(
"/auth/refresh",
{
config: { rateLimit: authRateLimitConfig },
},
async (request, reply) => {
const refreshTokenCookie = request.cookies.refresh_token;
if (!refreshTokenCookie) {
return reply.status(401).send({ error: "No refresh token", code: "NO_REFRESH_TOKEN" });
}
try {
// Verify refresh token
const decoded = app.jwt.verify<{ sub: number; jti: string }>(refreshTokenCookie, {
key: app.config.refreshSecret,
});
// Check if token exists and is valid
const [token] = await db.select().from(refreshTokens).where(eq(refreshTokens.tokenId, decoded.jti));
if (!token || token.revoked || token.expiresAt < new Date()) {
return reply.status(401).send({ error: "Invalid refresh token", code: "INVALID_REFRESH_TOKEN" });
}
// Get user
const [user] = await db.select().from(users).where(eq(users.id, decoded.sub));
if (!user || !user.isActive) {
return reply.status(401).send({ error: "User not found or disabled", code: "USER_INVALID" });
}
// Rotate refresh token (revoke old, create new)
await db
.update(refreshTokens)
.set({ revoked: true, rotatedAt: new Date() })
.where(eq(refreshTokens.id, token.id));
const newTokenId = randomBytes(32).toString("hex");
const refreshExp = new Date(Date.now() + refreshTtlDays * 24 * 60 * 60 * 1000);
await db.insert(refreshTokens).values({
userId: user.id,
tokenId: newTokenId,
expiresAt: refreshExp,
});
// Generate new tokens
const newAccessToken = app.jwt.sign(
{ sub: user.id, username: user.username },
{ expiresIn: `${accessTtlMinutes}m` }
);
const newRefreshToken = app.jwt.sign(
{ sub: user.id, jti: newTokenId },
{ expiresIn: `${refreshTtlDays}d`, key: app.config.refreshSecret }
);
return reply
.setCookie("access_token", newAccessToken, app.config.cookieOptions)
.setCookie("refresh_token", newRefreshToken, app.config.refreshCookieOptions)
.send({ ok: true });
} catch {
return reply.status(401).send({ error: "Invalid refresh token", code: "INVALID_REFRESH_TOKEN" });
}
}
);
// ---------------------------------------------------------------------------
// POST /auth/logout - Logout (revoke refresh token)
// ---------------------------------------------------------------------------
app.post(
"/auth/logout",
{
config: { rateLimit: authRateLimitConfig },
},
async (request, reply) => {
const refreshTokenCookie = request.cookies.refresh_token;
if (refreshTokenCookie) {
try {
const decoded = app.jwt.verify<{ jti: string }>(refreshTokenCookie, { key: app.config.refreshSecret });
// Revoke the refresh token
await db.update(refreshTokens).set({ revoked: true }).where(eq(refreshTokens.tokenId, decoded.jti));
} catch {
// Invalid token, ignore
}
}
return reply
.clearCookie("access_token", app.config.cookieOptions)
.clearCookie("refresh_token", app.config.refreshCookieOptions)
.send({ ok: true });
}
);
// ---------------------------------------------------------------------------
// GET /auth/me - Get current user profile
// ---------------------------------------------------------------------------
app.get("/auth/me", { preHandler: requireAuth }, async (request, reply) => {
const authUser = request.user as unknown as AuthUser | null;
if (!authUser) {
return reply.status(401).send({ error: "Not authenticated" });
}
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
if (!user) {
return reply.status(404).send({ error: "User not found" });
}
return {
id: user.id,
username: user.username,
avatarUrl: user.avatarUrl,
authProvider: user.authProvider,
createdAt: user.createdAt,
lastLoginAt: user.lastLoginAt,
};
});
// ---------------------------------------------------------------------------
// PUT /auth/me - Update current user profile
// ---------------------------------------------------------------------------
app.put<{ Body: z.infer<typeof updateProfileSchema> }>(
"/auth/me",
{
preHandler: requireAuth,
config: { rateLimit: authRateLimitConfig },
},
async (request, reply) => {
const authUser = request.user as unknown as AuthUser | null;
if (!authUser) {
return reply.status(401).send({ error: "Not authenticated" });
}
const parsed = updateProfileSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
error: parsed.error.errors[0]?.message ?? "Invalid input",
code: "VALIDATION_ERROR",
});
}
const { currentPassword, newPassword } = parsed.data;
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
if (!user) {
return reply.status(404).send({ error: "User not found" });
}
const updates: Partial<typeof users.$inferInsert> = {
updatedAt: new Date(),
};
// Update password if provided
if (newPassword) {
if (!currentPassword) {
return reply.status(400).send({ error: "Current password required", code: "CURRENT_PASSWORD_REQUIRED" });
}
if (!user.passwordHash) {
return reply.status(400).send({ error: "Cannot change password for SSO account", code: "SSO_ACCOUNT" });
}
const valid = await argon2.verify(user.passwordHash, currentPassword, ARGON2_OPTIONS);
if (!valid) {
return reply.status(401).send({ error: "Current password is incorrect", code: "INVALID_PASSWORD" });
}
updates.passwordHash = await argon2.hash(newPassword, ARGON2_OPTIONS);
}
await db.update(users).set(updates).where(eq(users.id, user.id));
return { ok: true, message: "Profile updated" };
}
);
// ---------------------------------------------------------------------------
// POST /auth/avatar - Upload user avatar
// ---------------------------------------------------------------------------
app.post(
"/auth/avatar",
{
preHandler: requireAuth,
config: { rateLimit: authRateLimitConfig },
},
async (request, reply) => {
const authUser = request.user as unknown as AuthUser | null;
if (!authUser) {
return reply.status(401).send({ error: "Not authenticated" });
}
const data = await request.file();
if (!data) {
return reply.status(400).send({ error: "No file uploaded" });
}
// Validate file type
const allowedTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"];
if (!allowedTypes.includes(data.mimetype)) {
return reply.status(400).send({ error: "Invalid file type. Allowed: JPEG, PNG, WebP, GIF" });
}
// Generate unique filename
const ext = data.filename.split(".").pop() || "jpg";
const filename = `avatar_${authUser.id}_${Date.now()}.${ext}`;
// Save file
const fs = await import("node:fs/promises");
const path = await import("node:path");
const imagesDir = path.join(getDataDir(), "images");
await fs.mkdir(imagesDir, { recursive: true });
const buffer = await data.toBuffer();
await fs.writeFile(path.join(imagesDir, filename), buffer);
// Delete old avatar if exists
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
if (user?.avatarUrl) {
try {
await fs.unlink(path.join(imagesDir, user.avatarUrl));
} catch {
// Ignore if file doesn't exist
}
}
// Update user
await db.update(users).set({ avatarUrl: filename, updatedAt: new Date() }).where(eq(users.id, authUser.id));
return { ok: true, avatarUrl: filename };
}
);
// ---------------------------------------------------------------------------
// DELETE /auth/avatar - Delete user avatar
// ---------------------------------------------------------------------------
app.delete(
"/auth/avatar",
{
preHandler: requireAuth,
config: { rateLimit: authRateLimitConfig },
},
async (request, reply) => {
const authUser = request.user as unknown as AuthUser | null;
if (!authUser) {
return reply.status(401).send({ error: "Not authenticated" });
}
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
if (!user?.avatarUrl) {
return reply.status(404).send({ error: "No avatar to delete" });
}
// Delete file
const fs = await import("node:fs/promises");
const path = await import("node:path");
try {
await fs.unlink(path.join(getDataDir(), "images", user.avatarUrl));
} catch {
// Ignore if file doesn't exist
}
// Update user
await db.update(users).set({ avatarUrl: null, updatedAt: new Date() }).where(eq(users.id, authUser.id));
return { ok: true };
}
);
// ---------------------------------------------------------------------------
// DELETE /auth/me - Delete user account and all data
// ---------------------------------------------------------------------------
app.delete(
"/auth/me",
{
preHandler: requireAuth,
config: { rateLimit: sensitiveRateLimitConfig },
},
async (request, reply) => {
const authUser = request.user as unknown as AuthUser | null;
if (!authUser) {
return reply.status(401).send({ error: "Not authenticated" });
}
// Delete avatar file if exists
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
if (user?.avatarUrl) {
const fs = await import("node:fs/promises");
const path = await import("node:path");
try {
await fs.unlink(path.join(getDataDir(), "images", user.avatarUrl));
} catch {
// Ignore if file doesn't exist
}
}
// Delete user - cascade delete handles all related data
await db.delete(users).where(eq(users.id, authUser.id));
app.log.info(`User deleted account: ${authUser.username} (ID: ${authUser.id})`);
// Clear auth cookies
return reply
.clearCookie("access_token", app.config.cookieOptions)
.clearCookie("refresh_token", app.config.refreshCookieOptions)
.send({ ok: true, message: "Account deleted" });
}
);
}