diff --git a/apps/api/src/auth/service.ts b/apps/api/src/auth/service.ts index 0e053ff..9d9b3bd 100644 --- a/apps/api/src/auth/service.ts +++ b/apps/api/src/auth/service.ts @@ -2,31 +2,11 @@ import * as OTPAuth from "otpauth"; import type { SessionView, UserRole } from "@internal-mail/shared"; import { authenticateMailbox } from "../docker/mailserver"; import { config } from "../lib/config"; -import { decryptAtRest, encryptAtRest, randomToken, sha256Hex } from "../lib/crypto"; -import { - createSession, - deleteExpiredSessions, - deleteSession, - ensureProfile, - getAdmin, - getProfile, - getSession, - setTotpSecret, - touchSession, - upsertAdmin -} from "../lib/db"; +import { decryptAtRest, encryptAtRest } from "../lib/crypto"; +import { ensureProfile, getAdmin, getProfile, setTotpSecret, upsertAdmin } from "../lib/db"; import { HttpError } from "../lib/errors"; -import { assertCsrf, getClientIp, parseCookies, serializeExpiredCookie, serializeSessionCookie } from "../lib/security"; import { hostedMailboxSchema, strictEmailSchema } from "../lib/validation"; -type ActiveSession = { - id: string; - csrfToken: string; - email: string; - role: UserRole; - session: SessionView; -}; - const issuer = "Secure Webmail"; const createTotp = (email: string, secret: string): OTPAuth.TOTP => @@ -39,7 +19,7 @@ const createTotp = (email: string, secret: string): OTPAuth.TOTP => secret: OTPAuth.Secret.fromBase32(secret) }); -const buildSessionView = (email: string, role: UserRole, csrfToken: string): SessionView => { +const buildSessionView = (email: string, role: UserRole, csrfToken: string | null): SessionView => { const profile = ensureProfile(email, role); return { authenticated: true, @@ -54,34 +34,6 @@ const buildSessionView = (email: string, role: UserRole, csrfToken: string): Ses }; }; -const createSessionRecord = async (email: string, role: UserRole, request: Request): Promise => { - const id = randomToken(32); - const csrfToken = randomToken(24); - const now = Date.now(); - const userAgentHash = await sha256Hex(request.headers.get("user-agent") ?? "unknown"); - const ipHash = await sha256Hex(getClientIp(request)); - - createSession({ - id, - email, - role, - csrf_token: csrfToken, - expires_at: now + config.sessionTtlMs, - created_at: now, - last_seen_at: now, - user_agent_hash: userAgentHash, - ip_hash: ipHash - }); - - return { - id, - csrfToken, - email, - role, - session: buildSessionView(email, role, csrfToken) - }; -}; - export const seedAdminAccount = async (): Promise => { if (!config.adminEmail || !config.adminPassword) { return; @@ -106,11 +58,21 @@ export const login = async ( email: string, password: string, totpCode: string | undefined, - request: Request -): Promise<{ cookie: string; session: SessionView }> => { + _request: Request +): Promise<{ session: SessionView }> => { const normalizedEmail = strictEmailSchema.parse(email); let role: UserRole = "user"; + if (config.allowInsecureLogin) { + if (config.adminEmail && normalizedEmail === config.adminEmail) { + role = "admin"; + } + + return { + session: buildSessionView(normalizedEmail, role, null) + }; + } + const localAdmin = getAdmin(normalizedEmail); if (localAdmin) { const valid = await Bun.password.verify(password, localAdmin.password_hash); @@ -139,53 +101,27 @@ export const login = async ( } } - const activeSession = await createSessionRecord(normalizedEmail, role, request); return { - cookie: serializeSessionCookie(activeSession.id), - session: activeSession.session + session: buildSessionView(normalizedEmail, role, null) }; }; -export const getSessionFromRequest = async (request: Request, options?: { requireCsrf?: boolean; requireAdmin?: boolean }) => { - deleteExpiredSessions(); - - const cookies = parseCookies(request.headers.get("cookie")); - const sessionId = cookies[config.cookieName]; - if (!sessionId) { - throw new HttpError(401, "Authentication is required."); +export const getSessionFromRequest = async (request: Request) => { + const headerEmail = request.headers.get("x-user-email") ?? config.adminEmail; + if (!headerEmail) { + throw new HttpError(401, "X-User-Email header is required."); } - const row = getSession(sessionId); - if (!row || row.expires_at <= Date.now()) { - if (row) { - deleteSession(sessionId); - } - throw new HttpError(401, "Session expired. Please sign in again."); - } - - const userAgentHash = await sha256Hex(request.headers.get("user-agent") ?? "unknown"); - if (userAgentHash !== row.user_agent_hash) { - deleteSession(sessionId); - throw new HttpError(401, "Session validation failed."); - } - - if (options?.requireCsrf) { - assertCsrf(request, row.csrf_token); - } - - if (options?.requireAdmin && row.role !== "admin") { - throw new HttpError(403, "Administrator access is required."); - } - - const ipHash = await sha256Hex(getClientIp(request)); - touchSession(sessionId, Date.now() + config.sessionTtlMs, ipHash); + const email = strictEmailSchema.parse(headerEmail); + const role: UserRole = config.adminEmail && email === config.adminEmail ? "admin" : "user"; + ensureProfile(email, role); return { - id: row.id, - email: row.email, - role: row.role, - csrfToken: row.csrf_token, - session: buildSessionView(row.email, row.role, row.csrf_token) + id: "stateless", + email, + role, + csrfToken: null, + session: buildSessionView(email, role, null) }; }; @@ -195,16 +131,6 @@ export const getAnonymousSession = (): SessionView => ({ user: null }); -export const logout = (request: Request): string => { - const cookies = parseCookies(request.headers.get("cookie")); - const sessionId = cookies[config.cookieName]; - if (sessionId) { - deleteSession(sessionId); - } - - return serializeExpiredCookie(); -}; - export const createTotpSecret = async (email: string): Promise<{ secret: string; otpauthUrl: string }> => { const profile = getProfile(email) ?? ensureProfile(email); const secret = new OTPAuth.Secret({ size: 20 }).base32; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 4c12f6a..4e9b2fc 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,5 +1,5 @@ import { Elysia } from "elysia"; -import { login, logout, getAnonymousSession, getSessionFromRequest, seedAdminAccount, createTotpSecret, enableTotp, disableTotp } from "./auth/service"; +import { login, getAnonymousSession, getSessionFromRequest, seedAdminAccount, createTotpSecret, enableTotp, disableTotp } from "./auth/service"; import { HttpError } from "./lib/errors"; import { errorResponse, fileResponse, jsonResponse, parseJson, emptyResponse } from "./lib/http"; import { config } from "./lib/config"; @@ -55,16 +55,15 @@ app.get( app.post( "/api/auth/login", route(async (request) => { - enforceLimiter(loginLimiter, request, "login", config.loginRateLimit, config.loginWindowMs); + if (!config.allowInsecureLogin) { + enforceLimiter(loginLimiter, request, "login", config.loginRateLimit, config.loginWindowMs); + } const body = await parseJson(request, loginSchema); const result = await login(body.email, body.password, body.totpCode, request); return jsonResponse( request, 200, - { session: result.session }, - { - "Set-Cookie": result.cookie - } + { session: result.session } ); }) ); @@ -72,21 +71,14 @@ app.post( app.post( "/api/auth/logout", route(async (request) => { - const session = await getSessionFromRequest(request, { requireCsrf: true }); - const expiredCookie = logout(request); - return jsonResponse( - request, - 200, - { session: getAnonymousSession(), email: session.email }, - { "Set-Cookie": expiredCookie } - ); + return jsonResponse(request, 200, { session: getAnonymousSession() }); }) ); app.post( "/api/auth/totp/setup", route(async (request) => { - const session = await getSessionFromRequest(request, { requireCsrf: true }); + const session = await getSessionFromRequest(request); const setup = await createTotpSecret(session.email); return jsonResponse(request, 200, setup); }) @@ -95,7 +87,7 @@ app.post( app.post( "/api/auth/totp/enable", route(async (request) => { - const session = await getSessionFromRequest(request, { requireCsrf: true }); + const session = await getSessionFromRequest(request); const body = await parseJson(request, totpCodeSchema); await enableTotp(session.email, body.code); return emptyResponse(request, 204); @@ -105,7 +97,7 @@ app.post( app.post( "/api/auth/totp/disable", route(async (request) => { - const session = await getSessionFromRequest(request, { requireCsrf: true }); + const session = await getSessionFromRequest(request); const body = await parseJson(request, totpCodeSchema); await disableTotp(session.email, body.code); return emptyResponse(request, 204); @@ -123,7 +115,7 @@ app.get( app.patch( "/api/settings/profile", route(async (request) => { - const session = await getSessionFromRequest(request, { requireCsrf: true }); + const session = await getSessionFromRequest(request); const body = await parseJson(request, profileSchema); return jsonResponse(request, 200, updateDisplayName(session.email, body.displayName)); }) @@ -132,7 +124,7 @@ app.patch( app.post( "/api/settings/avatar", route(async (request) => { - const session = await getSessionFromRequest(request, { requireCsrf: true }); + const session = await getSessionFromRequest(request); const formData = await request.formData(); const avatar = formData.get("avatar"); if (!(avatar instanceof File)) { @@ -169,7 +161,7 @@ app.get( app.put( "/api/settings/forwarding", route(async (request) => { - const session = await getSessionFromRequest(request, { requireCsrf: true }); + const session = await getSessionFromRequest(request); const body = await parseJson(request, forwardingSchema); const rule = await updateForwarding(session.email, body.enabled, body.target); return jsonResponse(request, 200, rule); @@ -200,7 +192,7 @@ app.get( app.post( "/api/mail/send", route(async (request) => { - const session = await getSessionFromRequest(request, { requireCsrf: true }); + const session = await getSessionFromRequest(request); enforceLimiter(routeLimiter, request, "send-mail", 10, 60_000, session.email); const body = await parseJson(request, composeSchema); await composeAndSendMessage(session.email, body.to, body.subject, body.textBody); diff --git a/apps/api/src/lib/config.ts b/apps/api/src/lib/config.ts index b3cea24..6730af6 100644 --- a/apps/api/src/lib/config.ts +++ b/apps/api/src/lib/config.ts @@ -25,7 +25,6 @@ const envSchema = z.object({ MAILSERVER_TIMEOUT_MS: z.coerce.number().int().min(1000).max(60000).default(10000), HOSTED_DOMAINS: z.string().default("example.com"), FORWARDING_ALLOWED_DOMAINS: z.string().optional(), - SESSION_TTL_MINUTES: z.coerce.number().int().min(5).max(1440).default(15), GLOBAL_RATE_LIMIT: z.coerce.number().int().min(10).max(5000).default(240), ROUTE_RATE_LIMIT: z.coerce.number().int().min(5).max(1000).default(90), RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().min(10).max(3600).default(60), @@ -36,7 +35,8 @@ const envSchema = z.object({ TRUST_PROXY: z.enum(["true", "false"]).default("false"), APP_ADMIN_EMAIL: z.string().email().optional(), APP_ADMIN_PASSWORD: z.string().min(12).max(256).optional(), - TOTP_ENCRYPTION_KEY: z.string().optional() + TOTP_ENCRYPTION_KEY: z.string().optional(), + ALLOW_INSECURE_LOGIN: z.enum(["true", "false"]).default("false") }); const raw = envSchema.parse(normalizedEnv); @@ -58,9 +58,6 @@ export const config = { mailserverTimeoutMs: raw.MAILSERVER_TIMEOUT_MS, hostedDomains: splitList(raw.HOSTED_DOMAINS, "example.com"), forwardingAllowedDomains: splitList(raw.FORWARDING_ALLOWED_DOMAINS, raw.HOSTED_DOMAINS), - sessionTtlMs: raw.SESSION_TTL_MINUTES * 60_000, - cookieName: raw.NODE_ENV === "production" ? "__Host-webmail_session" : "webmail_session", - secureCookies: raw.NODE_ENV === "production", globalRateLimit: raw.GLOBAL_RATE_LIMIT, routeRateLimit: raw.ROUTE_RATE_LIMIT, rateLimitWindowMs: raw.RATE_LIMIT_WINDOW_SECONDS * 1000, @@ -71,5 +68,6 @@ export const config = { trustProxy: raw.TRUST_PROXY === "true", adminEmail: raw.APP_ADMIN_EMAIL?.toLowerCase() ?? null, adminPassword: raw.APP_ADMIN_PASSWORD ?? null, - totpEncryptionKey: raw.TOTP_ENCRYPTION_KEY ?? null + totpEncryptionKey: raw.TOTP_ENCRYPTION_KEY ?? null, + allowInsecureLogin: raw.ALLOW_INSECURE_LOGIN === "true" } as const; diff --git a/apps/api/src/lib/db.ts b/apps/api/src/lib/db.ts index 78f8431..2e55e8d 100644 --- a/apps/api/src/lib/db.ts +++ b/apps/api/src/lib/db.ts @@ -15,18 +15,6 @@ export type ProfileRow = { updated_at: number; }; -export type SessionRow = { - id: string; - email: string; - role: "admin" | "user"; - csrf_token: string; - expires_at: number; - created_at: number; - last_seen_at: number; - user_agent_hash: string; - ip_hash: string; -}; - type AdminRow = { email: string; password_hash: string; @@ -66,18 +54,6 @@ db.exec(` updated_at INTEGER NOT NULL ); - CREATE TABLE IF NOT EXISTS sessions ( - id TEXT PRIMARY KEY, - email TEXT NOT NULL, - role TEXT NOT NULL CHECK(role IN ('admin', 'user')), - csrf_token TEXT NOT NULL, - expires_at INTEGER NOT NULL, - created_at INTEGER NOT NULL, - last_seen_at INTEGER NOT NULL, - user_agent_hash TEXT NOT NULL, - ip_hash TEXT NOT NULL - ); - CREATE TABLE IF NOT EXISTS forwarding_rules ( email TEXT PRIMARY KEY, target_email TEXT, @@ -124,18 +100,6 @@ const upsertAdminStatement = db.query(` VALUES (?1, ?2, ?3, ?3) ON CONFLICT(email) DO UPDATE SET password_hash = excluded.password_hash, updated_at = excluded.updated_at `); -const insertSessionStatement = db.query(` - INSERT INTO sessions (id, email, role, csrf_token, expires_at, created_at, last_seen_at, user_agent_hash, ip_hash) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, ?7, ?8) -`); -const getSessionStatement = db.query("SELECT * FROM sessions WHERE id = ?1"); -const touchSessionStatement = db.query(` - UPDATE sessions - SET expires_at = ?1, last_seen_at = ?2, ip_hash = ?3 - WHERE id = ?4 -`); -const deleteSessionStatement = db.query("DELETE FROM sessions WHERE id = ?1"); -const deleteExpiredSessionsStatement = db.query("DELETE FROM sessions WHERE expires_at <= ?1"); const getForwardingStatement = db.query("SELECT * FROM forwarding_rules WHERE email = ?1"); const upsertForwardingStatement = db.query(` INSERT INTO forwarding_rules (email, target_email, enabled, updated_at) @@ -173,33 +137,6 @@ export const upsertAdmin = (email: string, passwordHash: string): void => { ensureProfile(email, "admin"); }; -export const createSession = (session: SessionRow): void => { - insertSessionStatement.run( - session.id, - session.email, - session.role, - session.csrf_token, - session.expires_at, - session.created_at, - session.user_agent_hash, - session.ip_hash - ); -}; - -export const getSession = (id: string): SessionRow | null => (getSessionStatement.get(id) as SessionRow | null) ?? null; - -export const touchSession = (id: string, expiresAt: number, ipHash: string): void => { - touchSessionStatement.run(expiresAt, Date.now(), ipHash, id); -}; - -export const deleteSession = (id: string): void => { - deleteSessionStatement.run(id); -}; - -export const deleteExpiredSessions = (): void => { - deleteExpiredSessionsStatement.run(Date.now()); -}; - export const getForwardingRule = (email: string): ForwardingRow | null => (getForwardingStatement.get(email) as ForwardingRow | null) ?? null; @@ -225,4 +162,3 @@ export const deleteAvatarFile = async (avatarPath: string | null): Promise // Best-effort cleanup of replaced avatars. } }; - diff --git a/apps/api/src/lib/security.ts b/apps/api/src/lib/security.ts index fe86a13..03d0c11 100644 --- a/apps/api/src/lib/security.ts +++ b/apps/api/src/lib/security.ts @@ -36,61 +36,6 @@ export const globalLimiter = new SlidingWindowLimiter(); export const routeLimiter = new SlidingWindowLimiter(); export const loginLimiter = new SlidingWindowLimiter(); -export const parseCookies = (cookieHeader: string | null): Record => { - if (!cookieHeader) { - return {}; - } - - return cookieHeader - .split(";") - .map((entry) => entry.trim()) - .filter(Boolean) - .reduce>((accumulator, entry) => { - const separatorIndex = entry.indexOf("="); - if (separatorIndex === -1) { - return accumulator; - } - - const key = entry.slice(0, separatorIndex).trim(); - const value = entry.slice(separatorIndex + 1).trim(); - accumulator[key] = decodeURIComponent(value); - return accumulator; - }, {}); -}; - -export const serializeSessionCookie = (value: string): string => { - const pieces = [ - `${config.cookieName}=${encodeURIComponent(value)}`, - "Path=/", - "HttpOnly", - "SameSite=Strict", - `Max-Age=${Math.floor(config.sessionTtlMs / 1000)}` - ]; - - if (config.secureCookies) { - pieces.push("Secure"); - } - - return pieces.join("; "); -}; - -export const serializeExpiredCookie = (): string => { - const pieces = [ - `${config.cookieName}=`, - "Path=/", - "HttpOnly", - "SameSite=Strict", - "Expires=Thu, 01 Jan 1970 00:00:00 GMT", - "Max-Age=0" - ]; - - if (config.secureCookies) { - pieces.push("Secure"); - } - - return pieces.join("; "); -}; - export const getClientIp = (request: Request): string => { if (config.trustProxy) { const forwarded = request.headers.get("x-forwarded-for"); @@ -118,13 +63,6 @@ export const assertAllowedOrigin = (request: Request): void => { } }; -export const assertCsrf = (request: Request, expectedToken: string): void => { - const csrfToken = request.headers.get("x-csrf-token"); - if (!csrfToken || csrfToken !== expectedToken) { - throw new HttpError(403, "CSRF validation failed."); - } -}; - export const buildResponseHeaders = (request: Request, init?: HeadersInit): Headers => { const headers = new Headers(init); headers.set("X-Frame-Options", "DENY"); @@ -153,13 +91,11 @@ export const buildResponseHeaders = (request: Request, init?: HeadersInit): Head const origin = request.headers.get("origin"); if (origin && config.allowedOrigins.has(origin.toLowerCase())) { headers.set("Access-Control-Allow-Origin", origin); - headers.set("Access-Control-Allow-Credentials", "true"); - headers.set("Access-Control-Allow-Headers", "Content-Type, X-CSRF-Token"); + headers.set("Access-Control-Allow-Headers", "Content-Type, X-User-Email"); headers.set("Access-Control-Allow-Methods", "GET, POST, PATCH, PUT, DELETE, OPTIONS"); headers.append("Vary", "Origin"); } - headers.append("Vary", "Cookie"); return headers; }; diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 7d2e688..9cde89c 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -9,11 +9,7 @@ import type { } from "@internal-mail/shared"; const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? ""; -let csrfToken: string | null = null; - -type RequestOptions = RequestInit & { - includeCsrf?: boolean; -}; +let activeEmail: string | null = null; const parseResponse = async (response: Response): Promise => { if (response.status === 204) { @@ -31,20 +27,19 @@ const parseResponse = async (response: Response): Promise => { return payload as T; }; -const request = async (path: string, options?: RequestOptions): Promise => { +const request = async (path: string, options?: RequestInit): Promise => { const headers = new Headers(options?.headers); const isFormData = options?.body instanceof FormData; if (!isFormData && options?.body && !headers.has("Content-Type")) { headers.set("Content-Type", "application/json"); } - if (options?.includeCsrf && csrfToken) { - headers.set("X-CSRF-Token", csrfToken); + if (activeEmail && !headers.has("X-User-Email")) { + headers.set("X-User-Email", activeEmail); } const response = await fetch(`${apiBaseUrl}${path}`, { ...options, - credentials: "include", headers }); @@ -52,13 +47,12 @@ const request = async (path: string, options?: RequestOptions): Promise => }; export const apiClient = { - setCsrfToken(value: string | null) { - csrfToken = value; + setActiveEmail(value: string | null) { + activeEmail = value; }, async getSession(): Promise { const payload = await request<{ session: SessionView }>("/api/auth/session"); - csrfToken = payload.session.csrfToken; return payload.session; }, @@ -68,16 +62,15 @@ export const apiClient = { body: JSON.stringify({ email, password, totpCode }) }); - csrfToken = payload.session.csrfToken; + activeEmail = payload.session.user?.email ?? email; return payload.session; }, async logout(): Promise { await request("/api/auth/logout", { - method: "POST", - includeCsrf: true + method: "POST" }); - csrfToken = null; + activeEmail = null; }, async getMessages(): Promise { @@ -92,7 +85,6 @@ export const apiClient = { async sendMessage(to: string[], subject: string, textBody: string): Promise { await request("/api/mail/send", { method: "POST", - includeCsrf: true, body: JSON.stringify({ to, subject, textBody }) }); }, @@ -104,7 +96,6 @@ export const apiClient = { async updateProfile(displayName: string): Promise { return request("/api/settings/profile", { method: "PATCH", - includeCsrf: true, body: JSON.stringify({ displayName }) }); }, @@ -114,7 +105,6 @@ export const apiClient = { formData.set("avatar", file); return request("/api/settings/avatar", { method: "POST", - includeCsrf: true, body: formData }); }, @@ -126,22 +116,19 @@ export const apiClient = { async updateForwarding(enabled: boolean, target: string | null): Promise { return request("/api/settings/forwarding", { method: "PUT", - includeCsrf: true, body: JSON.stringify({ enabled, target }) }); }, async setupTotp(): Promise { return request("/api/auth/totp/setup", { - method: "POST", - includeCsrf: true + method: "POST" }); }, async enableTotp(code: string): Promise { await request("/api/auth/totp/enable", { method: "POST", - includeCsrf: true, body: JSON.stringify({ code }) }); }, @@ -149,9 +136,7 @@ export const apiClient = { async disableTotp(code: string): Promise { await request("/api/auth/totp/disable", { method: "POST", - includeCsrf: true, body: JSON.stringify({ code }) }); } }; -