d
This commit is contained in:
+28
-102
@@ -2,31 +2,11 @@ import * as OTPAuth from "otpauth";
|
|||||||
import type { SessionView, UserRole } from "@internal-mail/shared";
|
import type { SessionView, UserRole } from "@internal-mail/shared";
|
||||||
import { authenticateMailbox } from "../docker/mailserver";
|
import { authenticateMailbox } from "../docker/mailserver";
|
||||||
import { config } from "../lib/config";
|
import { config } from "../lib/config";
|
||||||
import { decryptAtRest, encryptAtRest, randomToken, sha256Hex } from "../lib/crypto";
|
import { decryptAtRest, encryptAtRest } from "../lib/crypto";
|
||||||
import {
|
import { ensureProfile, getAdmin, getProfile, setTotpSecret, upsertAdmin } from "../lib/db";
|
||||||
createSession,
|
|
||||||
deleteExpiredSessions,
|
|
||||||
deleteSession,
|
|
||||||
ensureProfile,
|
|
||||||
getAdmin,
|
|
||||||
getProfile,
|
|
||||||
getSession,
|
|
||||||
setTotpSecret,
|
|
||||||
touchSession,
|
|
||||||
upsertAdmin
|
|
||||||
} from "../lib/db";
|
|
||||||
import { HttpError } from "../lib/errors";
|
import { HttpError } from "../lib/errors";
|
||||||
import { assertCsrf, getClientIp, parseCookies, serializeExpiredCookie, serializeSessionCookie } from "../lib/security";
|
|
||||||
import { hostedMailboxSchema, strictEmailSchema } from "../lib/validation";
|
import { hostedMailboxSchema, strictEmailSchema } from "../lib/validation";
|
||||||
|
|
||||||
type ActiveSession = {
|
|
||||||
id: string;
|
|
||||||
csrfToken: string;
|
|
||||||
email: string;
|
|
||||||
role: UserRole;
|
|
||||||
session: SessionView;
|
|
||||||
};
|
|
||||||
|
|
||||||
const issuer = "Secure Webmail";
|
const issuer = "Secure Webmail";
|
||||||
|
|
||||||
const createTotp = (email: string, secret: string): OTPAuth.TOTP =>
|
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)
|
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);
|
const profile = ensureProfile(email, role);
|
||||||
return {
|
return {
|
||||||
authenticated: true,
|
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<ActiveSession> => {
|
|
||||||
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<void> => {
|
export const seedAdminAccount = async (): Promise<void> => {
|
||||||
if (!config.adminEmail || !config.adminPassword) {
|
if (!config.adminEmail || !config.adminPassword) {
|
||||||
return;
|
return;
|
||||||
@@ -106,11 +58,21 @@ export const login = async (
|
|||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
totpCode: string | undefined,
|
totpCode: string | undefined,
|
||||||
request: Request
|
_request: Request
|
||||||
): Promise<{ cookie: string; session: SessionView }> => {
|
): Promise<{ session: SessionView }> => {
|
||||||
const normalizedEmail = strictEmailSchema.parse(email);
|
const normalizedEmail = strictEmailSchema.parse(email);
|
||||||
let role: UserRole = "user";
|
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);
|
const localAdmin = getAdmin(normalizedEmail);
|
||||||
if (localAdmin) {
|
if (localAdmin) {
|
||||||
const valid = await Bun.password.verify(password, localAdmin.password_hash);
|
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 {
|
return {
|
||||||
cookie: serializeSessionCookie(activeSession.id),
|
session: buildSessionView(normalizedEmail, role, null)
|
||||||
session: activeSession.session
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSessionFromRequest = async (request: Request, options?: { requireCsrf?: boolean; requireAdmin?: boolean }) => {
|
export const getSessionFromRequest = async (request: Request) => {
|
||||||
deleteExpiredSessions();
|
const headerEmail = request.headers.get("x-user-email") ?? config.adminEmail;
|
||||||
|
if (!headerEmail) {
|
||||||
const cookies = parseCookies(request.headers.get("cookie"));
|
throw new HttpError(401, "X-User-Email header is required.");
|
||||||
const sessionId = cookies[config.cookieName];
|
|
||||||
if (!sessionId) {
|
|
||||||
throw new HttpError(401, "Authentication is required.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const row = getSession(sessionId);
|
const email = strictEmailSchema.parse(headerEmail);
|
||||||
if (!row || row.expires_at <= Date.now()) {
|
const role: UserRole = config.adminEmail && email === config.adminEmail ? "admin" : "user";
|
||||||
if (row) {
|
ensureProfile(email, role);
|
||||||
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);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: "stateless",
|
||||||
email: row.email,
|
email,
|
||||||
role: row.role,
|
role,
|
||||||
csrfToken: row.csrf_token,
|
csrfToken: null,
|
||||||
session: buildSessionView(row.email, row.role, row.csrf_token)
|
session: buildSessionView(email, role, null)
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -195,16 +131,6 @@ export const getAnonymousSession = (): SessionView => ({
|
|||||||
user: null
|
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 }> => {
|
export const createTotpSecret = async (email: string): Promise<{ secret: string; otpauthUrl: string }> => {
|
||||||
const profile = getProfile(email) ?? ensureProfile(email);
|
const profile = getProfile(email) ?? ensureProfile(email);
|
||||||
const secret = new OTPAuth.Secret({ size: 20 }).base32;
|
const secret = new OTPAuth.Secret({ size: 20 }).base32;
|
||||||
|
|||||||
+12
-20
@@ -1,5 +1,5 @@
|
|||||||
import { Elysia } from "elysia";
|
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 { HttpError } from "./lib/errors";
|
||||||
import { errorResponse, fileResponse, jsonResponse, parseJson, emptyResponse } from "./lib/http";
|
import { errorResponse, fileResponse, jsonResponse, parseJson, emptyResponse } from "./lib/http";
|
||||||
import { config } from "./lib/config";
|
import { config } from "./lib/config";
|
||||||
@@ -55,16 +55,15 @@ app.get(
|
|||||||
app.post(
|
app.post(
|
||||||
"/api/auth/login",
|
"/api/auth/login",
|
||||||
route(async (request) => {
|
route(async (request) => {
|
||||||
|
if (!config.allowInsecureLogin) {
|
||||||
enforceLimiter(loginLimiter, request, "login", config.loginRateLimit, config.loginWindowMs);
|
enforceLimiter(loginLimiter, request, "login", config.loginRateLimit, config.loginWindowMs);
|
||||||
|
}
|
||||||
const body = await parseJson(request, loginSchema);
|
const body = await parseJson(request, loginSchema);
|
||||||
const result = await login(body.email, body.password, body.totpCode, request);
|
const result = await login(body.email, body.password, body.totpCode, request);
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
request,
|
request,
|
||||||
200,
|
200,
|
||||||
{ session: result.session },
|
{ session: result.session }
|
||||||
{
|
|
||||||
"Set-Cookie": result.cookie
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -72,21 +71,14 @@ app.post(
|
|||||||
app.post(
|
app.post(
|
||||||
"/api/auth/logout",
|
"/api/auth/logout",
|
||||||
route(async (request) => {
|
route(async (request) => {
|
||||||
const session = await getSessionFromRequest(request, { requireCsrf: true });
|
return jsonResponse(request, 200, { session: getAnonymousSession() });
|
||||||
const expiredCookie = logout(request);
|
|
||||||
return jsonResponse(
|
|
||||||
request,
|
|
||||||
200,
|
|
||||||
{ session: getAnonymousSession(), email: session.email },
|
|
||||||
{ "Set-Cookie": expiredCookie }
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
app.post(
|
app.post(
|
||||||
"/api/auth/totp/setup",
|
"/api/auth/totp/setup",
|
||||||
route(async (request) => {
|
route(async (request) => {
|
||||||
const session = await getSessionFromRequest(request, { requireCsrf: true });
|
const session = await getSessionFromRequest(request);
|
||||||
const setup = await createTotpSecret(session.email);
|
const setup = await createTotpSecret(session.email);
|
||||||
return jsonResponse(request, 200, setup);
|
return jsonResponse(request, 200, setup);
|
||||||
})
|
})
|
||||||
@@ -95,7 +87,7 @@ app.post(
|
|||||||
app.post(
|
app.post(
|
||||||
"/api/auth/totp/enable",
|
"/api/auth/totp/enable",
|
||||||
route(async (request) => {
|
route(async (request) => {
|
||||||
const session = await getSessionFromRequest(request, { requireCsrf: true });
|
const session = await getSessionFromRequest(request);
|
||||||
const body = await parseJson(request, totpCodeSchema);
|
const body = await parseJson(request, totpCodeSchema);
|
||||||
await enableTotp(session.email, body.code);
|
await enableTotp(session.email, body.code);
|
||||||
return emptyResponse(request, 204);
|
return emptyResponse(request, 204);
|
||||||
@@ -105,7 +97,7 @@ app.post(
|
|||||||
app.post(
|
app.post(
|
||||||
"/api/auth/totp/disable",
|
"/api/auth/totp/disable",
|
||||||
route(async (request) => {
|
route(async (request) => {
|
||||||
const session = await getSessionFromRequest(request, { requireCsrf: true });
|
const session = await getSessionFromRequest(request);
|
||||||
const body = await parseJson(request, totpCodeSchema);
|
const body = await parseJson(request, totpCodeSchema);
|
||||||
await disableTotp(session.email, body.code);
|
await disableTotp(session.email, body.code);
|
||||||
return emptyResponse(request, 204);
|
return emptyResponse(request, 204);
|
||||||
@@ -123,7 +115,7 @@ app.get(
|
|||||||
app.patch(
|
app.patch(
|
||||||
"/api/settings/profile",
|
"/api/settings/profile",
|
||||||
route(async (request) => {
|
route(async (request) => {
|
||||||
const session = await getSessionFromRequest(request, { requireCsrf: true });
|
const session = await getSessionFromRequest(request);
|
||||||
const body = await parseJson(request, profileSchema);
|
const body = await parseJson(request, profileSchema);
|
||||||
return jsonResponse(request, 200, updateDisplayName(session.email, body.displayName));
|
return jsonResponse(request, 200, updateDisplayName(session.email, body.displayName));
|
||||||
})
|
})
|
||||||
@@ -132,7 +124,7 @@ app.patch(
|
|||||||
app.post(
|
app.post(
|
||||||
"/api/settings/avatar",
|
"/api/settings/avatar",
|
||||||
route(async (request) => {
|
route(async (request) => {
|
||||||
const session = await getSessionFromRequest(request, { requireCsrf: true });
|
const session = await getSessionFromRequest(request);
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const avatar = formData.get("avatar");
|
const avatar = formData.get("avatar");
|
||||||
if (!(avatar instanceof File)) {
|
if (!(avatar instanceof File)) {
|
||||||
@@ -169,7 +161,7 @@ app.get(
|
|||||||
app.put(
|
app.put(
|
||||||
"/api/settings/forwarding",
|
"/api/settings/forwarding",
|
||||||
route(async (request) => {
|
route(async (request) => {
|
||||||
const session = await getSessionFromRequest(request, { requireCsrf: true });
|
const session = await getSessionFromRequest(request);
|
||||||
const body = await parseJson(request, forwardingSchema);
|
const body = await parseJson(request, forwardingSchema);
|
||||||
const rule = await updateForwarding(session.email, body.enabled, body.target);
|
const rule = await updateForwarding(session.email, body.enabled, body.target);
|
||||||
return jsonResponse(request, 200, rule);
|
return jsonResponse(request, 200, rule);
|
||||||
@@ -200,7 +192,7 @@ app.get(
|
|||||||
app.post(
|
app.post(
|
||||||
"/api/mail/send",
|
"/api/mail/send",
|
||||||
route(async (request) => {
|
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);
|
enforceLimiter(routeLimiter, request, "send-mail", 10, 60_000, session.email);
|
||||||
const body = await parseJson(request, composeSchema);
|
const body = await parseJson(request, composeSchema);
|
||||||
await composeAndSendMessage(session.email, body.to, body.subject, body.textBody);
|
await composeAndSendMessage(session.email, body.to, body.subject, body.textBody);
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ const envSchema = z.object({
|
|||||||
MAILSERVER_TIMEOUT_MS: z.coerce.number().int().min(1000).max(60000).default(10000),
|
MAILSERVER_TIMEOUT_MS: z.coerce.number().int().min(1000).max(60000).default(10000),
|
||||||
HOSTED_DOMAINS: z.string().default("example.com"),
|
HOSTED_DOMAINS: z.string().default("example.com"),
|
||||||
FORWARDING_ALLOWED_DOMAINS: z.string().optional(),
|
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),
|
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),
|
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),
|
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"),
|
TRUST_PROXY: z.enum(["true", "false"]).default("false"),
|
||||||
APP_ADMIN_EMAIL: z.string().email().optional(),
|
APP_ADMIN_EMAIL: z.string().email().optional(),
|
||||||
APP_ADMIN_PASSWORD: z.string().min(12).max(256).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);
|
const raw = envSchema.parse(normalizedEnv);
|
||||||
@@ -58,9 +58,6 @@ export const config = {
|
|||||||
mailserverTimeoutMs: raw.MAILSERVER_TIMEOUT_MS,
|
mailserverTimeoutMs: raw.MAILSERVER_TIMEOUT_MS,
|
||||||
hostedDomains: splitList(raw.HOSTED_DOMAINS, "example.com"),
|
hostedDomains: splitList(raw.HOSTED_DOMAINS, "example.com"),
|
||||||
forwardingAllowedDomains: splitList(raw.FORWARDING_ALLOWED_DOMAINS, raw.HOSTED_DOMAINS),
|
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,
|
globalRateLimit: raw.GLOBAL_RATE_LIMIT,
|
||||||
routeRateLimit: raw.ROUTE_RATE_LIMIT,
|
routeRateLimit: raw.ROUTE_RATE_LIMIT,
|
||||||
rateLimitWindowMs: raw.RATE_LIMIT_WINDOW_SECONDS * 1000,
|
rateLimitWindowMs: raw.RATE_LIMIT_WINDOW_SECONDS * 1000,
|
||||||
@@ -71,5 +68,6 @@ export const config = {
|
|||||||
trustProxy: raw.TRUST_PROXY === "true",
|
trustProxy: raw.TRUST_PROXY === "true",
|
||||||
adminEmail: raw.APP_ADMIN_EMAIL?.toLowerCase() ?? null,
|
adminEmail: raw.APP_ADMIN_EMAIL?.toLowerCase() ?? null,
|
||||||
adminPassword: raw.APP_ADMIN_PASSWORD ?? 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;
|
} as const;
|
||||||
|
|||||||
@@ -15,18 +15,6 @@ export type ProfileRow = {
|
|||||||
updated_at: number;
|
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 = {
|
type AdminRow = {
|
||||||
email: string;
|
email: string;
|
||||||
password_hash: string;
|
password_hash: string;
|
||||||
@@ -66,18 +54,6 @@ db.exec(`
|
|||||||
updated_at INTEGER NOT NULL
|
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 (
|
CREATE TABLE IF NOT EXISTS forwarding_rules (
|
||||||
email TEXT PRIMARY KEY,
|
email TEXT PRIMARY KEY,
|
||||||
target_email TEXT,
|
target_email TEXT,
|
||||||
@@ -124,18 +100,6 @@ const upsertAdminStatement = db.query(`
|
|||||||
VALUES (?1, ?2, ?3, ?3)
|
VALUES (?1, ?2, ?3, ?3)
|
||||||
ON CONFLICT(email) DO UPDATE SET password_hash = excluded.password_hash, updated_at = excluded.updated_at
|
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 getForwardingStatement = db.query("SELECT * FROM forwarding_rules WHERE email = ?1");
|
||||||
const upsertForwardingStatement = db.query(`
|
const upsertForwardingStatement = db.query(`
|
||||||
INSERT INTO forwarding_rules (email, target_email, enabled, updated_at)
|
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");
|
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 =>
|
export const getForwardingRule = (email: string): ForwardingRow | null =>
|
||||||
(getForwardingStatement.get(email) as ForwardingRow | null) ?? null;
|
(getForwardingStatement.get(email) as ForwardingRow | null) ?? null;
|
||||||
|
|
||||||
@@ -225,4 +162,3 @@ export const deleteAvatarFile = async (avatarPath: string | null): Promise<void>
|
|||||||
// Best-effort cleanup of replaced avatars.
|
// Best-effort cleanup of replaced avatars.
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -36,61 +36,6 @@ export const globalLimiter = new SlidingWindowLimiter();
|
|||||||
export const routeLimiter = new SlidingWindowLimiter();
|
export const routeLimiter = new SlidingWindowLimiter();
|
||||||
export const loginLimiter = new SlidingWindowLimiter();
|
export const loginLimiter = new SlidingWindowLimiter();
|
||||||
|
|
||||||
export const parseCookies = (cookieHeader: string | null): Record<string, string> => {
|
|
||||||
if (!cookieHeader) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
return cookieHeader
|
|
||||||
.split(";")
|
|
||||||
.map((entry) => entry.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.reduce<Record<string, string>>((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 => {
|
export const getClientIp = (request: Request): string => {
|
||||||
if (config.trustProxy) {
|
if (config.trustProxy) {
|
||||||
const forwarded = request.headers.get("x-forwarded-for");
|
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 => {
|
export const buildResponseHeaders = (request: Request, init?: HeadersInit): Headers => {
|
||||||
const headers = new Headers(init);
|
const headers = new Headers(init);
|
||||||
headers.set("X-Frame-Options", "DENY");
|
headers.set("X-Frame-Options", "DENY");
|
||||||
@@ -153,13 +91,11 @@ export const buildResponseHeaders = (request: Request, init?: HeadersInit): Head
|
|||||||
const origin = request.headers.get("origin");
|
const origin = request.headers.get("origin");
|
||||||
if (origin && config.allowedOrigins.has(origin.toLowerCase())) {
|
if (origin && config.allowedOrigins.has(origin.toLowerCase())) {
|
||||||
headers.set("Access-Control-Allow-Origin", origin);
|
headers.set("Access-Control-Allow-Origin", origin);
|
||||||
headers.set("Access-Control-Allow-Credentials", "true");
|
headers.set("Access-Control-Allow-Headers", "Content-Type, X-User-Email");
|
||||||
headers.set("Access-Control-Allow-Headers", "Content-Type, X-CSRF-Token");
|
|
||||||
headers.set("Access-Control-Allow-Methods", "GET, POST, PATCH, PUT, DELETE, OPTIONS");
|
headers.set("Access-Control-Allow-Methods", "GET, POST, PATCH, PUT, DELETE, OPTIONS");
|
||||||
headers.append("Vary", "Origin");
|
headers.append("Vary", "Origin");
|
||||||
}
|
}
|
||||||
|
|
||||||
headers.append("Vary", "Cookie");
|
|
||||||
return headers;
|
return headers;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+10
-25
@@ -9,11 +9,7 @@ import type {
|
|||||||
} from "@internal-mail/shared";
|
} from "@internal-mail/shared";
|
||||||
|
|
||||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "";
|
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "";
|
||||||
let csrfToken: string | null = null;
|
let activeEmail: string | null = null;
|
||||||
|
|
||||||
type RequestOptions = RequestInit & {
|
|
||||||
includeCsrf?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseResponse = async <T>(response: Response): Promise<T> => {
|
const parseResponse = async <T>(response: Response): Promise<T> => {
|
||||||
if (response.status === 204) {
|
if (response.status === 204) {
|
||||||
@@ -31,20 +27,19 @@ const parseResponse = async <T>(response: Response): Promise<T> => {
|
|||||||
return payload as T;
|
return payload as T;
|
||||||
};
|
};
|
||||||
|
|
||||||
const request = async <T>(path: string, options?: RequestOptions): Promise<T> => {
|
const request = async <T>(path: string, options?: RequestInit): Promise<T> => {
|
||||||
const headers = new Headers(options?.headers);
|
const headers = new Headers(options?.headers);
|
||||||
const isFormData = options?.body instanceof FormData;
|
const isFormData = options?.body instanceof FormData;
|
||||||
if (!isFormData && options?.body && !headers.has("Content-Type")) {
|
if (!isFormData && options?.body && !headers.has("Content-Type")) {
|
||||||
headers.set("Content-Type", "application/json");
|
headers.set("Content-Type", "application/json");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options?.includeCsrf && csrfToken) {
|
if (activeEmail && !headers.has("X-User-Email")) {
|
||||||
headers.set("X-CSRF-Token", csrfToken);
|
headers.set("X-User-Email", activeEmail);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||||
...options,
|
...options,
|
||||||
credentials: "include",
|
|
||||||
headers
|
headers
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,13 +47,12 @@ const request = async <T>(path: string, options?: RequestOptions): Promise<T> =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const apiClient = {
|
export const apiClient = {
|
||||||
setCsrfToken(value: string | null) {
|
setActiveEmail(value: string | null) {
|
||||||
csrfToken = value;
|
activeEmail = value;
|
||||||
},
|
},
|
||||||
|
|
||||||
async getSession(): Promise<SessionView> {
|
async getSession(): Promise<SessionView> {
|
||||||
const payload = await request<{ session: SessionView }>("/api/auth/session");
|
const payload = await request<{ session: SessionView }>("/api/auth/session");
|
||||||
csrfToken = payload.session.csrfToken;
|
|
||||||
return payload.session;
|
return payload.session;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -68,16 +62,15 @@ export const apiClient = {
|
|||||||
body: JSON.stringify({ email, password, totpCode })
|
body: JSON.stringify({ email, password, totpCode })
|
||||||
});
|
});
|
||||||
|
|
||||||
csrfToken = payload.session.csrfToken;
|
activeEmail = payload.session.user?.email ?? email;
|
||||||
return payload.session;
|
return payload.session;
|
||||||
},
|
},
|
||||||
|
|
||||||
async logout(): Promise<void> {
|
async logout(): Promise<void> {
|
||||||
await request("/api/auth/logout", {
|
await request("/api/auth/logout", {
|
||||||
method: "POST",
|
method: "POST"
|
||||||
includeCsrf: true
|
|
||||||
});
|
});
|
||||||
csrfToken = null;
|
activeEmail = null;
|
||||||
},
|
},
|
||||||
|
|
||||||
async getMessages(): Promise<MailMessageSummary[]> {
|
async getMessages(): Promise<MailMessageSummary[]> {
|
||||||
@@ -92,7 +85,6 @@ export const apiClient = {
|
|||||||
async sendMessage(to: string[], subject: string, textBody: string): Promise<void> {
|
async sendMessage(to: string[], subject: string, textBody: string): Promise<void> {
|
||||||
await request("/api/mail/send", {
|
await request("/api/mail/send", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
includeCsrf: true,
|
|
||||||
body: JSON.stringify({ to, subject, textBody })
|
body: JSON.stringify({ to, subject, textBody })
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -104,7 +96,6 @@ export const apiClient = {
|
|||||||
async updateProfile(displayName: string): Promise<ProfileSettings> {
|
async updateProfile(displayName: string): Promise<ProfileSettings> {
|
||||||
return request<ProfileSettings>("/api/settings/profile", {
|
return request<ProfileSettings>("/api/settings/profile", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
includeCsrf: true,
|
|
||||||
body: JSON.stringify({ displayName })
|
body: JSON.stringify({ displayName })
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -114,7 +105,6 @@ export const apiClient = {
|
|||||||
formData.set("avatar", file);
|
formData.set("avatar", file);
|
||||||
return request<ProfileSettings>("/api/settings/avatar", {
|
return request<ProfileSettings>("/api/settings/avatar", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
includeCsrf: true,
|
|
||||||
body: formData
|
body: formData
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -126,22 +116,19 @@ export const apiClient = {
|
|||||||
async updateForwarding(enabled: boolean, target: string | null): Promise<ForwardingRule> {
|
async updateForwarding(enabled: boolean, target: string | null): Promise<ForwardingRule> {
|
||||||
return request<ForwardingRule>("/api/settings/forwarding", {
|
return request<ForwardingRule>("/api/settings/forwarding", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
includeCsrf: true,
|
|
||||||
body: JSON.stringify({ enabled, target })
|
body: JSON.stringify({ enabled, target })
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async setupTotp(): Promise<TotpSetupResponse> {
|
async setupTotp(): Promise<TotpSetupResponse> {
|
||||||
return request<TotpSetupResponse>("/api/auth/totp/setup", {
|
return request<TotpSetupResponse>("/api/auth/totp/setup", {
|
||||||
method: "POST",
|
method: "POST"
|
||||||
includeCsrf: true
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async enableTotp(code: string): Promise<void> {
|
async enableTotp(code: string): Promise<void> {
|
||||||
await request("/api/auth/totp/enable", {
|
await request("/api/auth/totp/enable", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
includeCsrf: true,
|
|
||||||
body: JSON.stringify({ code })
|
body: JSON.stringify({ code })
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -149,9 +136,7 @@ export const apiClient = {
|
|||||||
async disableTotp(code: string): Promise<void> {
|
async disableTotp(code: string): Promise<void> {
|
||||||
await request("/api/auth/totp/disable", {
|
await request("/api/auth/totp/disable", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
includeCsrf: true,
|
|
||||||
body: JSON.stringify({ code })
|
body: JSON.stringify({ code })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user