أساسيات React.js
المصادقة في React
تنفيذ المصادقة في تطبيقات React
المصادقة ميزة حاسمة في معظم التطبيقات. في هذا الدرس، سنبني نظام مصادقة كامل باستخدام رموز JWT، بما في ذلك تدفقات تسجيل الدخول/التسجيل، والمسارات المحمية، وتحديث الرمز، وتكامل OAuth.
نظرة عامة على بنية المصادقة
نظام مصادقة React النموذجي يتكون من:
- Auth Context: إدارة حالة المصادقة العامة
- خدمة API: طلبات HTTP لتسجيل الدخول والتسجيل وتسجيل الخروج
- تخزين الرمز: تخزين آمن لرموز الوصول والتحديث
- المسارات المحمية: حراس المسار للمستخدمين المصادقين
- تحديث الرمز: تجديد تلقائي للرمز قبل انتهاء الصلاحية
- المعترضات: إرفاق تلقائي للرمز بالطلبات
الأمان أولاً: لا تخزن البيانات الحساسة في localStorage بدون تشفير. استخدم ملفات تعريف الارتباط httpOnly للرموز عندما يكون ذلك ممكناً، أو نفّذ حماية XSS مناسبة.
إعداد Auth Context
أنشئ سياقاً لإدارة حالة المصادقة عالمياً:
Auth Context (AuthContext.tsx):
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
interface User {
id: string;
email: string;
name: string;
avatar?: string;
}
interface AuthContextValue {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
register: (name: string, email: string, password: string) => Promise<void>;
logout: () => void;
updateUser: (user: Partial<User>) => void;
}
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
interface AuthProviderProps {
children: ReactNode;
}
export function AuthProvider({ children }: AuthProviderProps) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// التحقق من تسجيل دخول المستخدم عند التحميل
checkAuth();
}, []);
const checkAuth = async () => {
try {
const token = localStorage.getItem('accessToken');
if (!token) {
setIsLoading(false);
return;
}
// التحقق من الرمز واستدعاء بيانات المستخدم
const response = await fetch('/api/auth/me', {
headers: {
Authorization: `Bearer ${token}`
}
});
if (response.ok) {
const userData = await response.json();
setUser(userData);
} else {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
}
} catch (error) {
console.error('Auth check failed:', error);
} finally {
setIsLoading(false);
}
};
const login = async (email: string, password: string) => {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Login failed');
}
const { user, accessToken, refreshToken } = await response.json();
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
setUser(user);
};
const register = async (name: string, email: string, password: string) => {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, password })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Registration failed');
}
const { user, accessToken, refreshToken } = await response.json();
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
setUser(user);
};
const logout = () => {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
setUser(null);
// اختياري: استدعاء نقطة نهاية تسجيل الخروج لإبطال رمز التحديث
fetch('/api/auth/logout', { method: 'POST' }).catch(() => {});
};
const updateUser = (updates: Partial<User>) => {
setUser(prev => prev ? { ...prev, ...updates } : null);
};
const value: AuthContextValue = {
user,
isAuthenticated: !!user,
isLoading,
login,
register,
logout,
updateUser,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
نماذج تسجيل الدخول والتسجيل
أنشئ نماذج مصادقة سهلة الاستخدام:
مكون نموذج تسجيل الدخول:
import { useState, FormEvent } from 'react';
import { useAuth } from './AuthContext';
import { useNavigate, Link } from 'react-router-dom';
function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await login(email, password);
navigate('/dashboard');
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed');
} finally {
setLoading(false);
}
};
return (
<div className="auth-container">
<div className="auth-card">
<h1>مرحباً بعودتك</h1>
<p>قم بتسجيل الدخول إلى حسابك</p>
{error && (
<div className="alert alert-error">
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="email">البريد الإلكتروني</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={loading}
placeholder="you@example.com"
/>
</div>
<div className="form-group">
<label htmlFor="password">كلمة المرور</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={loading}
placeholder="••••••••"
/>
</div>
<div className="form-actions">
<Link to="/forgot-password" className="link">
نسيت كلمة المرور؟
</Link>
</div>
<button type="submit" disabled={loading} className="btn-primary">
{loading ? 'جارٍ تسجيل الدخول...' : 'تسجيل الدخول'}
</button>
</form>
<div className="auth-footer">
ليس لديك حساب؟{" "}
<Link to="/register" className="link">سجّل الآن</Link>
</div>
</div>
</div>
);
}
مكون نموذج التسجيل:
import { useState, FormEvent } from 'react';
import { useAuth } from './AuthContext';
import { useNavigate, Link } from 'react-router-dom';
function RegisterPage() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { register } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError('');
// التحقق
if (password !== confirmPassword) {
setError('كلمات المرور غير متطابقة');
return;
}
if (password.length < 8) {
setError('يجب أن تكون كلمة المرور 8 أحرف على الأقل');
return;
}
setLoading(true);
try {
await register(name, email, password);
navigate('/dashboard');
} catch (err) {
setError(err instanceof Error ? err.message : 'فشل التسجيل');
} finally {
setLoading(false);
}
};
return (
<div className="auth-container">
<div className="auth-card">
<h1>إنشاء حساب</h1>
<p>سجّل للبدء</p>
{error && (
<div className="alert alert-error">
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="name">الاسم الكامل</label>
<input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
disabled={loading}
placeholder="أحمد محمد"
/>
</div>
<div className="form-group">
<label htmlFor="email">البريد الإلكتروني</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={loading}
placeholder="you@example.com"
/>
</div>
<div className="form-group">
<label htmlFor="password">كلمة المرور</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={loading}
placeholder="••••••••"
minLength={8}
/>
</div>
<div className="form-group">
<label htmlFor="confirmPassword">تأكيد كلمة المرور</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
disabled={loading}
placeholder="••••••••"
/>
</div>
<button type="submit" disabled={loading} className="btn-primary">
{loading ? 'جارٍ إنشاء الحساب...' : 'إنشاء حساب'}
</button>
</form>
<div className="auth-footer">
لديك حساب بالفعل؟{" "}
<Link to="/login" className="link">سجّل دخولك</Link>
</div>
</div>
</div>
);
}
المسارات المحمية
أنشئ حراس مسار لحماية الصفحات المصادق عليها:
مكون المسار المحمي:
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from './AuthContext';
interface ProtectedRouteProps {
children: React.ReactNode;
}
function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isAuthenticated, isLoading } = useAuth();
const location = useLocation();
if (isLoading) {
return (
<div className="loading-screen">
<div className="spinner"></div>
<p>جارٍ التحميل...</p>
</div>
);
}
if (!isAuthenticated) {
// إعادة التوجيه لتسجيل الدخول، لكن احفظ الموقع الذي كانوا يحاولون زيارته
return <Navigate to="/login" state={{ from: location }} replace />;
}
return <>{children}</>;
}
// الاستخدام في المسارات
function App() {
return (
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
{/* المسارات المحمية */}
<Route
path="/dashboard"
element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
}
/>
<Route
path="/profile"
element={
<ProtectedRoute>
<ProfilePage />
</ProtectedRoute>
}
/>
</Routes>
);
}
إعادة التوجيه بعد تسجيل الدخول: استخدم حالة الموقع لإعادة توجيه المستخدمين إلى الصفحة التي كانوا يحاولون الوصول إليها بعد تسجيل الدخول بنجاح، مما يوفر تجربة مستخدم أفضل.
آلية تحديث الرمز
نفّذ تحديثاً تلقائياً للرمز للحفاظ على المستخدمين مسجلين:
Axios Interceptor مع تحديث الرمز:
import axios from 'axios';
const api = axios.create({
baseURL: '/api',
});
let isRefreshing = false;
let failedQueue: any[] = [];
const processQueue = (error: any, token: string | null = null) => {
failedQueue.forEach(prom => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
// معترض الطلب - إرفاق الرمز
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// معترض الاستجابة - معالجة 401 وتحديث الرمز
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// إذا لم يكن الخطأ 401 أو تمت إعادة المحاولة بالفعل، ارفض
if (error.response?.status !== 401 || originalRequest._retry) {
return Promise.reject(error);
}
if (isRefreshing) {
// وضع الطلبات في قائمة الانتظار أثناء التحديث
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then(token => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return api(originalRequest);
})
.catch(err => Promise.reject(err));
}
originalRequest._retry = true;
isRefreshing = true;
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
processQueue(new Error('No refresh token'), null);
isRefreshing = false;
// إعادة التوجيه لتسجيل الدخول
window.location.href = '/login';
return Promise.reject(error);
}
try {
const response = await axios.post('/api/auth/refresh', {
refreshToken
});
const { accessToken } = response.data;
localStorage.setItem('accessToken', accessToken);
processQueue(null, accessToken);
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
return api(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null);
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
);
export default api;
تكامل OAuth (Google/GitHub)
نفّذ تسجيل الدخول الاجتماعي مع موفري OAuth:
مكون تسجيل دخول OAuth:
function OAuthButtons() {
const handleGoogleLogin = () => {
// إعادة التوجيه لنقطة نهاية OAuth للخلفية
window.location.href = '/api/auth/google';
};
const handleGitHubLogin = () => {
window.location.href = '/api/auth/github';
};
return (
<div className="oauth-buttons">
<button onClick={handleGoogleLogin} className="btn-oauth google">
<img src="/google-icon.svg" alt="" />
المتابعة مع Google
</button>
<button onClick={handleGitHubLogin} className="btn-oauth github">
<img src="/github-icon.svg" alt="" />
المتابعة مع GitHub
</button>
<div className="divider">
<span>أو</span>
</div>
</div>
);
}
معالج استدعاء OAuth:
import { useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from './AuthContext';
function OAuthCallbackPage() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { updateUser } = useAuth();
useEffect(() => {
const token = searchParams.get('token');
const refreshToken = searchParams.get('refreshToken');
const error = searchParams.get('error');
if (error) {
console.error('OAuth error:', error);
navigate('/login', { state: { error } });
return;
}
if (token && refreshToken) {
localStorage.setItem('accessToken', token);
localStorage.setItem('refreshToken', refreshToken);
// استدعاء بيانات المستخدم
fetch('/api/auth/me', {
headers: { Authorization: `Bearer ${token}` }
})
.then(res => res.json())
.then(user => {
updateUser(user);
navigate('/dashboard');
})
.catch(() => {
navigate('/login', { state: { error: 'فشل تحميل بيانات المستخدم' } });
});
} else {
navigate('/login', { state: { error: 'استجابة OAuth غير صالحة' } });
}
}, [searchParams, navigate, updateUser]);
return (
<div className="loading-screen">
<div className="spinner"></div>
<p>جارٍ إكمال تسجيل الدخول...</p>
</div>
);
}
export default OAuthCallbackPage;
اعتبارات الأمان: دائماً تحقق من رموز OAuth على الخلفية، استخدم HTTPS في الإنتاج، نفّذ حماية CSRF، اضبط أوقات انتهاء صلاحية قصيرة للرموز، ولا تعرض أسرار العميل في كود الواجهة الأمامية أبداً.
تمرين 1: نظام مصادقة كامل
ابنِ نظام مصادقة كامل مع:
- وظائف تسجيل الدخول والتسجيل وتسجيل الخروج
- مسارات محمية مع إعادة توجيه بعد تسجيل الدخول
- آلية تحديث الرمز
- مؤشر قوة كلمة المرور عند التسجيل
- خانة اختيار "تذكرني" (انتهاء صلاحية رمز ممتد)
تمرين 2: إدارة ملف المستخدم
أنشئ ميزات ملف المستخدم:
- عرض وتحرير معلومات الملف الشخصي
- تغيير كلمة المرور مع التحقق من كلمة المرور الحالية
- رفع وتحديث صورة الملف الشخصي
- تدفق التحقق من البريد الإلكتروني
- إعداد المصادقة الثنائية (إضافي)
تمرين 3: تدفق إعادة تعيين كلمة المرور
نفّذ نسيت/إعادة تعيين كلمة المرور:
- صفحة نسيت كلمة المرور (إدخال البريد الإلكتروني)
- إرسال رمز إعادة التعيين عبر البريد الإلكتروني (محاكاة)
- صفحة إعادة تعيين كلمة المرور مع التحقق من الرمز
- متطلبات قوة كلمة المرور
- ملاحظات النجاح/الخطأ وإعادة التوجيه