الجلسات والكوكيز والمرشّحات

مشروع: تسجيل الدخول والجلسات ومنطقة محمية

18 دقيقة الدرس 10 من 13

مشروع: تسجيل الدخول والجلسات ومنطقة محمية

كل ما غطّيناه في هذا البرنامج التعليمي يتقاطع هنا. ستبني تطبيقًا صغيرًا بشكل احترافي: نموذج تسجيل دخول، وآلية مصادقة قائمة على الجلسة، وفلتر يحرس كل رابط محمي، وخروج نظيف. الهدف ليس فقط إنجاز الأمر بل إنجازه كما يفعل المحترف: آمن افتراضيًا، سهل التوسعة، وسهل الفهم.

نظرة عامة على المشروع

يتكوّن التطبيق النهائي من أربعة أجزاء:

  1. LoginServlet — يعرض نموذج تسجيل الدخول (GET) ويعالج بيانات الاعتماد (POST).
  2. AuthFilter — فلتر jakarta.servlet.Filter مرتبط بالمسار /app/*؛ يحجب الوصول غير المصادق عليه ويعيد التوجيه إلى صفحة تسجيل الدخول.
  3. DashboardServlet — صفحة محمية، لا يمكن الوصول إليها إلا عبر الفلتر.
  4. LogoutServlet — يُبطل الجلسة ويعيد التوجيه إلى صفحة تسجيل الدخول.

لا إطار عمل خارجي، ولا Spring Security. فقط Servlet API — وهو بالضبط ما تنفّذه أطر العمل كـ Spring Security تحت الغطاء، لذا فإنّ فهم هذا يمنحك نفوذًا حقيقيًا.

الخطوة الأولى — مخزن المستخدمين

في تطبيق حقيقي تستعلم من قاعدة بيانات. في هذا المشروع استخدم خريطة بسيطة في الذاكرة حتى يبقى التركيز على تدفق المصادقة لا على سباكة JDBC.

package com.example.auth; import java.util.Map; public final class UserStore { // اسم المستخدم -> تجزئة bcrypt (في الإنتاج استخدم مكتبة تجزئة حقيقية) private static final Map<String, String> USERS = Map.of( "alice", "$2a$10$Examplehashfordemopurposesonly1", "bob", "$2a$10$Examplehashfordemopurposesonly2" ); private UserStore() {} /** تُعيد true إذا تطابقت بيانات الاعتماد. */ public static boolean verify(String username, String password) { String stored = USERS.get(username); if (stored == null) return false; // في الإنتاج استبدل بـ BCrypt.checkpw(password, stored) return password.equals("demo"); // نائب للمثال فقط } }
لا تخزّن كلمات المرور بنص واضح أبدًا. في الإنتاج خزّن دائمًا تجزئة bcrypt أو Argon2 واستخدم المكتبة المطابقة للتحقق. مكتبة jBCrypt تبعية من ملف JAR واحد. هذا المثال يستخدم نائبًا حتى يُترجَم الكود دون تبعيات إضافية.

الخطوة الثانية — سيرفليت تسجيل الدخول

يتعامل السيرفليت مع عرض النموذج (GET) والتحقق من بيانات الاعتماد (POST). عند النجاح يكتب اسم المستخدم المصادق عليه في الجلسة تحت مفتاح محدد جيدًا ويعيد التوجيه إلى المنطقة المحمية. عند الفشل يعيد التوجيه إلى النموذج مع رسالة خطأ.

package com.example.auth; import jakarta.servlet.ServletException; import jakarta.servlet.annotation.WebServlet; import jakarta.servlet.http.HttpServlet; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; import java.io.IOException; @WebServlet("/login") public class LoginServlet extends HttpServlet { public static final String SESSION_USER_KEY = "authenticatedUser"; @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.getRequestDispatcher("/WEB-INF/views/login.jsp").forward(req, resp); } @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username = req.getParameter("username"); String password = req.getParameter("password"); if (username == null || password == null || !UserStore.verify(username, password)) { req.setAttribute("error", "اسم المستخدم أو كلمة المرور غير صحيحة."); req.getRequestDispatcher("/WEB-INF/views/login.jsp").forward(req, resp); return; } // أبطل أي جلسة سابقة لمنع ثبات الجلسة HttpSession oldSession = req.getSession(false); if (oldSession != null) { oldSession.invalidate(); } // أنشئ جلسة جديدة وخزّن الهوية HttpSession session = req.getSession(true); session.setAttribute(SESSION_USER_KEY, username); session.setMaxInactiveInterval(30 * 60); // مهلة خمول 30 دقيقة resp.sendRedirect(req.getContextPath() + "/app/dashboard"); } }
هجوم ثبات الجلسة (Session Fixation): يستطيع المهاجم زرع معرّف جلسة معروف في متصفح الضحية قبل تسجيل الدخول. إذا أعاد التطبيق استخدام تلك الجلسة بعد المصادقة يصبح لدى المهاجم جلسة مصادق عليها صالحة. الحل بسيط: أبطل دائمًا الجلسة القديمة وأنشئ جلسة جديدة لحظة تسجيل الدخول. لا تتخطَّ هذه الخطوة أبدًا.

الخطوة الثالثة — فلتر المصادقة

الفلتر هو قلب المنطقة المحمية. يعترض كل طلب يطابق /app/*. إذا احتوت الجلسة على سمة المستخدم المصادق عليه يستمر الطلب؛ وإلا يُرسَل المستخدم إلى صفحة تسجيل الدخول. يُحفظ رابط الطلب الأصلي حتى ينتقل المستخدم إلى الصفحة الصحيحة بعد تسجيل الدخول — تفصيل صغير لكنه مهم في تجربة المستخدم.

package com.example.auth; import jakarta.servlet.Filter; import jakarta.servlet.FilterChain; import jakarta.servlet.FilterConfig; import jakarta.servlet.ServletException; import jakarta.servlet.ServletRequest; import jakarta.servlet.ServletResponse; import jakarta.servlet.annotation.WebFilter; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; import java.io.IOException; @WebFilter("/app/*") public class AuthFilter implements Filter { @Override public void init(FilterConfig cfg) throws ServletException { /* لا شيء للتهيئة */ } @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; HttpSession session = req.getSession(false); // لا تُنشئ جلسة هنا boolean authenticated = session != null && session.getAttribute(LoginServlet.SESSION_USER_KEY) != null; if (!authenticated) { // تذكّر إلى أين كان المستخدم يتجه String target = req.getRequestURI(); if (req.getQueryString() != null) { target += "?" + req.getQueryString(); } resp.sendRedirect(req.getContextPath() + "/login?next=" + java.net.URLEncoder.encode(target, "UTF-8")); return; // لا تستدع chain.doFilter — ينتهي الطلب هنا } chain.doFilter(request, response); // مصادق عليه: تابع } @Override public void destroy() {} }
استخدم getSession(false) في الفلاتر، وليس getSession(true). تمرير true (أو بدون وسيطة) سينشئ جلسة جديدة لكل زائر غير مصادق عليه مما يُهدر ذاكرة الخادم. مرّر false حتى تُعيد الجلسة المفقودة null — وهي إشارتك على عدم المصادقة.

الخطوة الرابعة — سيرفليت لوحة التحكم

لأن الفلتر يضمن المصادقة مسبقًا يستطيع السيرفليت التركيز كليًا على منطق أعماله. يثق بسمة الجلسة ويقرأ اسم المستخدم مباشرةً.

package com.example.auth; import jakarta.servlet.ServletException; import jakarta.servlet.annotation.WebServlet; import jakarta.servlet.http.HttpServlet; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; @WebServlet("/app/dashboard") public class DashboardServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String user = (String) req.getSession().getAttribute( LoginServlet.SESSION_USER_KEY); req.setAttribute("username", user); req.getRequestDispatcher("/WEB-INF/views/dashboard.jsp").forward(req, resp); } }

الخطوة الخامسة — تسجيل الخروج

يجب أن يؤدّي تسجيل الخروج مهمتين: تدمير الجلسة على جانب الخادم وإزالة ملف تعريف ارتباط الجلسة من المتصفح. إبطال الجلسة وحده كافٍ لإلغاء الحالة من جانب الخادم؛ لكن انتهاء صلاحية الكوكي صراحةً أكثر أناقةً ويتجنب الإرباك في أدوات المطوّر في المتصفح.

package com.example.auth; import jakarta.servlet.ServletException; import jakarta.servlet.annotation.WebServlet; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServlet; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; import java.io.IOException; @WebServlet("/logout") public class LogoutServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { HttpSession session = req.getSession(false); if (session != null) { session.invalidate(); } // أنهِ صلاحية كوكي JSESSIONID في المتصفح Cookie kill = new Cookie("JSESSIONID", ""); kill.setMaxAge(0); kill.setPath(req.getContextPath().isEmpty() ? "/" : req.getContextPath()); kill.setHttpOnly(true); resp.addCookie(kill); resp.sendRedirect(req.getContextPath() + "/login"); } }
يجب أن يستخدم تسجيل الخروج POST وليس GET. تسجيل الخروج القائم على GET عرضة لهجمات CSRF: يمكن لوسم صورة ضار في موقع آخر تسجيل خروج مستخدمك بصمت. استخدم نموذجًا بـ method="post" وإذا كان تطبيقك يمتلك بنية تحتية لرمز CSRF أدرجه.

عروض JSP

ابقِ العروض خفيفة. تقرأ صفحة تسجيل الدخول السمة الاختيارية error التي يضبطها السيرفليت. تقرأ لوحة التحكم username. يستخدم الاثنان وسم JSTL هو c:out لإخراج القيم بأمان — فهو يُهرّب HTML تلقائيًا مما يمنع XSS.

<%-- login.jsp --%> <%@ taglib prefix="c" uri="jakarta.tags.core" %> <form method="post" action="${pageContext.request.contextPath}/login"> <c:if test="${not empty error}"> <p class="error"><c:out value="${error}"/></p> </c:if> <input type="text" name="username" required /> <input type="password" name="password" required /> <button type="submit">تسجيل الدخول</button> </form>
<%-- dashboard.jsp --%> <%@ taglib prefix="c" uri="jakarta.tags.core" %> <p>مرحبًا، <c:out value="${username}"/>!</p> <form method="post" action="${pageContext.request.contextPath}/logout"> <button type="submit">تسجيل الخروج</button> </form>

كيف تعمل الأجزاء معًا

تتبّع دورة كاملة من تسجيل الدخول إلى لوحة التحكم يوضّح دور كل مكوّن:

  1. المتصفح GET على /app/dashboardAuthFilter يعترض، لا يجد جلسة، يعيد التوجيه إلى /login?next=/app/dashboard.
  2. المتصفح GET على /loginLoginServlet.doGet يعيد التوجيه إلى login.jsp.
  3. المتصفح POST على /loginLoginServlet.doPost يتحقق من بيانات الاعتماد، يُبطل الجلسة القديمة، ينشئ جلسة جديدة، يخزّن اسم المستخدم، يعيد التوجيه إلى /app/dashboard.
  4. المتصفح GET على /app/dashboardAuthFilter يجد سمة الجلسة، يستدعي chain.doFilter، DashboardServlet يعرض الصفحة.
  5. المتصفح POST على /logoutLogoutServlet يُبطل الجلسة، ينهي صلاحية الكوكي، يعيد التوجيه إلى /login.

قائمة تدقيق تصليب الأمان

  • ثبات الجلسة: أبطل دائمًا قبل إنشاء جلسة ما بعد تسجيل الدخول. ✓
  • HTTPS فقط: عيّن علامة Secure لكوكي الجلسة في web.xml حتى لا يُرسَل JSESSIONID عبر HTTP العادي أبدًا.
  • كوكي HttpOnly: يمنع JavaScript من قراءة كوكي الجلسة مما يحجب أكثر ناقلات سرقة الجلسة عبر XSS شيوعًا. يُضبط في web.xml بـ <cookie-config><http-only>true</http-only></cookie-config>.
  • مهلة قصيرة: تحدّ setMaxInactiveInterval من الضرر إذا سُرقت جلسة من متصفح خامل.
  • CSRF عند تسجيل الخروج: استخدم POST لجميع الإجراءات التي تغيّر الحالة.
  • تجزئة كلمة المرور: لا تخزّن أو تقارن نصًا واضحًا. استخدم bcrypt أو Argon2.

الخلاصة

يوضّح هذا المشروع حلقة المصادقة الكاملة باستخدام Servlet API فحسب. يُطبّق Filter التحكم في الوصول تصريحيًا عبر نمط URL مما يُبقي مخاوف المصادقة خارج سيرفليتات الأعمال. تحمل HttpSession الهوية المصادق عليها عبر الطلبات. تسجيل الخروج ينظّف الحالة من جانب الخادم وكوكي المتصفح معًا. لكل مصيدة أمنية — ثبات الجلسة، التخزين بنص واضح، تسجيل الخروج بـ GET، الإخراج غير المُهرَّب — إجراء مضادّ ملموس وبسيط. تنتقل هذه الأنماط مباشرةً إلى Spring Security وJakarta Security وأي إطار عمل آخر ستواجهه: الآليات الأساسية متطابقة.