mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 16:52:08 +01:00
feat: implement comprehensive CSRF protection
This commit is contained in:
156
components/forms/CSRFProtectedForm.tsx
Normal file
156
components/forms/CSRFProtectedForm.tsx
Normal file
@ -0,0 +1,156 @@
|
||||
/**
|
||||
* CSRF Protected Form Component
|
||||
*
|
||||
* A wrapper component that automatically adds CSRF protection to forms.
|
||||
* This component demonstrates how to integrate CSRF tokens into form submissions.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { FormEvent, ReactNode } from "react";
|
||||
import { useCSRFForm } from "../../lib/hooks/useCSRF";
|
||||
|
||||
interface CSRFProtectedFormProps {
|
||||
children: ReactNode;
|
||||
action: string;
|
||||
method?: "POST" | "PUT" | "DELETE" | "PATCH";
|
||||
onSubmit?: (formData: FormData) => Promise<void> | void;
|
||||
className?: string;
|
||||
encType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Form component with automatic CSRF protection
|
||||
*/
|
||||
export function CSRFProtectedForm({
|
||||
children,
|
||||
action,
|
||||
method = "POST",
|
||||
onSubmit,
|
||||
className,
|
||||
encType,
|
||||
}: CSRFProtectedFormProps) {
|
||||
const { token, submitForm, addTokenToFormData } = useCSRFForm();
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
const form = event.currentTarget;
|
||||
const formData = new FormData(form);
|
||||
|
||||
// Add CSRF token to form data
|
||||
addTokenToFormData(formData);
|
||||
|
||||
try {
|
||||
if (onSubmit) {
|
||||
// Use custom submit handler
|
||||
await onSubmit(formData);
|
||||
} else {
|
||||
// Use default form submission with CSRF protection
|
||||
const response = await submitForm(action, formData);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Form submission failed: ${response.status}`);
|
||||
}
|
||||
|
||||
// Handle successful submission
|
||||
console.log("Form submitted successfully");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Form submission error:", error);
|
||||
// You might want to show an error message to the user here
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
method={method}
|
||||
action={action}
|
||||
className={className}
|
||||
encType={encType}
|
||||
>
|
||||
{/* Hidden CSRF token field for non-JS fallback */}
|
||||
{token && (
|
||||
<input
|
||||
type="hidden"
|
||||
name="csrf_token"
|
||||
value={token}
|
||||
/>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Example usage component showing how to use CSRF protected forms
|
||||
*/
|
||||
export function ExampleCSRFForm() {
|
||||
const handleCustomSubmit = async (formData: FormData) => {
|
||||
// Custom form submission logic
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
console.log("Form data:", data);
|
||||
|
||||
// You can process the form data here before submission
|
||||
// The CSRF token is automatically included in formData
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-md">
|
||||
<h2 className="text-xl font-semibold mb-4">CSRF Protected Form Example</h2>
|
||||
|
||||
<CSRFProtectedForm
|
||||
action="/api/example-endpoint"
|
||||
onSubmit={handleCustomSubmit}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
required
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="message" className="block text-sm font-medium text-gray-700">
|
||||
Message
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
rows={4}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</CSRFProtectedForm>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
153
components/providers/CSRFProvider.tsx
Normal file
153
components/providers/CSRFProvider.tsx
Normal file
@ -0,0 +1,153 @@
|
||||
/**
|
||||
* CSRF Provider Component
|
||||
*
|
||||
* Provides CSRF token management for the entire application.
|
||||
* Automatically fetches and manages CSRF tokens for client-side requests.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||
import { CSRFClient } from "../../lib/csrf";
|
||||
|
||||
interface CSRFContextType {
|
||||
token: string | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refreshToken: () => Promise<void>;
|
||||
addTokenToFetch: (options: RequestInit) => RequestInit;
|
||||
addTokenToFormData: (formData: FormData) => FormData;
|
||||
addTokenToObject: <T extends Record<string, unknown>>(obj: T) => T & { csrfToken: string };
|
||||
}
|
||||
|
||||
const CSRFContext = createContext<CSRFContextType | undefined>(undefined);
|
||||
|
||||
interface CSRFProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSRF Provider Component
|
||||
*/
|
||||
export function CSRFProvider({ children }: CSRFProviderProps) {
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* Fetch CSRF token from server
|
||||
*/
|
||||
const fetchToken = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// First check if we already have a token in cookies
|
||||
const existingToken = CSRFClient.getToken();
|
||||
if (existingToken) {
|
||||
setToken(existingToken);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch new token from server
|
||||
const response = await fetch("/api/csrf-token", {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch CSRF token: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.token) {
|
||||
setToken(data.token);
|
||||
} else {
|
||||
throw new Error("Invalid response from CSRF endpoint");
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to fetch CSRF token";
|
||||
setError(errorMessage);
|
||||
console.error("CSRF token fetch error:", errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Refresh token manually
|
||||
*/
|
||||
const refreshToken = async () => {
|
||||
await fetchToken();
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize token on mount
|
||||
*/
|
||||
useEffect(() => {
|
||||
fetchToken();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Monitor token changes in cookies
|
||||
*/
|
||||
useEffect(() => {
|
||||
const checkToken = () => {
|
||||
const currentToken = CSRFClient.getToken();
|
||||
if (currentToken !== token) {
|
||||
setToken(currentToken);
|
||||
}
|
||||
};
|
||||
|
||||
// Check token every 30 seconds
|
||||
const interval = setInterval(checkToken, 30 * 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [token]);
|
||||
|
||||
const contextValue: CSRFContextType = {
|
||||
token,
|
||||
loading,
|
||||
error,
|
||||
refreshToken,
|
||||
addTokenToFetch: CSRFClient.addTokenToFetch,
|
||||
addTokenToFormData: CSRFClient.addTokenToFormData,
|
||||
addTokenToObject: CSRFClient.addTokenToObject,
|
||||
};
|
||||
|
||||
return (
|
||||
<CSRFContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</CSRFContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to use CSRF context
|
||||
*/
|
||||
export function useCSRFContext(): CSRFContextType {
|
||||
const context = useContext(CSRFContext);
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error("useCSRFContext must be used within a CSRFProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Higher-order component to wrap components with CSRF protection
|
||||
*/
|
||||
export function withCSRF<P extends object>(Component: React.ComponentType<P>) {
|
||||
const WrappedComponent = (props: P) => (
|
||||
<CSRFProvider>
|
||||
<Component {...props} />
|
||||
</CSRFProvider>
|
||||
);
|
||||
|
||||
WrappedComponent.displayName = `withCSRF(${Component.displayName || Component.name})`;
|
||||
|
||||
return WrappedComponent;
|
||||
}
|
||||
Reference in New Issue
Block a user