diff --git a/config/config.example.yml b/config/config.example.yml index 69a0e06e..c1895798 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -1,15 +1,15 @@ app: - dashboard_url: http://localhost + dashboard_url: http://localhost:3002 base_domain: localhost - log_level: debug + log_level: info save_logs: false server: external_port: 3000 internal_port: 3001 next_port: 3002 - internal_hostname: localhost - secure_cookies: false + internal_hostname: pangolin + secure_cookies: true session_cookie_name: p_session resource_session_cookie_name: p_resource_session resource_access_token_param: p_token @@ -38,4 +38,6 @@ users: password: Password123! flags: - require_email_verification: false + require_email_verification: true + disable_signup_without_invite: true + disable_user_create_org: true diff --git a/install/fs/traefik/dynamic_config.yml b/install/fs/traefik/dynamic_config.yml index 770c30ba..0361c9f7 100644 --- a/install/fs/traefik/dynamic_config.yml +++ b/install/fs/traefik/dynamic_config.yml @@ -4,6 +4,21 @@ http: redirectScheme: scheme: https permanent: true + cors: + headers: + accessControlAllowMethods: + - GET + - PUT + - POST + - DELETE + - PATCH + accessControlAllowHeaders: + - Content-Type + - X-CSRF-Token + accessControlAllowOriginList: + - https://{{.DashboardDomain}} + accessControlAllowCredentials: false + routers: # HTTP to HTTPS redirect router @@ -14,6 +29,7 @@ http: - web middlewares: - redirect-to-https + - cors # Next.js router (handles everything except API and WebSocket paths) next-router: @@ -21,6 +37,8 @@ http: service: next-service entryPoints: - websecure + middlewares: + - cors tls: certResolver: letsencrypt @@ -30,6 +48,8 @@ http: service: api-service entryPoints: - websecure + middlewares: + - cors tls: certResolver: letsencrypt @@ -39,6 +59,8 @@ http: service: api-service entryPoints: - websecure + middlewares: + - cors tls: certResolver: letsencrypt diff --git a/server/apiServer.ts b/server/apiServer.ts index 27796be9..9bab34a2 100644 --- a/server/apiServer.ts +++ b/server/apiServer.ts @@ -20,23 +20,30 @@ const externalPort = config.getRawConfig().server.external_port; export function createApiServer() { const apiServer = express(); - // Middleware setup apiServer.set("trust proxy", 1); - if (dev) { - apiServer.use( - cors({ - origin: `http://localhost:${config.getRawConfig().server.next_port}`, - credentials: true - }) - ); - } else { - const corsOptions = { - origin: config.getRawConfig().app.dashboard_url, - methods: ["GET", "POST", "PUT", "DELETE", "PATCH"], - allowedHeaders: ["Content-Type", "X-CSRF-Token"] - }; - apiServer.use(cors(corsOptions)); + const corsConfig = config.getRawConfig().server.cors; + + const options = { + ...(corsConfig?.origins + ? { origin: corsConfig.origins } + : { + origin: (origin: any, callback: any) => { + callback(null, true); + } + }), + ...(corsConfig?.methods && { methods: corsConfig.methods }), + ...(corsConfig?.allowed_headers && { + allowedHeaders: corsConfig.allowed_headers + }), + credentials: !(corsConfig?.credentials === false) + }; + + logger.debug("Using CORS options", options); + + apiServer.use(cors(options)); + + if (!dev) { apiServer.use(helmet()); apiServer.use(csrfProtectionMiddleware); } @@ -47,7 +54,8 @@ export function createApiServer() { if (!dev) { apiServer.use( rateLimitMiddleware({ - windowMin: config.getRawConfig().rate_limits.global.window_minutes, + windowMin: + config.getRawConfig().rate_limits.global.window_minutes, max: config.getRawConfig().rate_limits.global.max_requests, type: "IP_AND_PATH" }) diff --git a/server/auth/sessions/app.ts b/server/auth/sessions/app.ts index 4bf2c40d..ac91fc1f 100644 --- a/server/auth/sessions/app.ts +++ b/server/auth/sessions/app.ts @@ -1,6 +1,6 @@ import { encodeBase32LowerCaseNoPadding, - encodeHexLowerCase, + encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; import { Session, sessions, User, users } from "@server/db/schema"; @@ -9,8 +9,10 @@ import { eq } from "drizzle-orm"; import config from "@server/lib/config"; import type { RandomReader } from "@oslojs/crypto/random"; import { generateRandomString } from "@oslojs/crypto/random"; +import logger from "@server/logger"; -export const SESSION_COOKIE_NAME = config.getRawConfig().server.session_cookie_name; +export const SESSION_COOKIE_NAME = + config.getRawConfig().server.session_cookie_name; export const SESSION_COOKIE_EXPIRES = 1000 * 60 * 60 * 24 * 30; export const SECURE_COOKIES = config.getRawConfig().server.secure_cookies; export const COOKIE_DOMAIN = "." + config.getBaseDomain(); @@ -24,25 +26,25 @@ export function generateSessionToken(): string { export async function createSession( token: string, - userId: string, + userId: string ): Promise { const sessionId = encodeHexLowerCase( - sha256(new TextEncoder().encode(token)), + sha256(new TextEncoder().encode(token)) ); const session: Session = { sessionId: sessionId, userId, - expiresAt: new Date(Date.now() + SESSION_COOKIE_EXPIRES).getTime(), + expiresAt: new Date(Date.now() + SESSION_COOKIE_EXPIRES).getTime() }; await db.insert(sessions).values(session); return session; } export async function validateSessionToken( - token: string, + token: string ): Promise { const sessionId = encodeHexLowerCase( - sha256(new TextEncoder().encode(token)), + sha256(new TextEncoder().encode(token)) ); const result = await db .select({ user: users, session: sessions }) @@ -61,12 +63,12 @@ export async function validateSessionToken( } if (Date.now() >= session.expiresAt - SESSION_COOKIE_EXPIRES / 2) { session.expiresAt = new Date( - Date.now() + SESSION_COOKIE_EXPIRES, + Date.now() + SESSION_COOKIE_EXPIRES ).getTime(); await db .update(sessions) .set({ - expiresAt: session.expiresAt, + expiresAt: session.expiresAt }) .where(eq(sessions.sessionId, session.sessionId)); } @@ -81,26 +83,38 @@ export async function invalidateAllSessions(userId: string): Promise { await db.delete(sessions).where(eq(sessions.userId, userId)); } -export function serializeSessionCookie(token: string): string { - if (SECURE_COOKIES) { - return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`; +export function serializeSessionCookie( + token: string, + isSecure: boolean +): string { + if (isSecure) { + logger.debug("Setting cookie for secure origin"); + if (SECURE_COOKIES) { + return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`; + } else { + return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Domain=${COOKIE_DOMAIN}`; + } } else { - return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Domain=${COOKIE_DOMAIN}`; + return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/;`; } } -export function createBlankSessionTokenCookie(): string { - if (SECURE_COOKIES) { - return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`; +export function createBlankSessionTokenCookie(isSecure: boolean): string { + if (isSecure) { + if (SECURE_COOKIES) { + return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`; + } else { + return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${COOKIE_DOMAIN}`; + } } else { - return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${COOKIE_DOMAIN}`; + return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/;`; } } const random: RandomReader = { read(bytes: Uint8Array): void { crypto.getRandomValues(bytes); - }, + } }; export function generateId(length: number): string { diff --git a/server/lib/config.ts b/server/lib/config.ts index 2b19b304..01b7dc24 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -38,7 +38,13 @@ const environmentSchema = z.object({ secure_cookies: z.boolean(), session_cookie_name: z.string(), resource_session_cookie_name: z.string(), - resource_access_token_param: z.string() + resource_access_token_param: z.string(), + cors: z.object({ + origins: z.array(z.string()).optional(), + methods: z.array(z.string()).optional(), + allowed_headers: z.array(z.string()).optional(), + credentials: z.boolean().optional(), + }).optional() }), traefik: z.object({ http_entrypoint: z.string(), diff --git a/server/routers/auth/login.ts b/server/routers/auth/login.ts index f5c28ad5..328caeb9 100644 --- a/server/routers/auth/login.ts +++ b/server/routers/auth/login.ts @@ -120,7 +120,8 @@ export async function login( const token = generateSessionToken(); await createSession(token, existingUser.userId); - const cookie = serializeSessionCookie(token); + const isSecure = req.protocol === "https"; + const cookie = serializeSessionCookie(token, isSecure); res.appendHeader("Set-Cookie", cookie); diff --git a/server/routers/auth/logout.ts b/server/routers/auth/logout.ts index 29ca18f5..3b466767 100644 --- a/server/routers/auth/logout.ts +++ b/server/routers/auth/logout.ts @@ -27,7 +27,8 @@ export async function logout( try { await invalidateSession(sessionId); - res.setHeader("Set-Cookie", createBlankSessionTokenCookie()); + const isSecure = req.protocol === "https"; + res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure)); return response(res, { data: null, diff --git a/server/routers/auth/signup.ts b/server/routers/auth/signup.ts index 1bc2498e..9710d858 100644 --- a/server/routers/auth/signup.ts +++ b/server/routers/auth/signup.ts @@ -158,7 +158,8 @@ export async function signup( const token = generateSessionToken(); await createSession(token, userId); - const cookie = serializeSessionCookie(token); + const isSecure = req.protocol === "https"; + const cookie = serializeSessionCookie(token, isSecure); res.appendHeader("Set-Cookie", cookie); if (config.getRawConfig().flags?.require_email_verification) {