Harden auth cookies and fix native schema for event creation
Mirror to GitHub / mirror (push) Successful in 45s
Test and Lint / backend-test (push) Successful in 1m37s
Test and Lint / frontend-test (push) Successful in 2m8s
Version and Release / version-bump (push) Successful in 1m0s
Version and Release / trigger-drone (push) Successful in 3s

This commit is contained in:
2025-09-18 15:58:58 +02:00
parent bda76ff513
commit 71e7179145
35 changed files with 1055 additions and 381 deletions
+35 -7
View File
@@ -1,9 +1,9 @@
import React, { useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { MaintenanceMode } from './MaintenanceMode';
import { useMaintenanceMode } from '../contexts/MaintenanceContext';
import { setMaintenanceModeCallback, api, getAuthToken } from '../config/api';
import { setMaintenanceModeCallback, api } from '../config/api';
interface MaintenanceWrapperProps {
children: React.ReactNode;
@@ -12,10 +12,38 @@ interface MaintenanceWrapperProps {
export const MaintenanceWrapper: React.FC<MaintenanceWrapperProps> = ({ children }) => {
const location = useLocation();
const { isMaintenanceMode, setMaintenanceMode } = useMaintenanceMode();
const [hasAdminSession, setHasAdminSession] = useState(false);
// Check if current route is admin route
const isAdminRoute = location.pathname.startsWith('/admin');
const hasAdminAuth = !!getAuthToken(true);
useEffect(() => {
let isMounted = true;
const checkAdminSession = async () => {
if (!isAdminRoute) {
setHasAdminSession(false);
return;
}
try {
const response = await api.get<{ valid: boolean; type: string }>('/auth/session');
if (isMounted) {
setHasAdminSession(Boolean(response.data?.valid && response.data.type === 'admin'));
}
} catch (error) {
if (isMounted) {
setHasAdminSession(false);
}
}
};
checkAdminSession();
return () => {
isMounted = false;
};
}, [isAdminRoute]);
// Register the maintenance mode callback
useEffect(() => {
@@ -37,7 +65,7 @@ export const MaintenanceWrapper: React.FC<MaintenanceWrapperProps> = ({ children
} catch (error: any) {
if (error.response?.status === 503) {
// Only set maintenance mode for non-admin routes or unauthenticated admin routes
if (!isAdminRoute || !hasAdminAuth) {
if (!isAdminRoute || !hasAdminSession) {
setMaintenanceMode(true);
return { maintenance: true };
}
@@ -47,13 +75,13 @@ export const MaintenanceWrapper: React.FC<MaintenanceWrapperProps> = ({ children
},
staleTime: 30000, // Check every 30 seconds
retry: false, // Don't retry on failure
enabled: (!isAdminRoute || !hasAdminAuth) && !isMaintenanceMode, // Don't check if already in maintenance
enabled: (!isAdminRoute || !hasAdminSession) && !isMaintenanceMode, // Don't check if already in maintenance
});
// Show maintenance page if in maintenance mode and not on admin route with auth
if (isMaintenanceMode && (!isAdminRoute || !hasAdminAuth)) {
if (isMaintenanceMode && (!isAdminRoute || !hasAdminSession)) {
return <MaintenanceMode />;
}
return <>{children}</>;
};
};
@@ -1,5 +1,4 @@
import React, { useState, useEffect } from 'react';
import { getAuthToken } from '../../config/api';
import { buildResourceUrl } from '../../utils/url';
interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
@@ -25,33 +24,12 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
let objectUrl: string | null = null;
// Determine which token to use based on context
let token: string | undefined;
if (isGallery) {
// For gallery images, get the gallery-specific token
const pathParts = window.location.pathname.split('/');
if (pathParts[1] === 'gallery' && pathParts[2]) {
const gallerySlug = pathParts[2];
token = localStorage.getItem(`gallery_token_${gallerySlug}`) || undefined;
}
} else {
// For admin images, use the admin token
token = getAuthToken(true);
}
if (!src) {
setImageSrc(fallbackSrc || '');
setIsLoading(false);
return;
}
if (!token) {
// No auth token - use fallback
setImageSrc(fallbackSrc || '');
setIsLoading(false);
return;
}
setIsLoading(true);
setError(false);
@@ -71,9 +49,7 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
// Fetch authenticated image
const response = await fetch(fullImageUrl, {
headers: {
'Authorization': `Bearer ${token}`
}
credentials: 'include'
});
if (!response.ok) {
@@ -119,4 +95,4 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
}
return <img src={imageSrc} alt={alt} {...props} />;
};
};
+5 -100
View File
@@ -1,9 +1,4 @@
import axios, { AxiosHeaders } from 'axios';
import Cookies from 'js-cookie';
// Cookie keys
export const ADMIN_TOKEN_KEY = 'admin_token';
export const GALLERY_TOKEN_KEY = 'gallery_token';
import axios from 'axios';
// Maintenance mode callback
let maintenanceModeCallback: ((enabled: boolean) => void) | null = null;
@@ -18,80 +13,12 @@ export const api = axios.create({
headers: {
'Content-Type': 'application/json',
},
withCredentials: false, // Ensure we're not relying on cookies
withCredentials: true,
});
// Request interceptor to add auth token
// Request interceptor: drop Content-Type for FormData payloads so the browser can set boundaries
api.interceptors.request.use(
(config) => {
// Don't process if headers are already set by the component
const existingAuth = config.headers?.['Authorization'] || config.headers?.get?.('Authorization');
// If authorization is already set by the component, don't override it
if (existingAuth) {
return config;
}
// Check if it's an admin route or gallery route
const isAdminRoute = config.url?.includes('/admin');
if (isAdminRoute) {
const token = Cookies.get(ADMIN_TOKEN_KEY);
if (token) {
if (!config.headers) {
config.headers = {};
}
config.headers['Authorization'] = `Bearer ${token}`;
}
} else {
// For gallery routes, try to extract slug from the request URL first
const galleryMatch = config.url?.match(/gallery\/([^\/]+)/);
if (galleryMatch && galleryMatch[1]) {
const galleryIdOrSlug = galleryMatch[1];
// Remove any query parameters from the slug
const cleanIdOrSlug = galleryIdOrSlug.split('?')[0];
// Check if it's a numeric ID (for upload endpoints)
let token = null;
if (/^\d+$/.test(cleanIdOrSlug)) {
// It's an event ID - try to find the token from current page slug
const pathParts = window.location.pathname.split('/');
if (pathParts[1] === 'gallery' && pathParts[2]) {
const gallerySlug = pathParts[2];
const cleanSlug = gallerySlug.split('?')[0];
token = localStorage.getItem(`gallery_token_${cleanSlug}`);
}
} else {
// It's a slug - use it directly
token = localStorage.getItem(`gallery_token_${cleanIdOrSlug}`);
}
if (token) {
if (!config.headers) {
config.headers = {};
}
config.headers['Authorization'] = `Bearer ${token}`;
}
} else {
// Fallback to getting slug from the current page URL
const pathParts = window.location.pathname.split('/');
if (pathParts[1] === 'gallery' && pathParts[2]) {
const gallerySlug = pathParts[2];
// Remove any query parameters from the slug
const cleanSlug = gallerySlug.split('?')[0];
const token = localStorage.getItem(`gallery_token_${cleanSlug}`);
if (token) {
if (!config.headers) {
config.headers = {};
}
config.headers['Authorization'] = `Bearer ${token}`;
}
}
}
}
// Don't set Content-Type for FormData - let browser set it with boundary
if (config.data instanceof FormData) {
delete config.headers?.['Content-Type'];
}
@@ -110,10 +37,9 @@ api.interceptors.response.use(
// Handle maintenance mode (503)
if (error.response?.status === 503) {
const isAdminRoute = error.config?.url?.includes('/admin');
const hasAdminAuth = error.config?.headers?.Authorization?.startsWith('Bearer ');
// Only trigger maintenance mode for non-admin routes or unauthenticated admin routes
if (!isAdminRoute || !hasAdminAuth) {
if (!isAdminRoute) {
if (maintenanceModeCallback) {
maintenanceModeCallback(true);
}
@@ -126,8 +52,6 @@ api.interceptors.response.use(
const currentPath = window.location.pathname;
if (isAdminRoute) {
// Clear admin token on unauthorized
Cookies.remove(ADMIN_TOKEN_KEY);
// Only redirect if we're not already on the admin login page
if (!currentPath.includes('/admin/login')) {
window.location.href = '/admin/login';
@@ -144,8 +68,7 @@ api.interceptors.response.use(
// Don't clear tokens for image requests - they might just need a retry
if (!isImageRequest && galleryMatch && galleryMatch[1]) {
const gallerySlug = galleryMatch[1];
localStorage.removeItem(`gallery_token_${gallerySlug}`);
localStorage.removeItem(`gallery_event_${gallerySlug}`);
sessionStorage.removeItem(`gallery_event_${gallerySlug}`);
}
// Don't redirect - let the component handle the auth state
} else if (galleryMatch) {
@@ -159,21 +82,3 @@ api.interceptors.response.use(
return Promise.reject(error);
}
);
// Helper to set auth tokens
export const setAuthToken = (token: string, isAdmin: boolean = false) => {
const key = isAdmin ? ADMIN_TOKEN_KEY : GALLERY_TOKEN_KEY;
Cookies.set(key, token, { expires: 1 }); // 1 day expiry
};
// Helper to clear auth tokens
export const clearAuthToken = (isAdmin: boolean = false) => {
const key = isAdmin ? ADMIN_TOKEN_KEY : GALLERY_TOKEN_KEY;
Cookies.remove(key);
};
// Helper to get auth tokens
export const getAuthToken = (isAdmin: boolean = false) => {
const key = isAdmin ? ADMIN_TOKEN_KEY : GALLERY_TOKEN_KEY;
return Cookies.get(key);
};
+29 -7
View File
@@ -1,6 +1,6 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import type { ReactNode } from 'react';
import { getAuthToken } from '../config/api';
import { api } from '../config/api';
import { authService } from '../services';
import type { AdminUser } from '../types';
@@ -40,15 +40,31 @@ export const AdminAuthProvider: React.FC<AdminAuthProviderProps> = ({ children }
// Check if user has a valid token on mount
const checkAuth = async () => {
try {
const token = getAuthToken(true);
if (token) {
// For now, just assume the token is valid
// TODO: Validate token with backend and get user info
const storedUser = sessionStorage.getItem('admin_user');
if (storedUser) {
try {
setUser(JSON.parse(storedUser));
} catch (err) {
sessionStorage.removeItem('admin_user');
}
}
const response = await api.get<{ valid: boolean; type: string; adminUsername?: string; user?: string }>(
'/auth/session'
);
if (response.data?.valid && response.data.type === 'admin') {
setIsAuthenticated(true);
} else {
sessionStorage.removeItem('admin_user');
setIsAuthenticated(false);
setUser(null);
}
} catch (error) {
// Auth check failed - user needs to login
setError('Failed to check authentication');
sessionStorage.removeItem('admin_user');
setIsAuthenticated(false);
setUser(null);
} finally {
setIsLoading(false);
}
@@ -63,9 +79,11 @@ export const AdminAuthProvider: React.FC<AdminAuthProviderProps> = ({ children }
setError(null);
setIsAuthenticated(true);
setMustChangePassword(user.mustChangePassword || false);
sessionStorage.setItem('admin_user', JSON.stringify(user));
};
const logout = () => {
sessionStorage.removeItem('admin_user');
authService.adminLogout();
setIsAuthenticated(false);
setUser(null);
@@ -79,6 +97,10 @@ export const AdminAuthProvider: React.FC<AdminAuthProviderProps> = ({ children }
...user,
mustChangePassword: false
});
sessionStorage.setItem('admin_user', JSON.stringify({
...user,
mustChangePassword: false
}));
}
};
@@ -98,4 +120,4 @@ export const AdminAuthProvider: React.FC<AdminAuthProviderProps> = ({ children }
{children}
</AdminAuthContext.Provider>
);
};
};
+77 -61
View File
@@ -1,5 +1,6 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import type { ReactNode } from 'react';
import { api } from '../config/api';
import { authService, galleryService } from '../services';
import { cleanupOldGalleryAuth } from '../utils/cleanupGalleryAuth';
@@ -52,65 +53,81 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
};
useEffect(() => {
// Clean up old authentication data on mount
cleanupOldGalleryAuth();
// Check if user has a valid token on mount
const currentSlug = getCurrentGallerySlug();
if (currentSlug) {
// Try to restore event data from localStorage with slug-specific key
const storedEvent = localStorage.getItem(`gallery_event_${currentSlug}`);
const storedToken = localStorage.getItem(`gallery_token_${currentSlug}`);
if (storedEvent && storedToken) {
const initialise = async () => {
const currentSlug = getCurrentGallerySlug();
if (!currentSlug) {
setIsLoading(false);
return;
}
const storedEvent = sessionStorage.getItem(`gallery_event_${currentSlug}`);
if (storedEvent) {
try {
const eventData = JSON.parse(storedEvent);
// Verify the stored event matches the current gallery slug
if (eventData && eventData.id) {
setEvent(eventData);
setIsAuthenticated(true);
} else {
// Clear invalid data
localStorage.removeItem(`gallery_event_${currentSlug}`);
localStorage.removeItem(`gallery_token_${currentSlug}`);
const parsed = JSON.parse(storedEvent);
if (parsed && parsed.id) {
setEvent(parsed);
}
} catch (error) {
// Invalid stored data - clear it
localStorage.removeItem(`gallery_event_${currentSlug}`);
localStorage.removeItem(`gallery_token_${currentSlug}`);
}
} else {
// No stored auth; check for token in URL and auto-authenticate
const parts = window.location.pathname.split('/');
const urlToken = parts.length >= 5 ? parts[4] : (parts.length >= 4 ? parts[3] : undefined);
if (urlToken) {
(async () => {
try {
setIsLoading(true);
// Verify token against backend
const verify = await galleryService.verifyToken(currentSlug, urlToken);
if (verify?.valid) {
// Store token and fetch event via photos endpoint to get full event object
localStorage.setItem(`gallery_token_${currentSlug}`, urlToken);
const data = await galleryService.getGalleryPhotos(currentSlug);
if (data?.event) {
setEvent(data.event);
setIsAuthenticated(true);
localStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(data.event));
}
}
} catch (e) {
// Invalid token; ensure any residual storage is cleared
localStorage.removeItem(`gallery_token_${currentSlug}`);
localStorage.removeItem(`gallery_event_${currentSlug}`);
} finally {
setIsLoading(false);
}
})();
} catch (err) {
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
}
}
}
setIsLoading(false);
try {
setIsLoading(true);
const sessionResponse = await api.get<{ valid: boolean; type: string; eventSlug?: string }>(
'/auth/session',
{ params: { slug: currentSlug } }
);
if (sessionResponse.data?.valid && sessionResponse.data.type === 'gallery' && sessionResponse.data.eventSlug === currentSlug) {
setIsAuthenticated(true);
if (!storedEvent) {
// Fetch gallery details to hydrate context
const galleryData = await galleryService.getGalleryPhotos(currentSlug);
if (galleryData?.event) {
setEvent(galleryData.event);
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(galleryData.event));
}
}
return;
}
// If no active session, check for share token in URL
const parts = window.location.pathname.split('/');
const urlToken = parts.length >= 5 ? parts[4] : (parts.length >= 4 ? parts[3] : undefined);
if (urlToken) {
const verify = await galleryService.verifyToken(currentSlug, urlToken);
if (verify?.valid) {
const response = await authService.shareLinkLogin(currentSlug, urlToken);
if (response?.event) {
setEvent(response.event);
setIsAuthenticated(true);
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(response.event));
return;
}
}
}
// No valid session found
setIsAuthenticated(false);
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
setEvent(null);
} catch (error) {
setIsAuthenticated(false);
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
setEvent(null);
} finally {
setIsLoading(false);
}
};
initialise();
}, []);
const login = async (slug: string, password: string, recaptchaToken?: string | null) => {
@@ -121,9 +138,8 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
setEvent(response.event);
setIsAuthenticated(true);
// Store event data and token in localStorage with slug-specific key
localStorage.setItem(`gallery_event_${slug}`, JSON.stringify(response.event));
localStorage.setItem(`gallery_token_${slug}`, response.token);
// Store event data for quick reloads (non-sensitive)
sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(response.event));
} catch (err: any) {
setError(err.response?.data?.error || 'Invalid password');
throw err;
@@ -135,13 +151,13 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
const logout = () => {
const currentSlug = getCurrentGallerySlug();
if (currentSlug) {
localStorage.removeItem(`gallery_event_${currentSlug}`);
localStorage.removeItem(`gallery_token_${currentSlug}`);
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
}
authService.galleryLogout();
authService.galleryLogout(currentSlug || undefined);
setIsAuthenticated(false);
setEvent(null);
};
}
;
return (
<GalleryAuthContext.Provider
+15 -12
View File
@@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next';
import { Button, Input, Card, ReCaptcha } from '../../components/common';
import { useAdminAuth } from '../../contexts';
import { authService } from '../../services/auth.service';
import { getAuthToken, api } from '../../config/api';
import { api } from '../../config/api';
export const AdminLoginPage: React.FC = () => {
const { t } = useTranslation();
@@ -84,17 +84,20 @@ export const AdminLoginPage: React.FC = () => {
login(response.token, response.user);
toast.success(t('adminLogin.loginSuccess'));
setLoginSuccess(true);
} catch (error: any) {
// Login error handled by UI notification
// Handle network errors gracefully
if (error.code === 'ERR_NETWORK' || error.code === 'ERR_CONNECTION_RESET') {
} catch (error: any) {
// Login error handled by UI notification
// Handle network errors gracefully
if (error.code === 'ERR_NETWORK' || error.code === 'ERR_CONNECTION_RESET') {
// Check if we actually got logged in despite the error
const token = getAuthToken(true);
if (token) {
// Login was successful, just had a connection issue
setLoginSuccess(true);
return;
try {
const sessionResponse = await api.get<{ valid: boolean; type: string }>('/auth/session');
if (sessionResponse.data?.valid && sessionResponse.data.type === 'admin') {
setLoginSuccess(true);
return;
}
} catch (sessionError) {
// Ignore secondary failure, we'll surface the original network error
}
toast.error(t('adminLogin.networkError'));
} else if (error.response?.status === 429) {
@@ -259,4 +262,4 @@ export const AdminLoginPage: React.FC = () => {
);
};
AdminLoginPage.displayName = 'AdminLoginPage';
AdminLoginPage.displayName = 'AdminLoginPage';
+24 -9
View File
@@ -1,4 +1,4 @@
import { api, setAuthToken, clearAuthToken } from '../config/api';
import { api } from '../config/api';
import type { LoginResponse, GalleryAuthResponse } from '../types';
export const authService = {
@@ -10,14 +10,17 @@ export const authService = {
password: credentials.password,
recaptchaToken: credentials.recaptchaToken
});
setAuthToken(response.data.token, true);
return response.data;
},
adminLogout() {
clearAuthToken(true);
window.location.href = '/admin/login';
async adminLogout() {
try {
await api.post('/auth/logout');
} catch (err) {
// Ignore logout errors; fallback to redirect
} finally {
window.location.href = '/admin/login';
}
},
// Gallery authentication
@@ -32,7 +35,19 @@ export const authService = {
return response.data;
},
galleryLogout() {
// Logout is now handled by GalleryAuthContext
async shareLinkLogin(slug: string, token: string): Promise<GalleryAuthResponse> {
const response = await api.post<GalleryAuthResponse>('/auth/gallery/share-login', {
slug,
token,
});
return response.data;
},
};
async galleryLogout(slug?: string | null) {
try {
await api.post('/auth/gallery/logout', { slug });
} catch (err) {
// Ignore; cookie will naturally expire if removal fails
}
},
};
+3 -14
View File
@@ -49,21 +49,10 @@ class SecureTokenService {
}
try {
// Get the gallery token from localStorage
const galleryToken = localStorage.getItem(`gallery_token_${slug}`);
if (!galleryToken) {
throw new Error('No gallery authentication token found');
}
// Generate new token from backend with explicit auth header
// Generate new token from backend authentication handled via cookies
const response = await api.post<SecureToken>(
`/secure-images/${slug}/generate-token`,
{ photoId, accessType },
{
headers: {
'Authorization': `Bearer ${galleryToken}`
}
}
{ photoId, accessType }
);
const tokenData: SecureToken = {
@@ -190,4 +179,4 @@ if (typeof window !== 'undefined') {
setInterval(() => {
secureTokenService.clearExpiredTokens();
}, 5 * 60 * 1000);
}
}
+2 -8
View File
@@ -9,18 +9,12 @@ export const cleanupOldGalleryAuth = () => {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && (key.startsWith('gallery_token') || key.startsWith('gallery_event'))) {
// Check if it's an old format token that might be corrupted
const value = localStorage.getItem(key);
if (value && (value.length < 100 || !value.includes('.'))) {
// Token is too short or doesn't contain dots (not a valid JWT)
keysToRemove.push(key);
}
keysToRemove.push(key);
}
}
keysToRemove.forEach(key => {
localStorage.removeItem(key);
// Silently remove corrupted tokens
});
// Remove old gallery token from cookies if it exists
@@ -29,4 +23,4 @@ export const cleanupOldGalleryAuth = () => {
// Also clear session storage
sessionStorage.removeItem('gallery_event');
sessionStorage.removeItem('gallery_token');
};
};