feat: implement full authentication system with CSRF protection and email verification flow
All checks were successful
Build and Push App / build (push) Successful in 5m59s

This commit is contained in:
2026-04-30 23:46:34 +02:00
parent 4e881bcbe6
commit 9c003d1b7d
21 changed files with 762 additions and 19 deletions

View File

@@ -2,6 +2,7 @@ import React from 'react';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { ArrowLeft, Share2, Play, Calendar, User, Building2, MapPin, ArrowRight } from 'lucide-react';
import { sanitizeHTML } from '../../../lib/sanitize';
import { prisma } from '../../../lib/prisma';
import { InterviewType } from '@prisma/client';
@@ -158,10 +159,10 @@ export default async function AfroLifeDetailPage({ params }: Props) {
{/* Description / Article Content / Event Content */}
<div className="prose prose-lg prose-orange max-w-none mx-auto text-gray-600 break-words overflow-hidden">
{isEvent ? (
<div dangerouslySetInnerHTML={{ __html: (event as any).description }} />
<div dangerouslySetInnerHTML={{ __html: sanitizeHTML((event as any).description) }} />
) : (
!isVideo && (interview as any).content ? (
<div dangerouslySetInnerHTML={{ __html: (interview as any).content }} />
<div dangerouslySetInnerHTML={{ __html: sanitizeHTML((interview as any).content) }} />
) : (
<p className="text-xl font-serif italic text-gray-500 border-l-4 border-brand-500 pl-6 py-2">
{(interview as any).excerpt}

View File

@@ -1,6 +1,7 @@
import React from 'react';
import Link from 'next/link';
import { Play, FileText, Clock, Mic, Calendar } from 'lucide-react';
import { sanitizeHTML } from '../../lib/sanitize';
import { prisma } from '../../lib/prisma';
import { InterviewType } from '@prisma/client';
import EventTimeline from '../../components/EventTimeline';
@@ -169,7 +170,7 @@ export default async function AfroLifePage({ searchParams }: Props) {
<h3 className={`text-xl font-serif font-bold mb-2 transition-colors leading-tight line-clamp-2 ${isPastEvent ? 'text-gray-500' : 'text-gray-900 group-hover:text-brand-600'}`}>
{item.title}
</h3>
<div className="text-gray-600 text-sm line-clamp-2 prose-sm" dangerouslySetInnerHTML={{ __html: item.excerpt }} />
<div className="text-gray-600 text-sm line-clamp-2 prose-sm" dangerouslySetInnerHTML={{ __html: sanitizeHTML(item.excerpt) }} />
</div>
</Link>
);

View File

@@ -0,0 +1,7 @@
import { NextResponse } from 'next/server';
import { generateCsrfToken } from '@/lib/csrf';
export async function GET() {
const token = await generateCsrfToken();
return NextResponse.json({ csrfToken: token });
}

View File

@@ -1,9 +1,22 @@
import { NextResponse } from 'next/server';
import { NextResponse, NextRequest } from 'next/server';
import prisma from '@/lib/prisma';
import { sendEmail } from '@/lib/mail';
import crypto from 'crypto';
export async function POST(req: Request) {
import { verifyCsrfToken, verifyOrigin } from '@/lib/csrf';
export async function POST(req: NextRequest) {
// CSRF Protection
const isOriginValid = verifyOrigin(req);
const isCsrfValid = await verifyCsrfToken(req);
if (!isOriginValid || !isCsrfValid) {
return NextResponse.json(
{ error: 'Protection CSRF : Requête invalide' },
{ status: 403 }
);
}
try {
const { email } = await req.json();

View File

@@ -2,7 +2,20 @@ import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
import bcrypt from 'bcryptjs';
import { verifyCsrfToken, verifyOrigin } from '@/lib/csrf';
export async function POST(request: NextRequest) {
// CSRF Protection
const isOriginValid = verifyOrigin(request);
const isCsrfValid = await verifyCsrfToken(request);
if (!isOriginValid || !isCsrfValid) {
return NextResponse.json(
{ error: 'Protection CSRF : Requête invalide' },
{ status: 403 }
);
}
try {
const { email, password } = await request.json();

View File

@@ -3,7 +3,20 @@ import prisma from '@/lib/prisma';
import bcrypt from 'bcryptjs';
import crypto from 'crypto';
import { verifyCsrfToken, verifyOrigin } from '@/lib/csrf';
export async function POST(request: NextRequest) {
// CSRF Protection
const isOriginValid = verifyOrigin(request);
const isCsrfValid = await verifyCsrfToken(request);
if (!isOriginValid || !isCsrfValid) {
return NextResponse.json(
{ error: 'Protection CSRF : Requête invalide' },
{ status: 403 }
);
}
try {
const { name, email, password } = await request.json();

View File

@@ -2,7 +2,20 @@ import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
import crypto from 'crypto';
import { verifyCsrfToken, verifyOrigin } from '@/lib/csrf';
export async function POST(request: NextRequest) {
// CSRF Protection
const isOriginValid = verifyOrigin(request);
const isCsrfValid = await verifyCsrfToken(request);
if (!isOriginValid || !isCsrfValid) {
return NextResponse.json(
{ error: 'Protection CSRF : Requête invalide' },
{ status: 403 }
);
}
try {
const { email } = await request.json();

View File

@@ -1,10 +1,22 @@
import { NextResponse } from 'next/server';
import { NextResponse, NextRequest } from 'next/server';
import prisma from '@/lib/prisma';
import bcrypt from 'bcryptjs';
import { verifyCsrfToken, verifyOrigin } from '@/lib/csrf';
export async function POST(request: NextRequest) {
// CSRF Protection
const isOriginValid = verifyOrigin(request);
const isCsrfValid = await verifyCsrfToken(request);
if (!isOriginValid || !isCsrfValid) {
return NextResponse.json(
{ error: 'Protection CSRF : Requête invalide' },
{ status: 403 }
);
}
export async function POST(req: Request) {
try {
const { token, password } = await req.json();
const { token, password } = await request.json();
if (!token || !password) {
return NextResponse.json({ error: 'Token et mot de passe sont requis' }, { status: 400 });

View File

@@ -2,6 +2,7 @@ import React from 'react';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { ArrowLeft, User, Calendar, Share2 } from 'lucide-react';
import { sanitizeHTML } from '../../../lib/sanitize';
import { prisma } from '../../../lib/prisma';
import { Metadata } from 'next';
@@ -107,7 +108,7 @@ export default async function BlogPostPage({ params }: Props) {
</p>
<div
className="mt-6"
dangerouslySetInnerHTML={{ __html: post.content }}
dangerouslySetInnerHTML={{ __html: sanitizeHTML(post.content) }}
/>
</div>

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { getLegalDocument } from '@/lib/legal';
import { sanitizeHTML } from '@/lib/sanitize';
import { getSiteSettings } from '@/lib/settings';
import { Metadata } from 'next';
@@ -32,7 +33,7 @@ export default async function CGUPage() {
<div
className="prose prose-orange max-w-none text-gray-600 space-y-6"
dangerouslySetInnerHTML={{ __html: doc.content }}
dangerouslySetInnerHTML={{ __html: sanitizeHTML(doc.content) }}
/>
<div className="mt-12 pt-8 border-t border-gray-100 text-xs text-gray-400">

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { getLegalDocument } from '@/lib/legal';
import { sanitizeHTML } from '@/lib/sanitize';
import { getSiteSettings } from '@/lib/settings';
import { Metadata } from 'next';
@@ -32,7 +33,7 @@ export default async function CGVPage() {
<div
className="prose prose-orange max-w-none text-gray-600 space-y-6"
dangerouslySetInnerHTML={{ __html: doc.content }}
dangerouslySetInnerHTML={{ __html: sanitizeHTML(doc.content) }}
/>
<div className="mt-12 pt-8 border-t border-gray-100 text-xs text-gray-400">

View File

@@ -7,10 +7,19 @@ import { useUser } from '@/components/UserProvider';
const ForgotPasswordPage = () => {
const { settings } = useUser();
const siteName = settings?.siteName || "Afrohub";
// Fetch CSRF token on mount
React.useEffect(() => {
fetch('/api/auth/csrf')
.then(res => res.json())
.then(data => setCsrfToken(data.csrfToken))
.catch(err => console.error('Error fetching CSRF token:', err));
}, []);
const [email, setEmail] = useState('');
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [csrfToken, setCsrfToken] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -21,7 +30,10 @@ const ForgotPasswordPage = () => {
try {
const res = await fetch('/api/auth/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
'x-csrf-token': csrfToken
},
body: JSON.stringify({ email }),
});
@@ -61,6 +73,7 @@ const ForgotPasswordPage = () => {
</div>
) : (
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<input type="hidden" name="csrf_token" value={csrfToken} />
{error && (
<div className="bg-red-50 border-l-4 border-red-500 p-4 rounded text-red-700 text-sm">
{error}

View File

@@ -17,11 +17,20 @@ const LoginPage = () => {
const [loading, setLoading] = useState(false);
const [resendLoading, setResendLoading] = useState(false);
const [resendMessage, setResendMessage] = useState('');
const [csrfToken, setCsrfToken] = useState('');
const router = useRouter();
const searchParams = useSearchParams();
const verified = searchParams.get('verified') === 'true';
const queryError = searchParams.get('error');
// Fetch CSRF token on mount
React.useEffect(() => {
fetch('/api/auth/csrf')
.then(res => res.json())
.then(data => setCsrfToken(data.csrfToken))
.catch(err => console.error('Error fetching CSRF token:', err));
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -33,7 +42,10 @@ const LoginPage = () => {
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
'x-csrf-token': csrfToken
},
body: JSON.stringify({ email, password }),
});
@@ -64,7 +76,10 @@ const LoginPage = () => {
try {
const res = await fetch('/api/auth/resend-verification', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
'x-csrf-token': csrfToken
},
body: JSON.stringify({ email }),
});
@@ -131,6 +146,7 @@ const LoginPage = () => {
)}
<form className="space-y-6" onSubmit={handleSubmit}>
<input type="hidden" name="csrf_token" value={csrfToken} />
<div className="rounded-md shadow-sm -space-y-px">
<div>
<input

View File

@@ -19,11 +19,20 @@ const RegisterPage = () => {
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [csrfToken, setCsrfToken] = useState('');
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
setError('');
};
// Fetch CSRF token on mount
React.useEffect(() => {
fetch('/api/auth/csrf')
.then(res => res.json())
.then(data => setCsrfToken(data.csrfToken))
.catch(err => console.error('Error fetching CSRF token:', err));
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -39,7 +48,10 @@ const RegisterPage = () => {
try {
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
'x-csrf-token': csrfToken
},
body: JSON.stringify({
name: formData.name,
email: formData.email,
@@ -87,6 +99,7 @@ const RegisterPage = () => {
</div>
) : (
<form className="space-y-6" onSubmit={handleSubmit}>
<input type="hidden" name="csrf_token" value={csrfToken} />
{error && (
<div className="bg-red-50 border-l-4 border-red-500 p-4 rounded-r-lg flex items-center gap-3 text-red-700 animate-in slide-in-from-top-2 duration-300">
<AlertCircle size={20} className="shrink-0" />

View File

@@ -13,9 +13,18 @@ const ResetPasswordForm = () => {
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [csrfToken, setCsrfToken] = useState('');
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get('token');
// Fetch CSRF token on mount
React.useEffect(() => {
fetch('/api/auth/csrf')
.then(res => res.json())
.then(data => setCsrfToken(data.csrfToken))
.catch(err => console.error('Error fetching CSRF token:', err));
}, []);
useEffect(() => {
if (!token) {
@@ -43,7 +52,10 @@ const ResetPasswordForm = () => {
try {
const res = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
'x-csrf-token': csrfToken
},
body: JSON.stringify({ token, password }),
});
@@ -84,6 +96,7 @@ const ResetPasswordForm = () => {
</div>
) : (
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<input type="hidden" name="csrf_token" value={csrfToken} />
{error && (
<div className="bg-red-50 border-l-4 border-red-500 p-4 rounded text-red-700 text-sm">
{error}