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 type { ReactNode } from 'react';
|
||||||
import { Result, Button } from 'antd';
|
import { Result, Button, Skeleton } from 'antd';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { SettingOutlined } from '@ant-design/icons';
|
import { SettingOutlined } from '@ant-design/icons';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
@ -36,8 +36,8 @@ export default function FeatureGate({ feature, children }: FeatureGateProps) {
|
|||||||
const isSuperAdmin = hasAnyRole(user, ['SUPER_ADMIN']);
|
const isSuperAdmin = hasAnyRole(user, ['SUPER_ADMIN']);
|
||||||
const featureName = FEATURE_LABELS[feature] || feature;
|
const featureName = FEATURE_LABELS[feature] || feature;
|
||||||
|
|
||||||
// While loading or if settings haven't arrived yet, render children (optimistic)
|
// Show skeleton while settings are loading to prevent briefly showing disabled features
|
||||||
if (loading || !settings) return <>{children}</>;
|
if (loading || !settings) return <Skeleton active style={{ padding: 24 }} />;
|
||||||
|
|
||||||
if (settings[feature] === false) {
|
if (settings[feature] === false) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -5,8 +5,8 @@ import type { PhotoAlbum } from '@/types/media';
|
|||||||
|
|
||||||
/** Append JWT access token as query param for <img> src URLs */
|
/** Append JWT access token as query param for <img> src URLs */
|
||||||
function getAuthenticatedUrl(url: string): string {
|
function getAuthenticatedUrl(url: string): string {
|
||||||
const { getTokens } = getAuthCallbacks();
|
const { getAccessToken } = getAuthCallbacks();
|
||||||
const { accessToken } = getTokens();
|
const accessToken = getAccessToken();
|
||||||
if (!accessToken) return url;
|
if (!accessToken) return url;
|
||||||
const separator = url.includes('?') ? '&' : '?';
|
const separator = url.includes('?') ? '&' : '?';
|
||||||
return `${url}${separator}token=${accessToken}`;
|
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 */
|
/** Append JWT access token as query param for <img> src URLs */
|
||||||
function getAuthenticatedUrl(url: string): string {
|
function getAuthenticatedUrl(url: string): string {
|
||||||
const { getTokens } = getAuthCallbacks();
|
const { getAccessToken } = getAuthCallbacks();
|
||||||
const { accessToken } = getTokens();
|
const accessToken = getAccessToken();
|
||||||
if (!accessToken) return url;
|
if (!accessToken) return url;
|
||||||
const separator = url.includes('?') ? '&' : '?';
|
const separator = url.includes('?') ? '&' : '?';
|
||||||
return `${url}${separator}token=${accessToken}`;
|
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 */
|
/** Append JWT access token as query param for <img> src URLs */
|
||||||
function getAuthenticatedUrl(url: string): string {
|
function getAuthenticatedUrl(url: string): string {
|
||||||
const { getTokens } = getAuthCallbacks();
|
const { getAccessToken } = getAuthCallbacks();
|
||||||
const { accessToken } = getTokens();
|
const accessToken = getAccessToken();
|
||||||
if (!accessToken) return url;
|
if (!accessToken) return url;
|
||||||
const separator = url.includes('?') ? '&' : '?';
|
const separator = url.includes('?') ? '&' : '?';
|
||||||
return `${url}${separator}token=${accessToken}`;
|
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 */
|
/** Append JWT access token as query param for <img> src URLs */
|
||||||
function getAuthenticatedUrl(url: string): string {
|
function getAuthenticatedUrl(url: string): string {
|
||||||
const { getTokens } = getAuthCallbacks();
|
const { getAccessToken } = getAuthCallbacks();
|
||||||
const { accessToken } = getTokens();
|
const accessToken = getAccessToken();
|
||||||
if (!accessToken) return url;
|
if (!accessToken) return url;
|
||||||
const separator = url.includes('?') ? '&' : '?';
|
const separator = url.includes('?') ? '&' : '?';
|
||||||
return `${url}${separator}token=${accessToken}`;
|
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 */
|
/** Append JWT access token as query param for <img>/<video> src URLs */
|
||||||
function getAuthenticatedUrl(url: string): string {
|
function getAuthenticatedUrl(url: string): string {
|
||||||
const { getTokens } = getAuthCallbacks();
|
const { getAccessToken } = getAuthCallbacks();
|
||||||
const { accessToken } = getTokens();
|
const accessToken = getAccessToken();
|
||||||
if (!accessToken) return url;
|
if (!accessToken) return url;
|
||||||
const separator = url.includes('?') ? '&' : '?';
|
const separator = url.includes('?') ? '&' : '?';
|
||||||
return `${url}${separator}token=${accessToken}`;
|
return `${url}${separator}token=${accessToken}`;
|
||||||
|
|||||||
@ -124,8 +124,8 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
|
|||||||
|
|
||||||
const appendToken = (url: string): string => {
|
const appendToken = (url: string): string => {
|
||||||
if (!isAdmin) return url;
|
if (!isAdmin) return url;
|
||||||
const { getTokens } = getAuthCallbacks();
|
const { getAccessToken } = getAuthCallbacks();
|
||||||
const { accessToken } = getTokens();
|
const accessToken = getAccessToken();
|
||||||
if (!accessToken) return url;
|
if (!accessToken) return url;
|
||||||
const sep = url.includes('?') ? '&' : '?';
|
const sep = url.includes('?') ? '&' : '?';
|
||||||
return `${url}${sep}token=${accessToken}`;
|
return `${url}${sep}token=${accessToken}`;
|
||||||
@ -139,8 +139,8 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
|
|||||||
// Use relative URL to go through nginx proxy
|
// Use relative URL to go through nginx proxy
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
const { getTokens } = getAuthCallbacks();
|
const { getAccessToken } = getAuthCallbacks();
|
||||||
const { accessToken } = getTokens();
|
const accessToken = getAccessToken();
|
||||||
if (accessToken) {
|
if (accessToken) {
|
||||||
headers['Authorization'] = `Bearer ${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 */
|
/** Append JWT access token as query param for <video> src URLs */
|
||||||
function getAuthenticatedUrl(url: string): string {
|
function getAuthenticatedUrl(url: string): string {
|
||||||
const { getTokens } = getAuthCallbacks();
|
const { getAccessToken } = getAuthCallbacks();
|
||||||
const { accessToken } = getTokens();
|
const accessToken = getAccessToken();
|
||||||
if (!accessToken) return url;
|
if (!accessToken) return url;
|
||||||
const separator = url.includes('?') ? '&' : '?';
|
const separator = url.includes('?') ? '&' : '?';
|
||||||
return `${url}${separator}token=${accessToken}`;
|
return `${url}${separator}token=${accessToken}`;
|
||||||
|
|||||||
@ -3,32 +3,32 @@ import type { AuthResponse } from '@/types/api';
|
|||||||
|
|
||||||
export const api = axios.create({
|
export const api = axios.create({
|
||||||
baseURL: '/api',
|
baseURL: '/api',
|
||||||
|
withCredentials: true, // Send httpOnly cookies with all requests
|
||||||
});
|
});
|
||||||
|
|
||||||
// Token accessor — set by auth store on init to break circular dependency
|
// Token accessor — set by auth store on init to break circular dependency
|
||||||
let getTokens: () => { accessToken: string | null; refreshToken: string | null } =
|
let getAccessToken: () => string | null = () => null;
|
||||||
() => ({ accessToken: null, refreshToken: null });
|
let onTokenRefresh: (accessToken: string) => void = () => {};
|
||||||
let onTokenRefresh: (accessToken: string, refreshToken: string) => void = () => {};
|
|
||||||
let onAuthFailure: () => void = () => {};
|
let onAuthFailure: () => void = () => {};
|
||||||
|
|
||||||
export function registerAuthCallbacks(callbacks: {
|
export function registerAuthCallbacks(callbacks: {
|
||||||
getTokens: typeof getTokens;
|
getAccessToken: typeof getAccessToken;
|
||||||
onTokenRefresh: typeof onTokenRefresh;
|
onTokenRefresh: typeof onTokenRefresh;
|
||||||
onAuthFailure: typeof onAuthFailure;
|
onAuthFailure: typeof onAuthFailure;
|
||||||
}) {
|
}) {
|
||||||
getTokens = callbacks.getTokens;
|
getAccessToken = callbacks.getAccessToken;
|
||||||
onTokenRefresh = callbacks.onTokenRefresh;
|
onTokenRefresh = callbacks.onTokenRefresh;
|
||||||
onAuthFailure = callbacks.onAuthFailure;
|
onAuthFailure = callbacks.onAuthFailure;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to get current callbacks (for use in other API clients)
|
// Helper to get current callbacks (for use in other API clients)
|
||||||
export function getAuthCallbacks() {
|
export function getAuthCallbacks() {
|
||||||
return { getTokens, onTokenRefresh, onAuthFailure };
|
return { getAccessToken, onTokenRefresh, onAuthFailure };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request interceptor: attach access token
|
// Request interceptor: attach access token
|
||||||
api.interceptors.request.use((config) => {
|
api.interceptors.request.use((config) => {
|
||||||
const { accessToken } = getTokens();
|
const accessToken = getAccessToken();
|
||||||
if (accessToken) {
|
if (accessToken) {
|
||||||
config.headers.Authorization = `Bearer ${accessToken}`;
|
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||||
}
|
}
|
||||||
@ -51,16 +51,11 @@ api.interceptors.response.use(
|
|||||||
) {
|
) {
|
||||||
originalRequest._retry = true;
|
originalRequest._retry = true;
|
||||||
|
|
||||||
const { refreshToken } = getTokens();
|
|
||||||
if (!refreshToken) {
|
|
||||||
onAuthFailure();
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!refreshPromise) {
|
if (!refreshPromise) {
|
||||||
|
// Refresh token sent automatically as httpOnly cookie
|
||||||
refreshPromise = api
|
refreshPromise = api
|
||||||
.post<AuthResponse>('/auth/refresh', { refreshToken })
|
.post<AuthResponse>('/auth/refresh')
|
||||||
.then((res) => res.data)
|
.then((res) => res.data)
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
refreshPromise = null;
|
refreshPromise = null;
|
||||||
@ -68,7 +63,7 @@ api.interceptors.response.use(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await refreshPromise;
|
const data = await refreshPromise;
|
||||||
onTokenRefresh(data.accessToken!, data.refreshToken!);
|
onTokenRefresh(data.accessToken!);
|
||||||
|
|
||||||
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
|
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
|
||||||
return api(originalRequest);
|
return api(originalRequest);
|
||||||
|
|||||||
@ -8,13 +8,14 @@ export const mediaApi = axios.create({
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
|
withCredentials: true, // Send httpOnly cookies with requests
|
||||||
});
|
});
|
||||||
|
|
||||||
// Request interceptor: attach Bearer token from auth store
|
// Request interceptor: attach Bearer token from auth store
|
||||||
mediaApi.interceptors.request.use(
|
mediaApi.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
const callbacks = getAuthCallbacks();
|
const callbacks = getAuthCallbacks();
|
||||||
const { accessToken } = callbacks.getTokens();
|
const accessToken = callbacks.getAccessToken();
|
||||||
if (accessToken) {
|
if (accessToken) {
|
||||||
config.headers.Authorization = `Bearer ${accessToken}`;
|
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||||
}
|
}
|
||||||
@ -41,22 +42,15 @@ mediaApi.interceptors.response.use(
|
|||||||
originalRequest._retry = true;
|
originalRequest._retry = true;
|
||||||
|
|
||||||
const callbacks = getAuthCallbacks();
|
const callbacks = getAuthCallbacks();
|
||||||
const { refreshToken } = callbacks.getTokens();
|
|
||||||
|
|
||||||
// No refresh token available - fail immediately
|
|
||||||
if (!refreshToken) {
|
|
||||||
callbacks.onAuthFailure();
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use shared refresh promise to prevent concurrent refreshes
|
// Use shared refresh promise to prevent concurrent refreshes
|
||||||
if (!mediaRefreshPromise) {
|
if (!mediaRefreshPromise) {
|
||||||
// Import main API client dynamically to avoid circular dependency
|
// Refresh token sent automatically as httpOnly cookie
|
||||||
const { api } = await import('./api');
|
const { api } = await import('./api');
|
||||||
|
|
||||||
mediaRefreshPromise = api
|
mediaRefreshPromise = api
|
||||||
.post<AuthResponse>('/auth/refresh', { refreshToken })
|
.post<AuthResponse>('/auth/refresh')
|
||||||
.then((res) => res.data)
|
.then((res) => res.data)
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
mediaRefreshPromise = null;
|
mediaRefreshPromise = null;
|
||||||
@ -65,8 +59,8 @@ mediaApi.interceptors.response.use(
|
|||||||
|
|
||||||
const data = await mediaRefreshPromise;
|
const data = await mediaRefreshPromise;
|
||||||
|
|
||||||
// Update tokens in auth store via callback
|
// Update access token in auth store via callback
|
||||||
callbacks.onTokenRefresh(data.accessToken!, data.refreshToken!);
|
callbacks.onTokenRefresh(data.accessToken!);
|
||||||
|
|
||||||
// Retry original request with new token
|
// Retry original request with new token
|
||||||
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
|
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
|
||||||
|
|||||||
@ -41,7 +41,7 @@ mediaPublicApi.interceptors.request.use(
|
|||||||
|
|
||||||
// Optionally attach Bearer token if user is logged in
|
// Optionally attach Bearer token if user is logged in
|
||||||
const callbacks = getAuthCallbacks();
|
const callbacks = getAuthCallbacks();
|
||||||
const { accessToken } = callbacks.getTokens();
|
const accessToken = callbacks.getAccessToken();
|
||||||
if (accessToken) {
|
if (accessToken) {
|
||||||
config.headers.Authorization = `Bearer ${accessToken}`;
|
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,7 +13,7 @@ const { Title, Text } = Typography;
|
|||||||
export default function QuickJoinPage() {
|
export default function QuickJoinPage() {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { setTokens, fetchMe } = useAuthStore();
|
const { setAccessToken, fetchMe } = useAuthStore();
|
||||||
const { settings } = useSettingsStore();
|
const { settings } = useSettingsStore();
|
||||||
const screens = Grid.useBreakpoint();
|
const screens = Grid.useBreakpoint();
|
||||||
const isMobile = !screens.md;
|
const isMobile = !screens.md;
|
||||||
@ -35,8 +35,8 @@ export default function QuickJoinPage() {
|
|||||||
phone: values.phone || undefined,
|
phone: values.phone || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Store tokens and fetch user profile
|
// Store access token and fetch user profile (refresh token set as httpOnly cookie by server)
|
||||||
setTokens(data.accessToken, data.refreshToken);
|
setAccessToken(data.accessToken);
|
||||||
await fetchMe();
|
await fetchMe();
|
||||||
|
|
||||||
// Redirect to volunteer map with cut/shift context
|
// Redirect to volunteer map with cut/shift context
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import type { User, AuthResponse } from '@/types/api';
|
|||||||
interface AuthState {
|
interface AuthState {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
accessToken: string | null;
|
accessToken: string | null;
|
||||||
refreshToken: string | null;
|
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
@ -21,7 +20,7 @@ interface AuthActions {
|
|||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
fetchMe: () => Promise<void>;
|
fetchMe: () => Promise<void>;
|
||||||
hydrate: () => Promise<void>;
|
hydrate: () => Promise<void>;
|
||||||
setTokens: (accessToken: string, refreshToken: string) => void;
|
setAccessToken: (accessToken: string) => void;
|
||||||
clearAuth: () => void;
|
clearAuth: () => void;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
}
|
}
|
||||||
@ -31,7 +30,6 @@ export const useAuthStore = create<AuthState & AuthActions>()(
|
|||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
user: null,
|
user: null,
|
||||||
accessToken: null,
|
accessToken: null,
|
||||||
refreshToken: null,
|
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
error: null,
|
error: null,
|
||||||
@ -41,6 +39,7 @@ export const useAuthStore = create<AuthState & AuthActions>()(
|
|||||||
login: async (email: string, password: string) => {
|
login: async (email: string, password: string) => {
|
||||||
set({ error: null, errorCode: null, isLoading: true, registrationMessage: null });
|
set({ error: null, errorCode: null, isLoading: true, registrationMessage: null });
|
||||||
try {
|
try {
|
||||||
|
// Refresh token is set as httpOnly cookie by the server (not in response body)
|
||||||
const { data } = await api.post<AuthResponse>('/auth/login', {
|
const { data } = await api.post<AuthResponse>('/auth/login', {
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
@ -48,7 +47,6 @@ export const useAuthStore = create<AuthState & AuthActions>()(
|
|||||||
set({
|
set({
|
||||||
user: data.user,
|
user: data.user,
|
||||||
accessToken: data.accessToken || null,
|
accessToken: data.accessToken || null,
|
||||||
refreshToken: data.refreshToken || null,
|
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
});
|
});
|
||||||
@ -83,7 +81,6 @@ export const useAuthStore = create<AuthState & AuthActions>()(
|
|||||||
set({
|
set({
|
||||||
user: data.user,
|
user: data.user,
|
||||||
accessToken: data.accessToken || null,
|
accessToken: data.accessToken || null,
|
||||||
refreshToken: data.refreshToken || null,
|
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
});
|
});
|
||||||
@ -98,11 +95,9 @@ export const useAuthStore = create<AuthState & AuthActions>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
logout: async () => {
|
logout: async () => {
|
||||||
const { refreshToken } = get();
|
|
||||||
try {
|
try {
|
||||||
if (refreshToken) {
|
// Refresh token sent automatically as httpOnly cookie
|
||||||
await api.post('/auth/logout', { refreshToken });
|
await api.post('/auth/logout');
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore logout errors
|
// Ignore logout errors
|
||||||
}
|
}
|
||||||
@ -110,19 +105,12 @@ export const useAuthStore = create<AuthState & AuthActions>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
refresh: async () => {
|
refresh: async () => {
|
||||||
const { refreshToken } = get();
|
|
||||||
if (!refreshToken) {
|
|
||||||
get().clearAuth();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const { data } = await api.post<AuthResponse>('/auth/refresh', {
|
// Refresh token sent automatically as httpOnly cookie
|
||||||
refreshToken,
|
const { data } = await api.post<AuthResponse>('/auth/refresh');
|
||||||
});
|
|
||||||
set({
|
set({
|
||||||
user: data.user,
|
user: data.user,
|
||||||
accessToken: data.accessToken || null,
|
accessToken: data.accessToken || null,
|
||||||
refreshToken: data.refreshToken || null,
|
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
@ -157,15 +145,14 @@ export const useAuthStore = create<AuthState & AuthActions>()(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
setTokens: (accessToken: string, refreshToken: string) => {
|
setAccessToken: (accessToken: string) => {
|
||||||
set({ accessToken, refreshToken });
|
set({ accessToken });
|
||||||
},
|
},
|
||||||
|
|
||||||
clearAuth: () => {
|
clearAuth: () => {
|
||||||
set({
|
set({
|
||||||
user: null,
|
user: null,
|
||||||
accessToken: null,
|
accessToken: null,
|
||||||
refreshToken: null,
|
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
@ -182,7 +169,6 @@ export const useAuthStore = create<AuthState & AuthActions>()(
|
|||||||
name: 'cml-auth',
|
name: 'cml-auth',
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
accessToken: state.accessToken,
|
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)
|
// Register callbacks for the API interceptor (breaks circular dependency)
|
||||||
registerAuthCallbacks({
|
registerAuthCallbacks({
|
||||||
getTokens: () => {
|
getAccessToken: () => useAuthStore.getState().accessToken,
|
||||||
const state = useAuthStore.getState();
|
onTokenRefresh: (accessToken) => {
|
||||||
return {
|
useAuthStore.getState().setAccessToken(accessToken);
|
||||||
accessToken: state.accessToken,
|
|
||||||
refreshToken: state.refreshToken,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
onTokenRefresh: (accessToken, refreshToken) => {
|
|
||||||
useAuthStore.getState().setTokens(accessToken, refreshToken);
|
|
||||||
},
|
},
|
||||||
onAuthFailure: () => {
|
onAuthFailure: () => {
|
||||||
useAuthStore.getState().clearAuth();
|
useAuthStore.getState().clearAuth();
|
||||||
|
|||||||
28
api/package-lock.json
generated
28
api/package-lock.json
generated
@ -17,6 +17,7 @@
|
|||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"bullmq": "^5.34.0",
|
"bullmq": "^5.34.0",
|
||||||
"compression": "^1.7.5",
|
"compression": "^1.7.5",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"csv-parse": "^6.1.0",
|
"csv-parse": "^6.1.0",
|
||||||
"csv-stringify": "^6.6.0",
|
"csv-stringify": "^6.6.0",
|
||||||
@ -52,6 +53,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/compression": "^1.7.5",
|
"@types/compression": "^1.7.5",
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jsonwebtoken": "^9.0.7",
|
"@types/jsonwebtoken": "^9.0.7",
|
||||||
@ -1894,6 +1896,15 @@
|
|||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/cors": {
|
||||||
"version": "2.8.19",
|
"version": "2.8.19",
|
||||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||||
@ -2533,6 +2544,23 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/cookie-signature": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||||
|
|||||||
@ -25,6 +25,7 @@
|
|||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"bullmq": "^5.34.0",
|
"bullmq": "^5.34.0",
|
||||||
"compression": "^1.7.5",
|
"compression": "^1.7.5",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"csv-parse": "^6.1.0",
|
"csv-parse": "^6.1.0",
|
||||||
"csv-stringify": "^6.6.0",
|
"csv-stringify": "^6.6.0",
|
||||||
@ -60,6 +61,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/compression": "^1.7.5",
|
"@types/compression": "^1.7.5",
|
||||||
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jsonwebtoken": "^9.0.7",
|
"@types/jsonwebtoken": "^9.0.7",
|
||||||
|
|||||||
@ -4830,6 +4830,7 @@ model TicketTier {
|
|||||||
minDonationCAD Int? @map("min_donation_cad") // In cents
|
minDonationCAD Int? @map("min_donation_cad") // In cents
|
||||||
maxQuantity Int? @map("max_quantity")
|
maxQuantity Int? @map("max_quantity")
|
||||||
soldCount Int @default(0) @map("sold_count")
|
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")
|
maxPerOrder Int @default(10) @map("max_per_order")
|
||||||
salesStartAt DateTime? @map("sales_start_at")
|
salesStartAt DateTime? @map("sales_start_at")
|
||||||
salesEndAt DateTime? @map("sales_end_at")
|
salesEndAt DateTime? @map("sales_end_at")
|
||||||
|
|||||||
@ -19,6 +19,30 @@ import { profileService } from '../people/profile.service';
|
|||||||
|
|
||||||
const router = Router();
|
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
|
// POST /api/auth/login
|
||||||
router.post(
|
router.post(
|
||||||
'/login',
|
'/login',
|
||||||
@ -27,7 +51,10 @@ router.post(
|
|||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const result = await authService.login(req.body.email, req.body.password);
|
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) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
@ -42,7 +69,14 @@ router.post(
|
|||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const result = await authService.register(req.body);
|
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) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
@ -231,30 +265,44 @@ router.post(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// POST /api/auth/refresh
|
// POST /api/auth/refresh
|
||||||
|
// Accepts refresh token from httpOnly cookie (preferred) or request body (legacy/backward compat)
|
||||||
router.post(
|
router.post(
|
||||||
'/refresh',
|
'/refresh',
|
||||||
authRateLimit,
|
authRateLimit,
|
||||||
validate(refreshSchema),
|
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const result = await authService.refreshTokens(req.body.refreshToken);
|
const refreshToken = req.cookies?.[REFRESH_COOKIE_NAME] || req.body?.refreshToken;
|
||||||
res.json(result);
|
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) {
|
} catch (err) {
|
||||||
|
clearRefreshCookie(res);
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// POST /api/auth/logout
|
// POST /api/auth/logout
|
||||||
|
// Accepts refresh token from httpOnly cookie (preferred) or request body (legacy/backward compat)
|
||||||
router.post(
|
router.post(
|
||||||
'/logout',
|
'/logout',
|
||||||
authRateLimit,
|
authRateLimit,
|
||||||
validate(refreshSchema),
|
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
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' });
|
res.json({ message: 'Logged out' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
clearRefreshCookie(res);
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -424,16 +424,23 @@ export const webhookService = {
|
|||||||
const { ticketsService } = await import('../ticketed-events/tickets.service');
|
const { ticketsService } = await import('../ticketed-events/tickets.service');
|
||||||
const { ticketEmailService } = await import('../ticketed-events/ticket-email.service');
|
const { ticketEmailService } = await import('../ticketed-events/ticket-email.service');
|
||||||
|
|
||||||
|
const qty = parseInt(quantity, 10);
|
||||||
const tickets = await ticketsService.createTickets({
|
const tickets = await ticketsService.createTickets({
|
||||||
eventId,
|
eventId,
|
||||||
tierId,
|
tierId,
|
||||||
quantity: parseInt(quantity, 10),
|
quantity: qty,
|
||||||
holderEmail: buyerEmail || order.buyerEmail,
|
holderEmail: buyerEmail || order.buyerEmail,
|
||||||
holderName: buyerName || order.buyerName || undefined,
|
holderName: buyerName || order.buyerName || undefined,
|
||||||
userId: userId || order.userId || undefined,
|
userId: userId || order.userId || undefined,
|
||||||
orderId: order.id,
|
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
|
// Fetch event + tier for email
|
||||||
const event = await prisma.ticketedEvent.findUnique({ where: { id: eventId } });
|
const event = await prisma.ticketedEvent.findUnique({ where: { id: eventId } });
|
||||||
const tier = await prisma.ticketTier.findUnique({ where: { id: tierId } });
|
const tier = await prisma.ticketTier.findUnique({ where: { id: tierId } });
|
||||||
@ -534,6 +541,21 @@ export const webhookService = {
|
|||||||
where: { id: order.id },
|
where: { id: order.id },
|
||||||
data: { status: 'FAILED' },
|
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}`);
|
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');
|
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
|
// Check sales window
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
if (tier.salesStartAt && tier.salesStartAt > now) {
|
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');
|
throw new AppError(400, 'Ticket sales have ended', 'SALES_ENDED');
|
||||||
}
|
}
|
||||||
|
|
||||||
const unitAmount = tier.tierType === 'DONATION'
|
// Atomically reserve capacity (soldCount + reservedCount checked together)
|
||||||
? Math.max(tier.priceCAD, tier.minDonationCAD || 0)
|
// This prevents overselling from concurrent Stripe checkout sessions
|
||||||
: tier.priceCAD;
|
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 stripe = await getStripe();
|
||||||
const session = await stripe.checkout.sessions.create({
|
const session = await stripe.checkout.sessions.create({
|
||||||
|
|||||||
@ -696,7 +696,7 @@ export const ticketedEventsService = {
|
|||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const tiers = event.ticketTiers.map(t => {
|
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) &&
|
const onSale = (!t.salesStartAt || t.salesStartAt <= now) &&
|
||||||
(!t.salesEndAt || t.salesEndAt >= now);
|
(!t.salesEndAt || t.salesEndAt >= now);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { validate } from '../../middleware/validate';
|
|||||||
import { quickJoinRateLimit } from '../../middleware/rate-limit';
|
import { quickJoinRateLimit } from '../../middleware/rate-limit';
|
||||||
import { volunteerInviteService } from './volunteer-invite.service';
|
import { volunteerInviteService } from './volunteer-invite.service';
|
||||||
import { generateInviteSchema, redeemInviteSchema } from './volunteer-invite.schemas';
|
import { generateInviteSchema, redeemInviteSchema } from './volunteer-invite.schemas';
|
||||||
|
import { env } from '../../config/env';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@ -33,9 +34,16 @@ router.post(
|
|||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const result = await volunteerInviteService.redeemInvite(req.body);
|
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({
|
res.json({
|
||||||
accessToken: result.tokens.accessToken,
|
accessToken: result.tokens.accessToken,
|
||||||
refreshToken: result.tokens.refreshToken,
|
|
||||||
cutId: result.cutId,
|
cutId: result.cutId,
|
||||||
shiftId: result.shiftId,
|
shiftId: result.shiftId,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -125,6 +125,7 @@ import { actionItemsRouter } from './modules/meetings/action-items.routes';
|
|||||||
import { WebSocketServer } from 'ws';
|
import { WebSocketServer } from 'ws';
|
||||||
import { docsCollabService } from './modules/docs/docs-collab.service';
|
import { docsCollabService } from './modules/docs/docs-collab.service';
|
||||||
import { correlationId } from './middleware/correlation-id';
|
import { correlationId } from './middleware/correlation-id';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@ -133,6 +134,7 @@ app.set('trust proxy', 1);
|
|||||||
|
|
||||||
// --- Middleware Stack ---
|
// --- Middleware Stack ---
|
||||||
app.use(correlationId);
|
app.use(correlationId);
|
||||||
|
app.use(cookieParser());
|
||||||
|
|
||||||
app.use(helmet({
|
app.use(helmet({
|
||||||
contentSecurityPolicy: env.CSP_ENABLED === 'true'
|
contentSecurityPolicy: env.CSP_ENABLED === 'true'
|
||||||
|
|||||||
@ -510,7 +510,7 @@ services:
|
|||||||
image: {{registryUrl}}/mongo:6.0
|
image: {{registryUrl}}/mongo:6.0
|
||||||
container_name: {{containerPrefix}}-mongodb
|
container_name: {{containerPrefix}}-mongodb
|
||||||
restart: unless-stopped
|
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:
|
environment:
|
||||||
MONGO_INITDB_ROOT_USERNAME: "${MONGO_ROOT_USER:-rocketchat}"
|
MONGO_INITDB_ROOT_USERNAME: "${MONGO_ROOT_USER:-rocketchat}"
|
||||||
MONGO_INITDB_ROOT_PASSWORD: "${MONGO_ROOT_PASSWORD}"
|
MONGO_INITDB_ROOT_PASSWORD: "${MONGO_ROOT_PASSWORD}"
|
||||||
|
|||||||
@ -891,7 +891,7 @@ services:
|
|||||||
image: ${GITEA_REGISTRY:-gitea.bnkops.com/admin}/mongo:6.0
|
image: ${GITEA_REGISTRY:-gitea.bnkops.com/admin}/mongo:6.0
|
||||||
container_name: mongodb-rocketchat
|
container_name: mongodb-rocketchat
|
||||||
restart: unless-stopped
|
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:
|
environment:
|
||||||
MONGO_INITDB_ROOT_USERNAME: ${MONGO_ROOT_USER:-rocketchat}
|
MONGO_INITDB_ROOT_USERNAME: ${MONGO_ROOT_USER:-rocketchat}
|
||||||
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD:?MONGO_ROOT_PASSWORD must be set in .env}
|
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD:?MONGO_ROOT_PASSWORD must be set in .env}
|
||||||
|
|||||||
@ -908,7 +908,7 @@ services:
|
|||||||
image: mongo:6.0
|
image: mongo:6.0
|
||||||
container_name: mongodb-rocketchat
|
container_name: mongodb-rocketchat
|
||||||
restart: unless-stopped
|
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:
|
environment:
|
||||||
MONGO_INITDB_ROOT_USERNAME: ${MONGO_ROOT_USER:-rocketchat}
|
MONGO_INITDB_ROOT_USERNAME: ${MONGO_ROOT_USER:-rocketchat}
|
||||||
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD:?MONGO_ROOT_PASSWORD must be set in .env}
|
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD:?MONGO_ROOT_PASSWORD must be set in .env}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user