Security audit follow-up: httpOnly cookies, ticket reservations, MongoDB keyfile
Deferred findings from the March 27 security audit, plus a bug fix: MongoDB keyfile (bug fix): - Generate replica.key on first boot via entrypoint script - Fixes crash from --auth + --keyFile without an existing keyfile - Applied to docker-compose.yml, docker-compose.prod.yml, CCP template I7 — Ticket overselling prevention (reservation pattern): - Add reservedCount field to TicketTier schema - Atomically increment reservedCount inside transaction on checkout - Release reservation on checkout.session.completed (webhook) - Release reservation on checkout.session.expired (webhook) - Include reservedCount in availability calculations I17 — Move refresh token to httpOnly cookie: - Server sets httpOnly SameSite=Strict cookie on login/register/refresh - Cookie scoped to /api/auth path, secure in production - Refresh/logout endpoints read from cookie (with body fallback for compat) - Frontend no longer stores refreshToken in localStorage - Auth store simplified: removed refreshToken from state + persistence - API interceptor uses withCredentials:true for automatic cookie sending - Updated media-api, media-public-api, QuickJoinPage, volunteer-invite - Renamed getTokens → getAccessToken across all media components - Install cookie-parser middleware L2 — FeatureGate loading state: - Show Skeleton instead of children while settings are loading - Prevents briefly exposing disabled feature pages Bunker Admin
This commit is contained in:
parent
82a66a97d0
commit
b215cda018
@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Result, Button } from 'antd';
|
||||
import { Result, Button, Skeleton } from 'antd';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { SettingOutlined } from '@ant-design/icons';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
@ -36,8 +36,8 @@ export default function FeatureGate({ feature, children }: FeatureGateProps) {
|
||||
const isSuperAdmin = hasAnyRole(user, ['SUPER_ADMIN']);
|
||||
const featureName = FEATURE_LABELS[feature] || feature;
|
||||
|
||||
// While loading or if settings haven't arrived yet, render children (optimistic)
|
||||
if (loading || !settings) return <>{children}</>;
|
||||
// Show skeleton while settings are loading to prevent briefly showing disabled features
|
||||
if (loading || !settings) return <Skeleton active style={{ padding: 24 }} />;
|
||||
|
||||
if (settings[feature] === false) {
|
||||
return (
|
||||
|
||||
@ -5,8 +5,8 @@ import type { PhotoAlbum } from '@/types/media';
|
||||
|
||||
/** Append JWT access token as query param for <img> src URLs */
|
||||
function getAuthenticatedUrl(url: string): string {
|
||||
const { getTokens } = getAuthCallbacks();
|
||||
const { accessToken } = getTokens();
|
||||
const { getAccessToken } = getAuthCallbacks();
|
||||
const accessToken = getAccessToken();
|
||||
if (!accessToken) return url;
|
||||
const separator = url.includes('?') ? '&' : '?';
|
||||
return `${url}${separator}token=${accessToken}`;
|
||||
|
||||
@ -12,8 +12,8 @@ import type { PhotoAlbum, PhotoAlbumItem } from '@/types/media';
|
||||
|
||||
/** Append JWT access token as query param for <img> src URLs */
|
||||
function getAuthenticatedUrl(url: string): string {
|
||||
const { getTokens } = getAuthCallbacks();
|
||||
const { accessToken } = getTokens();
|
||||
const { getAccessToken } = getAuthCallbacks();
|
||||
const accessToken = getAccessToken();
|
||||
if (!accessToken) return url;
|
||||
const separator = url.includes('?') ? '&' : '?';
|
||||
return `${url}${separator}token=${accessToken}`;
|
||||
|
||||
@ -13,8 +13,8 @@ import type { Photo } from '@/types/media';
|
||||
|
||||
/** Append JWT access token as query param for <img> src URLs */
|
||||
function getAuthenticatedUrl(url: string): string {
|
||||
const { getTokens } = getAuthCallbacks();
|
||||
const { accessToken } = getTokens();
|
||||
const { getAccessToken } = getAuthCallbacks();
|
||||
const accessToken = getAccessToken();
|
||||
if (!accessToken) return url;
|
||||
const separator = url.includes('?') ? '&' : '?';
|
||||
return `${url}${separator}token=${accessToken}`;
|
||||
|
||||
@ -5,8 +5,8 @@ import type { Photo } from '@/types/media';
|
||||
|
||||
/** Append JWT access token as query param for <img> src URLs */
|
||||
function getAuthenticatedUrl(url: string): string {
|
||||
const { getTokens } = getAuthCallbacks();
|
||||
const { accessToken } = getTokens();
|
||||
const { getAccessToken } = getAuthCallbacks();
|
||||
const accessToken = getAccessToken();
|
||||
if (!accessToken) return url;
|
||||
const separator = url.includes('?') ? '&' : '?';
|
||||
return `${url}${separator}token=${accessToken}`;
|
||||
|
||||
@ -8,8 +8,8 @@ import ScheduleBadge from './ScheduleBadge';
|
||||
|
||||
/** Append JWT access token as query param for <img>/<video> src URLs */
|
||||
function getAuthenticatedUrl(url: string): string {
|
||||
const { getTokens } = getAuthCallbacks();
|
||||
const { accessToken } = getTokens();
|
||||
const { getAccessToken } = getAuthCallbacks();
|
||||
const accessToken = getAccessToken();
|
||||
if (!accessToken) return url;
|
||||
const separator = url.includes('?') ? '&' : '?';
|
||||
return `${url}${separator}token=${accessToken}`;
|
||||
|
||||
@ -124,8 +124,8 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
|
||||
|
||||
const appendToken = (url: string): string => {
|
||||
if (!isAdmin) return url;
|
||||
const { getTokens } = getAuthCallbacks();
|
||||
const { accessToken } = getTokens();
|
||||
const { getAccessToken } = getAuthCallbacks();
|
||||
const accessToken = getAccessToken();
|
||||
if (!accessToken) return url;
|
||||
const sep = url.includes('?') ? '&' : '?';
|
||||
return `${url}${sep}token=${accessToken}`;
|
||||
@ -139,8 +139,8 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
|
||||
// Use relative URL to go through nginx proxy
|
||||
const headers: Record<string, string> = {};
|
||||
if (isAdmin) {
|
||||
const { getTokens } = getAuthCallbacks();
|
||||
const { accessToken } = getTokens();
|
||||
const { getAccessToken } = getAuthCallbacks();
|
||||
const accessToken = getAccessToken();
|
||||
if (accessToken) {
|
||||
headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
@ -6,8 +6,8 @@ import { getAuthCallbacks } from '@/lib/api';
|
||||
|
||||
/** Append JWT access token as query param for <video> src URLs */
|
||||
function getAuthenticatedUrl(url: string): string {
|
||||
const { getTokens } = getAuthCallbacks();
|
||||
const { accessToken } = getTokens();
|
||||
const { getAccessToken } = getAuthCallbacks();
|
||||
const accessToken = getAccessToken();
|
||||
if (!accessToken) return url;
|
||||
const separator = url.includes('?') ? '&' : '?';
|
||||
return `${url}${separator}token=${accessToken}`;
|
||||
|
||||
@ -3,32 +3,32 @@ import type { AuthResponse } from '@/types/api';
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: '/api',
|
||||
withCredentials: true, // Send httpOnly cookies with all requests
|
||||
});
|
||||
|
||||
// Token accessor — set by auth store on init to break circular dependency
|
||||
let getTokens: () => { accessToken: string | null; refreshToken: string | null } =
|
||||
() => ({ accessToken: null, refreshToken: null });
|
||||
let onTokenRefresh: (accessToken: string, refreshToken: string) => void = () => {};
|
||||
let getAccessToken: () => string | null = () => null;
|
||||
let onTokenRefresh: (accessToken: string) => void = () => {};
|
||||
let onAuthFailure: () => void = () => {};
|
||||
|
||||
export function registerAuthCallbacks(callbacks: {
|
||||
getTokens: typeof getTokens;
|
||||
getAccessToken: typeof getAccessToken;
|
||||
onTokenRefresh: typeof onTokenRefresh;
|
||||
onAuthFailure: typeof onAuthFailure;
|
||||
}) {
|
||||
getTokens = callbacks.getTokens;
|
||||
getAccessToken = callbacks.getAccessToken;
|
||||
onTokenRefresh = callbacks.onTokenRefresh;
|
||||
onAuthFailure = callbacks.onAuthFailure;
|
||||
}
|
||||
|
||||
// Helper to get current callbacks (for use in other API clients)
|
||||
export function getAuthCallbacks() {
|
||||
return { getTokens, onTokenRefresh, onAuthFailure };
|
||||
return { getAccessToken, onTokenRefresh, onAuthFailure };
|
||||
}
|
||||
|
||||
// Request interceptor: attach access token
|
||||
api.interceptors.request.use((config) => {
|
||||
const { accessToken } = getTokens();
|
||||
const accessToken = getAccessToken();
|
||||
if (accessToken) {
|
||||
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
@ -51,16 +51,11 @@ api.interceptors.response.use(
|
||||
) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
const { refreshToken } = getTokens();
|
||||
if (!refreshToken) {
|
||||
onAuthFailure();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
try {
|
||||
if (!refreshPromise) {
|
||||
// Refresh token sent automatically as httpOnly cookie
|
||||
refreshPromise = api
|
||||
.post<AuthResponse>('/auth/refresh', { refreshToken })
|
||||
.post<AuthResponse>('/auth/refresh')
|
||||
.then((res) => res.data)
|
||||
.finally(() => {
|
||||
refreshPromise = null;
|
||||
@ -68,7 +63,7 @@ api.interceptors.response.use(
|
||||
}
|
||||
|
||||
const data = await refreshPromise;
|
||||
onTokenRefresh(data.accessToken!, data.refreshToken!);
|
||||
onTokenRefresh(data.accessToken!);
|
||||
|
||||
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
|
||||
return api(originalRequest);
|
||||
|
||||
@ -8,13 +8,14 @@ export const mediaApi = axios.create({
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
withCredentials: true, // Send httpOnly cookies with requests
|
||||
});
|
||||
|
||||
// Request interceptor: attach Bearer token from auth store
|
||||
mediaApi.interceptors.request.use(
|
||||
(config) => {
|
||||
const callbacks = getAuthCallbacks();
|
||||
const { accessToken } = callbacks.getTokens();
|
||||
const accessToken = callbacks.getAccessToken();
|
||||
if (accessToken) {
|
||||
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
@ -41,22 +42,15 @@ mediaApi.interceptors.response.use(
|
||||
originalRequest._retry = true;
|
||||
|
||||
const callbacks = getAuthCallbacks();
|
||||
const { refreshToken } = callbacks.getTokens();
|
||||
|
||||
// No refresh token available - fail immediately
|
||||
if (!refreshToken) {
|
||||
callbacks.onAuthFailure();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
try {
|
||||
// Use shared refresh promise to prevent concurrent refreshes
|
||||
if (!mediaRefreshPromise) {
|
||||
// Import main API client dynamically to avoid circular dependency
|
||||
// Refresh token sent automatically as httpOnly cookie
|
||||
const { api } = await import('./api');
|
||||
|
||||
mediaRefreshPromise = api
|
||||
.post<AuthResponse>('/auth/refresh', { refreshToken })
|
||||
.post<AuthResponse>('/auth/refresh')
|
||||
.then((res) => res.data)
|
||||
.finally(() => {
|
||||
mediaRefreshPromise = null;
|
||||
@ -65,8 +59,8 @@ mediaApi.interceptors.response.use(
|
||||
|
||||
const data = await mediaRefreshPromise;
|
||||
|
||||
// Update tokens in auth store via callback
|
||||
callbacks.onTokenRefresh(data.accessToken!, data.refreshToken!);
|
||||
// Update access token in auth store via callback
|
||||
callbacks.onTokenRefresh(data.accessToken!);
|
||||
|
||||
// Retry original request with new token
|
||||
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
|
||||
|
||||
@ -41,7 +41,7 @@ mediaPublicApi.interceptors.request.use(
|
||||
|
||||
// Optionally attach Bearer token if user is logged in
|
||||
const callbacks = getAuthCallbacks();
|
||||
const { accessToken } = callbacks.getTokens();
|
||||
const accessToken = callbacks.getAccessToken();
|
||||
if (accessToken) {
|
||||
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
@ -13,7 +13,7 @@ const { Title, Text } = Typography;
|
||||
export default function QuickJoinPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { setTokens, fetchMe } = useAuthStore();
|
||||
const { setAccessToken, fetchMe } = useAuthStore();
|
||||
const { settings } = useSettingsStore();
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
@ -35,8 +35,8 @@ export default function QuickJoinPage() {
|
||||
phone: values.phone || undefined,
|
||||
});
|
||||
|
||||
// Store tokens and fetch user profile
|
||||
setTokens(data.accessToken, data.refreshToken);
|
||||
// Store access token and fetch user profile (refresh token set as httpOnly cookie by server)
|
||||
setAccessToken(data.accessToken);
|
||||
await fetchMe();
|
||||
|
||||
// Redirect to volunteer map with cut/shift context
|
||||
|
||||
@ -6,7 +6,6 @@ import type { User, AuthResponse } from '@/types/api';
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
accessToken: string | null;
|
||||
refreshToken: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
@ -21,7 +20,7 @@ interface AuthActions {
|
||||
refresh: () => Promise<void>;
|
||||
fetchMe: () => Promise<void>;
|
||||
hydrate: () => Promise<void>;
|
||||
setTokens: (accessToken: string, refreshToken: string) => void;
|
||||
setAccessToken: (accessToken: string) => void;
|
||||
clearAuth: () => void;
|
||||
clearError: () => void;
|
||||
}
|
||||
@ -31,7 +30,6 @@ export const useAuthStore = create<AuthState & AuthActions>()(
|
||||
(set, get) => ({
|
||||
user: null,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
@ -41,6 +39,7 @@ export const useAuthStore = create<AuthState & AuthActions>()(
|
||||
login: async (email: string, password: string) => {
|
||||
set({ error: null, errorCode: null, isLoading: true, registrationMessage: null });
|
||||
try {
|
||||
// Refresh token is set as httpOnly cookie by the server (not in response body)
|
||||
const { data } = await api.post<AuthResponse>('/auth/login', {
|
||||
email,
|
||||
password,
|
||||
@ -48,7 +47,6 @@ export const useAuthStore = create<AuthState & AuthActions>()(
|
||||
set({
|
||||
user: data.user,
|
||||
accessToken: data.accessToken || null,
|
||||
refreshToken: data.refreshToken || null,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
@ -83,7 +81,6 @@ export const useAuthStore = create<AuthState & AuthActions>()(
|
||||
set({
|
||||
user: data.user,
|
||||
accessToken: data.accessToken || null,
|
||||
refreshToken: data.refreshToken || null,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
@ -98,11 +95,9 @@ export const useAuthStore = create<AuthState & AuthActions>()(
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
const { refreshToken } = get();
|
||||
try {
|
||||
if (refreshToken) {
|
||||
await api.post('/auth/logout', { refreshToken });
|
||||
}
|
||||
// Refresh token sent automatically as httpOnly cookie
|
||||
await api.post('/auth/logout');
|
||||
} catch {
|
||||
// Ignore logout errors
|
||||
}
|
||||
@ -110,19 +105,12 @@ export const useAuthStore = create<AuthState & AuthActions>()(
|
||||
},
|
||||
|
||||
refresh: async () => {
|
||||
const { refreshToken } = get();
|
||||
if (!refreshToken) {
|
||||
get().clearAuth();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { data } = await api.post<AuthResponse>('/auth/refresh', {
|
||||
refreshToken,
|
||||
});
|
||||
// Refresh token sent automatically as httpOnly cookie
|
||||
const { data } = await api.post<AuthResponse>('/auth/refresh');
|
||||
set({
|
||||
user: data.user,
|
||||
accessToken: data.accessToken || null,
|
||||
refreshToken: data.refreshToken || null,
|
||||
isAuthenticated: true,
|
||||
});
|
||||
} catch {
|
||||
@ -157,15 +145,14 @@ export const useAuthStore = create<AuthState & AuthActions>()(
|
||||
}
|
||||
},
|
||||
|
||||
setTokens: (accessToken: string, refreshToken: string) => {
|
||||
set({ accessToken, refreshToken });
|
||||
setAccessToken: (accessToken: string) => {
|
||||
set({ accessToken });
|
||||
},
|
||||
|
||||
clearAuth: () => {
|
||||
set({
|
||||
user: null,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
@ -182,7 +169,6 @@ export const useAuthStore = create<AuthState & AuthActions>()(
|
||||
name: 'cml-auth',
|
||||
partialize: (state) => ({
|
||||
accessToken: state.accessToken,
|
||||
refreshToken: state.refreshToken,
|
||||
}),
|
||||
}
|
||||
)
|
||||
@ -190,15 +176,9 @@ export const useAuthStore = create<AuthState & AuthActions>()(
|
||||
|
||||
// Register callbacks for the API interceptor (breaks circular dependency)
|
||||
registerAuthCallbacks({
|
||||
getTokens: () => {
|
||||
const state = useAuthStore.getState();
|
||||
return {
|
||||
accessToken: state.accessToken,
|
||||
refreshToken: state.refreshToken,
|
||||
};
|
||||
},
|
||||
onTokenRefresh: (accessToken, refreshToken) => {
|
||||
useAuthStore.getState().setTokens(accessToken, refreshToken);
|
||||
getAccessToken: () => useAuthStore.getState().accessToken,
|
||||
onTokenRefresh: (accessToken) => {
|
||||
useAuthStore.getState().setAccessToken(accessToken);
|
||||
},
|
||||
onAuthFailure: () => {
|
||||
useAuthStore.getState().clearAuth();
|
||||
|
||||
28
api/package-lock.json
generated
28
api/package-lock.json
generated
@ -17,6 +17,7 @@
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bullmq": "^5.34.0",
|
||||
"compression": "^1.7.5",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"csv-parse": "^6.1.0",
|
||||
"csv-stringify": "^6.6.0",
|
||||
@ -52,6 +53,7 @@
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
@ -1894,6 +1896,15 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cookie-parser": {
|
||||
"version": "1.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
|
||||
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
|
||||
"dev": true,
|
||||
"peerDependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cors": {
|
||||
"version": "2.8.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||
@ -2533,6 +2544,23 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser": {
|
||||
"version": "1.4.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
||||
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
|
||||
"dependencies": {
|
||||
"cookie": "0.7.2",
|
||||
"cookie-signature": "1.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser/node_modules/cookie-signature": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||
|
||||
@ -25,6 +25,7 @@
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bullmq": "^5.34.0",
|
||||
"compression": "^1.7.5",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"csv-parse": "^6.1.0",
|
||||
"csv-stringify": "^6.6.0",
|
||||
@ -60,6 +61,7 @@
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
|
||||
@ -4830,6 +4830,7 @@ model TicketTier {
|
||||
minDonationCAD Int? @map("min_donation_cad") // In cents
|
||||
maxQuantity Int? @map("max_quantity")
|
||||
soldCount Int @default(0) @map("sold_count")
|
||||
reservedCount Int @default(0) @map("reserved_count") // Pending checkout sessions
|
||||
maxPerOrder Int @default(10) @map("max_per_order")
|
||||
salesStartAt DateTime? @map("sales_start_at")
|
||||
salesEndAt DateTime? @map("sales_end_at")
|
||||
|
||||
@ -19,6 +19,30 @@ import { profileService } from '../people/profile.service';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const REFRESH_COOKIE_NAME = 'cml_refresh';
|
||||
const REFRESH_COOKIE_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days in ms
|
||||
|
||||
/** Set the refresh token as an httpOnly cookie */
|
||||
function setRefreshCookie(res: Response, token: string) {
|
||||
res.cookie(REFRESH_COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
maxAge: REFRESH_COOKIE_MAX_AGE,
|
||||
path: '/api/auth',
|
||||
});
|
||||
}
|
||||
|
||||
/** Clear the refresh token cookie */
|
||||
function clearRefreshCookie(res: Response) {
|
||||
res.clearCookie(REFRESH_COOKIE_NAME, {
|
||||
httpOnly: true,
|
||||
secure: env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
path: '/api/auth',
|
||||
});
|
||||
}
|
||||
|
||||
// POST /api/auth/login
|
||||
router.post(
|
||||
'/login',
|
||||
@ -27,7 +51,10 @@ router.post(
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = await authService.login(req.body.email, req.body.password);
|
||||
res.json(result);
|
||||
// Set refresh token as httpOnly cookie (not in response body)
|
||||
setRefreshCookie(res, result.refreshToken);
|
||||
const { refreshToken: _, ...responseWithoutRefresh } = result;
|
||||
res.json(responseWithoutRefresh);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
@ -42,7 +69,14 @@ router.post(
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = await authService.register(req.body);
|
||||
res.status(201).json(result);
|
||||
// Set refresh token as httpOnly cookie if tokens were issued (non-verification path)
|
||||
if ('refreshToken' in result && result.refreshToken) {
|
||||
setRefreshCookie(res, result.refreshToken);
|
||||
const { refreshToken: _, ...responseWithoutRefresh } = result;
|
||||
res.status(201).json(responseWithoutRefresh);
|
||||
} else {
|
||||
res.status(201).json(result);
|
||||
}
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
@ -231,30 +265,44 @@ router.post(
|
||||
);
|
||||
|
||||
// POST /api/auth/refresh
|
||||
// Accepts refresh token from httpOnly cookie (preferred) or request body (legacy/backward compat)
|
||||
router.post(
|
||||
'/refresh',
|
||||
authRateLimit,
|
||||
validate(refreshSchema),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = await authService.refreshTokens(req.body.refreshToken);
|
||||
res.json(result);
|
||||
const refreshToken = req.cookies?.[REFRESH_COOKIE_NAME] || req.body?.refreshToken;
|
||||
if (!refreshToken) {
|
||||
res.status(401).json({ error: { message: 'No refresh token', code: 'INVALID_REFRESH_TOKEN' } });
|
||||
return;
|
||||
}
|
||||
const result = await authService.refreshTokens(refreshToken);
|
||||
// Set new refresh token as httpOnly cookie
|
||||
setRefreshCookie(res, result.refreshToken);
|
||||
const { refreshToken: _, ...responseWithoutRefresh } = result;
|
||||
res.json(responseWithoutRefresh);
|
||||
} catch (err) {
|
||||
clearRefreshCookie(res);
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// POST /api/auth/logout
|
||||
// Accepts refresh token from httpOnly cookie (preferred) or request body (legacy/backward compat)
|
||||
router.post(
|
||||
'/logout',
|
||||
authRateLimit,
|
||||
validate(refreshSchema),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await authService.logout(req.body.refreshToken);
|
||||
const refreshToken = req.cookies?.[REFRESH_COOKIE_NAME] || req.body?.refreshToken;
|
||||
if (refreshToken) {
|
||||
await authService.logout(refreshToken);
|
||||
}
|
||||
clearRefreshCookie(res);
|
||||
res.json({ message: 'Logged out' });
|
||||
} catch (err) {
|
||||
clearRefreshCookie(res);
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
@ -424,16 +424,23 @@ export const webhookService = {
|
||||
const { ticketsService } = await import('../ticketed-events/tickets.service');
|
||||
const { ticketEmailService } = await import('../ticketed-events/ticket-email.service');
|
||||
|
||||
const qty = parseInt(quantity, 10);
|
||||
const tickets = await ticketsService.createTickets({
|
||||
eventId,
|
||||
tierId,
|
||||
quantity: parseInt(quantity, 10),
|
||||
quantity: qty,
|
||||
holderEmail: buyerEmail || order.buyerEmail,
|
||||
holderName: buyerName || order.buyerName || undefined,
|
||||
userId: userId || order.userId || undefined,
|
||||
orderId: order.id,
|
||||
});
|
||||
|
||||
// Release reservation (soldCount was incremented by createTickets)
|
||||
await prisma.ticketTier.update({
|
||||
where: { id: tierId },
|
||||
data: { reservedCount: { decrement: qty } },
|
||||
});
|
||||
|
||||
// Fetch event + tier for email
|
||||
const event = await prisma.ticketedEvent.findUnique({ where: { id: eventId } });
|
||||
const tier = await prisma.ticketTier.findUnique({ where: { id: tierId } });
|
||||
@ -534,6 +541,21 @@ export const webhookService = {
|
||||
where: { id: order.id },
|
||||
data: { status: 'FAILED' },
|
||||
});
|
||||
|
||||
// Release ticket reservations if this was an event ticket checkout
|
||||
const { tierId, quantity } = session.metadata || {};
|
||||
if (tierId && quantity) {
|
||||
const qty = parseInt(quantity, 10);
|
||||
if (qty > 0) {
|
||||
await prisma.ticketTier.update({
|
||||
where: { id: tierId },
|
||||
data: { reservedCount: { decrement: qty } },
|
||||
}).catch((err) => {
|
||||
logger.warn(`Failed to release reservation for tier ${tierId}:`, err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Checkout expired, order marked failed: ${order.id}`);
|
||||
}
|
||||
},
|
||||
|
||||
@ -119,14 +119,6 @@ router.post('/:slug/checkout', optionalAuth, validate(checkoutSchema), async (re
|
||||
throw new AppError(400, 'Use /register for free tickets', 'USE_REGISTER');
|
||||
}
|
||||
|
||||
// Check availability
|
||||
if (tier.maxQuantity && tier.soldCount + quantity > tier.maxQuantity) {
|
||||
throw new AppError(400, 'Not enough tickets available', 'SOLD_OUT');
|
||||
}
|
||||
if (event.maxAttendees && event.currentAttendees + quantity > event.maxAttendees) {
|
||||
throw new AppError(400, 'Event is at full capacity', 'SOLD_OUT');
|
||||
}
|
||||
|
||||
// Check sales window
|
||||
const now = new Date();
|
||||
if (tier.salesStartAt && tier.salesStartAt > now) {
|
||||
@ -136,9 +128,33 @@ router.post('/:slug/checkout', optionalAuth, validate(checkoutSchema), async (re
|
||||
throw new AppError(400, 'Ticket sales have ended', 'SALES_ENDED');
|
||||
}
|
||||
|
||||
const unitAmount = tier.tierType === 'DONATION'
|
||||
? Math.max(tier.priceCAD, tier.minDonationCAD || 0)
|
||||
: tier.priceCAD;
|
||||
// Atomically reserve capacity (soldCount + reservedCount checked together)
|
||||
// This prevents overselling from concurrent Stripe checkout sessions
|
||||
const reserved = await prisma.$transaction(async (tx) => {
|
||||
const currentTier = await tx.ticketTier.findUnique({ where: { id: tierId } });
|
||||
if (!currentTier) throw new AppError(400, 'Tier not found', 'NOT_FOUND');
|
||||
|
||||
const effectiveSold = currentTier.soldCount + currentTier.reservedCount;
|
||||
if (currentTier.maxQuantity && effectiveSold + quantity > currentTier.maxQuantity) {
|
||||
throw new AppError(400, 'Not enough tickets available', 'SOLD_OUT');
|
||||
}
|
||||
|
||||
const currentEvent = await tx.ticketedEvent.findUnique({ where: { id: event.id } });
|
||||
if (currentEvent?.maxAttendees && currentEvent.currentAttendees + currentTier.reservedCount + quantity > currentEvent.maxAttendees) {
|
||||
throw new AppError(400, 'Event is at full capacity', 'SOLD_OUT');
|
||||
}
|
||||
|
||||
await tx.ticketTier.update({
|
||||
where: { id: tierId },
|
||||
data: { reservedCount: { increment: quantity } },
|
||||
});
|
||||
|
||||
return currentTier;
|
||||
});
|
||||
|
||||
const unitAmount = reserved.tierType === 'DONATION'
|
||||
? Math.max(reserved.priceCAD, reserved.minDonationCAD || 0)
|
||||
: reserved.priceCAD;
|
||||
|
||||
const stripe = await getStripe();
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
|
||||
@ -696,7 +696,7 @@ export const ticketedEventsService = {
|
||||
|
||||
const now = new Date();
|
||||
const tiers = event.ticketTiers.map(t => {
|
||||
const available = t.maxQuantity ? t.maxQuantity - t.soldCount : null;
|
||||
const available = t.maxQuantity ? t.maxQuantity - t.soldCount - (t.reservedCount || 0) : null;
|
||||
const onSale = (!t.salesStartAt || t.salesStartAt <= now) &&
|
||||
(!t.salesEndAt || t.salesEndAt >= now);
|
||||
return {
|
||||
|
||||
@ -5,6 +5,7 @@ import { validate } from '../../middleware/validate';
|
||||
import { quickJoinRateLimit } from '../../middleware/rate-limit';
|
||||
import { volunteerInviteService } from './volunteer-invite.service';
|
||||
import { generateInviteSchema, redeemInviteSchema } from './volunteer-invite.schemas';
|
||||
import { env } from '../../config/env';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@ -33,9 +34,16 @@ router.post(
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = await volunteerInviteService.redeemInvite(req.body);
|
||||
// Set refresh token as httpOnly cookie
|
||||
res.cookie('cml_refresh', result.tokens.refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||
path: '/api/auth',
|
||||
});
|
||||
res.json({
|
||||
accessToken: result.tokens.accessToken,
|
||||
refreshToken: result.tokens.refreshToken,
|
||||
cutId: result.cutId,
|
||||
shiftId: result.shiftId,
|
||||
});
|
||||
|
||||
@ -125,6 +125,7 @@ import { actionItemsRouter } from './modules/meetings/action-items.routes';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { docsCollabService } from './modules/docs/docs-collab.service';
|
||||
import { correlationId } from './middleware/correlation-id';
|
||||
import cookieParser from 'cookie-parser';
|
||||
|
||||
const app = express();
|
||||
|
||||
@ -133,6 +134,7 @@ app.set('trust proxy', 1);
|
||||
|
||||
// --- Middleware Stack ---
|
||||
app.use(correlationId);
|
||||
app.use(cookieParser());
|
||||
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: env.CSP_ENABLED === 'true'
|
||||
|
||||
@ -510,7 +510,7 @@ services:
|
||||
image: {{registryUrl}}/mongo:6.0
|
||||
container_name: {{containerPrefix}}-mongodb
|
||||
restart: unless-stopped
|
||||
command: ["mongod", "--replSet", "rs0", "--bind_ip_all", "--auth", "--keyFile", "/data/replica.key"]
|
||||
entrypoint: ["/bin/bash", "-c", "if [ ! -f /data/replica.key ]; then openssl rand -base64 756 > /data/replica.key; fi && chmod 400 /data/replica.key && chown 999:999 /data/replica.key && exec mongod --replSet rs0 --bind_ip_all --auth --keyFile /data/replica.key"]
|
||||
environment:
|
||||
MONGO_INITDB_ROOT_USERNAME: "${MONGO_ROOT_USER:-rocketchat}"
|
||||
MONGO_INITDB_ROOT_PASSWORD: "${MONGO_ROOT_PASSWORD}"
|
||||
|
||||
@ -891,7 +891,7 @@ services:
|
||||
image: ${GITEA_REGISTRY:-gitea.bnkops.com/admin}/mongo:6.0
|
||||
container_name: mongodb-rocketchat
|
||||
restart: unless-stopped
|
||||
command: ["mongod", "--replSet", "rs0", "--bind_ip_all", "--auth", "--keyFile", "/data/replica.key"]
|
||||
entrypoint: ["/bin/bash", "-c", "if [ ! -f /data/replica.key ]; then openssl rand -base64 756 > /data/replica.key; fi && chmod 400 /data/replica.key && chown 999:999 /data/replica.key && exec mongod --replSet rs0 --bind_ip_all --auth --keyFile /data/replica.key"]
|
||||
environment:
|
||||
MONGO_INITDB_ROOT_USERNAME: ${MONGO_ROOT_USER:-rocketchat}
|
||||
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD:?MONGO_ROOT_PASSWORD must be set in .env}
|
||||
|
||||
@ -908,7 +908,7 @@ services:
|
||||
image: mongo:6.0
|
||||
container_name: mongodb-rocketchat
|
||||
restart: unless-stopped
|
||||
command: ["mongod", "--replSet", "rs0", "--bind_ip_all", "--auth", "--keyFile", "/data/replica.key"]
|
||||
entrypoint: ["/bin/bash", "-c", "if [ ! -f /data/replica.key ]; then openssl rand -base64 756 > /data/replica.key; fi && chmod 400 /data/replica.key && chown 999:999 /data/replica.key && exec mongod --replSet rs0 --bind_ip_all --auth --keyFile /data/replica.key"]
|
||||
environment:
|
||||
MONGO_INITDB_ROOT_USERNAME: ${MONGO_ROOT_USER:-rocketchat}
|
||||
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD:?MONGO_ROOT_PASSWORD must be set in .env}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user