Java Web 高级编程 - 第五章 使用会话维持状态

本章内容

 

需要会话的原因

使用会话cookie和URL重写

在Web会话的理论中,会话是由服务器或Web应用程序管理的某些文件、内存片段、对象或者容器,它包含了分配给它的各种不同数据。

通常会话被赋予一个随机生成的字符串,称为会话ID。

第一次创建会话时(即收到请求时),创建的会话ID将会作为响应的一部分返回到用户浏览器中。接下来从该用户浏览器中发出的请求都将通过某种方式包含该会话ID。当应用程序收到含有会话ID的请求时,它可以通过该ID将现有会话与当前请求关联起来。

了解会话cookie

HTTP_cookie

cookie是一种必要的通信机制,可以通过Set-Cookie响应头在服务器和浏览器之间传递任意的数据,并存储在用户计算机中,然后再通过请求头Cookie从浏览器返回到服务器中。

https://en.wikipedia.org/wiki/HTTP_cookie#Setting_a_cookie

Request 1
  GET /support HTTP/1.1
  Host: www.example.com
Response 1
  HTTP/1.1 302 Moved Temporarily
  Location: https://www.example.com/support/login
  Set-Cookie: JSESSIONID=NRxclGg2vG7kI4MdlLn; Domain=.example.com; Path=/; HttpOnly
Request 2
  GET /support/login HTTP/1.1
  Host: www.example.com
  Cookie: JSESSIONID=NRxclGg2vG7kI4MdlLn
Response 2
  HTTP/1.1 200 OK
  Content-Type: text/html;charset=UTF-8
  Content-Length: 21765
Request 3
  POST /support/login HTTP/1.1
  Host: www.example.com
  Cookie: JSESSIONID=NRxclGg2vG7kI4MdlLn
Response 3
  HTTP/1.1 302 Moved Temporarily
  Location: http://www.example.com/support/home
  Set-Cookie: remusername=Nick; Expires=Wed, 02-Jun-2021 12:15:47 GMT;
  Domain=.example.com; Path=/; HttpOnly
Request 4
  GET /support/home HTTP/1.1
  Host: www.example.com
  Cookie: JSESSIONID=NRxclGg2vG7kI4MdlLn; remusername=Nick
Response 4
  HTTP/1.1 200 OK
  Content-Type: text/html;charset=UTF-8
  Content-Length: 56823

使用cookie传输会话ID的问题是:用户可以在浏览器中禁止对cookie的支持,这样将完全禁止这种传输会话ID的方式。

URL中的会话ID

另一种传输会话ID的流行方式是通过URL。

Web或应用服务器知道如何超找URL中包含了会话ID的特定模式,如果找到了,就从URL中获得会话。

不同的技术对如何在URL中内嵌和定位会话ID使用不同的策略。

Java EE应用程序的会话ID被添加到URL的最后一个路径段的矩阵参数中。通过这种方式分离开会话ID和查询字符串的参数,使它们不会互相冲突。

http://www.example.com/support;JSESSIONID=NRxclGg2vG7kI4MdlLn?foo=bar&high=five

将会话ID内嵌在URL中,可以避免使用cookie。不过,你可能会好奇第一次如何将请求URL中的会话ID发送到浏览器。请求URL只在将会话ID从浏览器发送到服务器时有效。所以会话ID是如何产生的呢?答案是必须将会话ID内嵌在应用程序返回的所有URL中,包含页面的链接、表单操作以及302重定向。

Request 1
  GET /support HTTP/1.1
  Host: www.example.com
Response 1
  HTTP/1.1 302 Moved Temporarily
  Location: https://www.example.com/support/login;JSESSIONID=NRxclGg2vG7kI4MdlLn
Request 2
  GET /support/login;JSESSIONID=NRxclGg2vG7kI4MdlLn HTTP/1.1
  Host: www.example.com
Response 2
  HTTP/1.1 200 OK
  Content-Type: text/html;charset=UTF-8
  Content-Length: 21796
  ...
  <form action="http://www.example.com/support/login;JSESSIONID=NRxclGg2vG7kI4MdlLn"
  method="post">
  ...
Request 3
  POST /support/login;JSESSIONID=NRxclGg2vG7kI4MdlLn HTTP/1.1
  Host: www.example.com
Response 3
  HTTP/1.1 302 Moved Temporarily
  Location: http://www.example.com/support/home;JSESSIONID=NRxclGg2vG7kI4MdlLn
Request 4
  GET /support/home;JSESSIONID=NRxclGg2vG7kI4MdlLn HTTP/1.1
  Host: www.example.com
Response 4
  HTTP/1.1 200 OK
  Content-Type: text/html;charset=UTF-8
  Content-Length: 56854
  ...
  <a href="http://www.example.com/support/somewhere;JSESSIONID=NRxclGg2vG7kI4MdlLn">
  ...

HttpServletResponse接口定义了两个可以重写URL的方法encodeUrlencodeRedirectURL,它们会在必要时把会话ID内嵌在URL中。

任何在链接、表单操作或其他标签中的URL都会被传入到encodeUrl方法中,然后该方法将返回一个正确的、经过编码处理的URL。

任何传入sendRedirect响应方法中的URL也可以传入encodeRedirectURL方法中,该方法将返回一个正确的、经过编码处理的URL。

会话的漏洞

1.复制并粘贴错误

影响应用程序的最简单方式之一就是:不知情的用户将浏览器中的URL复制粘贴到了邮件、论坛、聊天室或其他公共区域中。在URL中内嵌会话的方式正式引起此类问题的根源。还记得在客户端和服务器端反复发送的URL吗?这些URL、会话ID和所有的信息都显示在客户端浏览器的地址栏中。如果用户决定要跟朋友分享应用程序中的某个页面,并将地址栏中的URL复制粘贴出来,那么他的朋友将可以看到URL中包含的会话ID。如果他们在该会话终止之前访问该URL,那么他们也会被当成之前分享URL的用户。这明显会引起问题,因为该用户的朋友可能会不小心看到他的个人信息。

更危险的问题是如果恶意用户发现了该链接,可以使用劫持用户的会话。他将可以修改账户邮件地址、获取密码重置链接并最终修改密码-获得用户账户的所有权限。

产生问题的原因是无意操作-用户复制粘贴地址栏中的URL-解决此问题的唯一正确方法就是完全禁止在URL中内嵌会话ID。尽管这听起来像是一个严厉的错误,并且有可能对应用程序的可用性产生灾难性的影响,但事实上之前提到过,许多重要的网络公司都要求用户在访问他们的网站时使用cookie,因此cookie已经成为事实上的通用解决方案。cookie在Web用户中应用十分广泛,比起在URL中内嵌会话ID引起的问题,cookie固有的风险并不那么常见,危险性也较小。

2.会话固定

会话固定攻击类似于复制粘贴错误,不过其中的不知情用户变成了攻击者,而被攻击的用户则使用了含有会话的URL。攻击者可能会首先找到一些允许在URL中内嵌会话ID的网站。攻击者将通过这种方式获得一个会话ID,然后将含有会话ID的URL发送给目标用户。此时,当用户点击链接进入网站是,它的会话ID就变成了URL中含有的固定ID-攻击者已经持有该ID。如果用户接着在该回话期间登录网站,那么攻击者也可以登陆成功,因为这个会话ID是他分享的,因此他也可以访问用户的账户。

有两种方式可以解决这个问题:

  • 如同复制粘贴错误一样,可以通过禁止在URL中内嵌ID的方式避免,同时也需要在应用程序中禁止接受通过URL传递的会话ID。
  • 在登录后采用会话迁移。当用户登录时,修改会话ID或者将会话详细信息复制到新的会话中,并使之前的会话无效。

3.跨站脚本和会话劫持

之前提到了复制粘贴错误,当恶意的第三方利用这个问题是,就变成了会话固定攻击。还有另外一种形式的会话劫持,它将利用JavaScript读取会话cookie的内容。攻击者将利用网站的漏洞实行跨站脚本攻击,将JavaScript注入某个页面,使用JavaScript DOM属性document.cookie读取会话ID cookie中的内容。在攻击者从不知情用户处获得会话ID之后,他可以通过在自己的计算机中创建cookie模拟改会话,或者使用URL嵌入模拟受害者的身份。

防止此类攻击的最明显的方法就是不要再网站中使用跨站脚本。保护网站避免此类攻击还有李毅中方式,那就是在所有cookie中使用HttpOnly特性。通过设置该特性,cookie将至可被用在浏览器创建的HTTP(或HTTPS)请求中,无论请求是由链接创建,还是通过在地址栏手动输入URL、表单提交或AJAX请求。更重要的是,HttpOnly完全禁止了JavaScript,Flash或其他浏览器脚本以及插件获取cookie内容的能力。这将阻止跨站脚本会话劫持攻击的发生。会话ID cookie中总是应该包含HttpOnly特性。

4.不安全的cookie

最后需要考虑的一个漏洞是中间人攻击(MitM攻击),这是典型的数据截获攻击,攻击者通过观察客户端和服务器端交互的请求或响应,从中获取信息。这种类型的攻击促进了安全套接字层和传输层安全(SSL/TLS)的发展,他们是HTTPS协议的基础。使用HTTPS保护网络通信将有效地防止MitM攻击,并保护会话ID cookie不被盗用。

cookie的Secure标志专门用于解决这个问题。当服务器将会话ID通过响应返回到客户端时,它将设置Secure标志。该标志告诉浏览器只应该通过HTTPS传输cookie。从现在开始,cookie只以加密的方式传输,攻击者将无法拦截它。这种方式的缺点是网站必须一直使用HTTPS。否则,一旦将用户重定向至HTTP,浏览器将不再传输cookie并且会话也会丢失。

 

在会话中存储数据

首先在web.xml中添加:

<jsp-config>
    <jsp-property-group>
    <url-pattern>*.jsp</url-pattern>
    <url-pattern>*.jspf</url-pattern>
    <page-encoding>UTF-8</page-encoding>
    <scripting-invalid>false</scripting-invalid>
    <include-prelude>/WEB-INF/jsp/base.jspf</include-prelude>
    <trim-directive-whitespaces>true</trim-directive-whitespaces>
    <default-content-type>text/html</default-content-type>
    </jsp-property-group>
</jsp-config>

 

然后增加以下文件/WEB-INF/jsp/base.jspf

<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>

 

另外,在Web根目录中增加一个简单的index.jsp文件,用于将请求重定向值商店Servlet。

<c:redirect url="/shop" />

 

1.在部署描述符中配置会话

可以再部署描述符中配置HTTP会话,并且处于安全目的也应该配置。

在部署描述符中使用<session-config>标签配置会话。

https://www.owasp.org/index.php/HttpOnly

<session-config>
    <session-timeout>30</session-timeout>
    <cookie-config>
        <name>JSESSIONID</name>
        <domain>example.org</domain>
        <path>/shop</path>
        <comment><![CDATA[Keeps you logged in. See our privacy policy for
        more information.]]></comment> 
        <http-only>true</http-only> 
        <secure>false</secure> 
        <max-age>1800</max-age>
    </cookie-config> 
    <tracking-mode>COOKIE</tracking-mode>
    <tracking-mode>URL</tracking-mode>
    <tracking-mode>SSL</tracking-mode> 
</session-config>
session-config示例

 

URL-容器将只在URL中内嵌会话ID。不使用cookie或SSL会话ID。

COOKIE-容器将使用会话cookie追踪会话ID。

SSL-容器将使用SSL会话ID作为HTTP会话ID。要求使用的所有请求都必须是HTTPS请求。

可以为<tracking-mode>配置多个值,表示容器可以使用多种策略。例如,如果同时指定了COOKIE和URL,容器将优先使用cookie,但如果cookie不可用,那么容器将会使用URL。如果启用了SSL追踪模式,那么就不能使用COOKIE和URL模式。

只有在追踪模式中使用了COOKIE时,才可以使用<cookie-config>标签。

<name>可以自定义会话cookie的名字。默认值是JSESSIONID。

<domain>和<path>对应着cookie的Domain和Path特性。

<comment>将在会话ID cookie中添加Comment特性,在其中可以添加任意文本。这通常用于解释cookie的目的,并告知用户网站的隐私政策。

<http-only>和<secure>对应着cookie的HttpOnly和Secure特性。默认值都是false。应该一直将<http-only>设置为true。如果使用了HTTPS,也应该将<secure>设置为true。

<max-age>指定了cookie的Max-Age特性,用于控制cookie何时过期。

了解了可用的选项后,按照下面的XML配置设置项目:

<session-config>
    <session-timeout>30</session-timeout>
    <cookie-config>
        <http-only>true</http-only>
    </cookie-config>
    <tracking-mode>COOKIE</tracking-mode>
</session-config>    

 

使用了该配置的应用程序,会话超市事件将被设置为30分钟,并且只使用cookie用于会话追踪,在会话cookie中也将包含HttpOnly特性用于解决安全问题。它将接受所有其他的默认值,并且不再cookie中指定comment特性。

2.存储和获取数据

2.1 在Servlet中使用会话

package com.wrox;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.util.Hashtable;
import java.util.Map;

@WebServlet(
        name = "storeServlet",
        urlPatterns = "/shop"
)
public class StoreServlet extends HttpServlet
{
    private final Map<Integer, String> products = new Hashtable<>();

    public StoreServlet()
    {
        this.products.put(1, "Sandpaper");
        this.products.put(2, "Nails");
        this.products.put(3, "Glue");
        this.products.put(4, "Paint");
        this.products.put(5, "Tape");
    }

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException
    {
        String action = request.getParameter("action");
        if(action == null)
            action = "browse";

        switch(action)
        {
            case "addToCart":
                this.addToCart(request, response);
                break;

            case "emptyCart":
                this.emptyCart(request, response);
                break;

            case "viewCart":
                this.viewCart(request, response);
                break;

            case "browse":
            default:
                this.browse(request, response);
                break;
        }
    }

    private void addToCart(HttpServletRequest request,
                           HttpServletResponse response)
            throws ServletException, IOException
    {
        int productId;
        try
        {
            productId = Integer.parseInt(request.getParameter("productId"));
        }
        catch(Exception e)
        {
            response.sendRedirect("shop");
            return;
        }

        HttpSession session = request.getSession();
        if(session.getAttribute("cart") == null)
            session.setAttribute("cart", new Hashtable<Integer, Integer>());

        @SuppressWarnings("unchecked")
        Map<Integer, Integer> cart =
                (Map<Integer, Integer>)session.getAttribute("cart");
        if(!cart.containsKey(productId))
            cart.put(productId, 0);
        cart.put(productId, cart.get(productId) + 1);

        response.sendRedirect("shop?action=viewCart");
    }

    private void emptyCart(HttpServletRequest request,
                           HttpServletResponse response)
            throws ServletException, IOException
    {
        request.getSession().removeAttribute("cart");
        response.sendRedirect("shop?action=viewCart");
    }

    private void viewCart(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException
    {
        request.setAttribute("products", this.products);
        request.getRequestDispatcher("/WEB-INF/jsp/view/viewCart.jsp")
                .forward(request, response);
    }

    private void browse(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException
    {
        request.setAttribute("products", this.products);
        request.getRequestDispatcher("/WEB-INF/jsp/view/browse.jsp")
               .forward(request, response);
    }
}

 

粗体代码调用了getSession获取会话,getAttribute返回会话中存储的对象。setAttribute将对象绑定到会话中。

2.2 在JSP中使用会话

<%@ page import="java.util.Map" %>
<!DOCTYPE html>
<html>
    <head>
        <title>View Cart</title>
    </head>
    <body>
        <h2>View Cart</h2>
        <a href="<c:url value="/shop" />">Product List</a><br /><br />
        <a href="<c:url value="/shop?action=emptyCart" />">Empty Cart</a><br /><br />
        <%
            @SuppressWarnings("unchecked")
            Map<Integer, String> products =
                    (Map<Integer, String>)request.getAttribute("products");
            @SuppressWarnings("unchecked")
            Map<Integer, Integer> cart =
                    (Map<Integer, Integer>)session.getAttribute("cart");

            if(cart == null || cart.size() == 0)
                out.println("Your cart is empty.");
            else
            {
                for(int id : cart.keySet())
                {
                    out.println(products.get(id) + " (qty: " + cart.get(id) +
                            ")<br />");
                }
            }
        %>
    </body>
</html>

 

JSP使用隐式的session变量。

3.删除数据

private void emptyCart(HttpServletRequest request, HttpServletResponse response)
        throws ServletException, IOException
{
    request.getSession().removeAttribute("cart");
    response.sendRedirect("shop?action=viewCart");
}    

 

HttpSession最重要的方法之一就是invalidate方法。当用户注销时需要调用该方法。方法invalidate将销毁会话并解除所有绑定到会话的数据。即使客户浏览器使用相同的会话ID发起了另一个请求,已经无效的会话也不能再使用。相反,新的会话将被创建,并且响应中将包含新的会话ID。

4.在绘画中存储更复杂的数据

import java.io.Serializable;
import java.net.InetAddress;

public class PageVisit implements Serializable
{
    private long enteredTimestamp;

    private Long leftTimestamp;

    private String request;

    private InetAddress ipAddress;
}

 

package com.wrox;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.Vector;

@WebServlet(
        name = "storeServlet",
        urlPatterns = "/do/*"
)
public class ActivityServlet extends HttpServlet
{
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException
    {
        this.recordSessionActivity(request);

        this.viewSessionActivity(request, response);
    }

    private void recordSessionActivity(HttpServletRequest request)
    {
        HttpSession session = request.getSession();

        if(session.getAttribute("activity") == null)
            session.setAttribute("activity", new Vector<PageVisit>());
        @SuppressWarnings("unchecked")
        Vector<PageVisit> visits =
                (Vector<PageVisit>)session.getAttribute("activity");

        if(!visits.isEmpty())
        {
            PageVisit last = visits.lastElement();
            last.setLeftTimestamp(System.currentTimeMillis());
        }

        PageVisit now = new PageVisit();
        now.setEnteredTimestamp(System.currentTimeMillis());
        if(request.getQueryString() == null)
            now.setRequest(request.getRequestURL().toString());
        else
            now.setRequest(request.getRequestURL()+"?"+request.getQueryString());
        try
        {
            now.setIpAddress(InetAddress.getByName(request.getRemoteAddr()));
        }
        catch (UnknownHostException e)
        {
            e.printStackTrace();
        }
        visits.add(now);
    }

    private void viewSessionActivity(HttpServletRequest request,
                                     HttpServletResponse response)
            throws ServletException, IOException
    {
        request.getRequestDispatcher("/WEB-INF/jsp/view/viewSessionActivity.jsp")
               .forward(request, response);
    }
}

 

<%@ page import="java.util.Vector, com.wrox.PageVisit, java.util.Date" %>
<%@ page import="java.text.SimpleDateFormat" %>
<%!
    private static String toString(long timeInterval)
    {
        if(timeInterval < 1_000)
            return "less than one second";
        if(timeInterval < 60_000)
            return (timeInterval / 1_000) + " seconds";
        return "about " + (timeInterval / 60_000) + " minutes";
    }
%>
<%
    SimpleDateFormat f = new SimpleDateFormat("EEE, d MMM yyyy HH:mm:ss Z");
%>
<!DOCTYPE html>
<html>
    <head>
        <title>Session Activity Tracker</title>
    </head>
    <body>
        <h2>Session Properties</h2>
        Session ID: <%= session.getId() %><br />
        Session is new: <%= session.isNew() %><br />
        Session created: <%= f.format(new Date(session.getCreationTime()))%><br />

        <h2>Page Activity This Session</h2>
        <%
            @SuppressWarnings("unchecked")
            Vector<PageVisit> visits =
                    (Vector<PageVisit>)session.getAttribute("activity");

            for(PageVisit visit : visits)
            {
                out.print(visit.getRequest());
                if(visit.getIpAddress() != null)
                    out.print(" from IP " + visit.getIpAddress().getHostAddress());
                out.print(" (" + f.format(new Date(visit.getEnteredTimestamp())));
                if(visit.getLeftTimestamp() != null)
                {
                    out.print(", stayed for " + toString(
                            visit.getLeftTimestamp() - visit.getEnteredTimestamp()
                    ));
                }
                out.println(")<br />");
            }
        %>
    </body>
</html>

 

 
 
使用会话
package com.wrox;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.util.Hashtable;
import java.util.Map;

@WebServlet(
        name = "loginServlet",
        urlPatterns = "/login"
)
public class LoginServlet extends HttpServlet
{
    private static final Map<String, String> userDatabase = new Hashtable<>();

    static {
        userDatabase.put("Nicholas", "password");
        userDatabase.put("Sarah", "drowssap");
        userDatabase.put("Mike", "wordpass");
        userDatabase.put("John", "green");
    }

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException
    {
        HttpSession session = request.getSession();
        if(request.getParameter("logout") != null)
        {
            session.invalidate();
            response.sendRedirect("login");
            return;
        }
        else if(session.getAttribute("username") != null)
        {
            response.sendRedirect("tickets");
            return;
        }

        request.setAttribute("loginFailed", false);
        request.getRequestDispatcher("/WEB-INF/jsp/view/login.jsp")
               .forward(request, response);
    }

    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException
    {
        HttpSession session = request.getSession();
        if(session.getAttribute("username") != null)
        {
            response.sendRedirect("tickets");
            return;
        }

        String username = request.getParameter("username");
        String password = request.getParameter("password");
        if(username == null || password == null ||
                !LoginServlet.userDatabase.containsKey(username) ||
                !password.equals(LoginServlet.userDatabase.get(username)))
        {
            request.setAttribute("loginFailed", true);
            request.getRequestDispatcher("/WEB-INF/jsp/view/login.jsp")
                   .forward(request, response);
        }
        else
        {
            session.setAttribute("username", username);
            request.changeSessionId();
            response.sendRedirect("tickets");
        }
    }
}
LoginServlet.java

数据库只是一个简单的map,包含了用户名和密码,并未使用其他任何权限级别。用户要么可以访问系统,要么不能,并且密码也未以安全的方式存储。

方法doGet负责显示登录界面。该代码第一件工作就是检查用户是否已经登录,如果已经登录,就将他们重定向至票据页面。如果未登录,即将请求特性loginFailed设置为false,然后将请求转发至登录JSP。当JSP中的登录表单被提交时,请求将被发送到doPost方法。

doPost方法再次验证用户是否已经登录,并检查用户名和密码是否与数据库中存储的一致。如果登录失败,就将请求中的loginFailed设置为true,并将请求转发至登录JSP。如果用户名和密码都正确,那么在绘画中添加username特性,修改会话ID,然后将用户重定向至票据页面。

changeSessionId可以通过迁移会话的方式(修改会话ID)应付之前提到的会话固定攻击。

<!DOCTYPE html>
<html>
    <head>
        <title>Customer Support</title>
    </head>
    <body>
        <h2>Login</h2>
        You must log in to access the customer support site.<br /><br />
        <%
            if(((Boolean)request.getAttribute("loginFailed")))
            {
                %>
        <b>The username or password you entered are not correct. Please try
            again.</b><br /><br />
                <%
            }
        %>
        <form method="POST" action="<c:url value="/login" />">
            Username<br />
            <input type="text" name="username" /><br /><br />
            Password<br />
            <input type="password" name="password" /><br /><br />
            <input type="submit" value="Log In" />
        </form>
    </body>
</html>
login.jsp

 

 
添加注销链接
<a href="<c:url value="login?logout" />">Logout</a>

增加以上代码后,页面将会出现注销链接。单击链接,页面将被重新返回到登录页面,表示已经成功注销。

使用监听器检测会话的变化
package com.wrox;

import javax.servlet.annotation.WebListener;
import javax.servlet.http.HttpSessionEvent;
import javax.servlet.http.HttpSessionIdListener;
import javax.servlet.http.HttpSessionListener;
import java.text.SimpleDateFormat;
import java.util.Date;

@WebListener
public class SessionListener implements HttpSessionListener, HttpSessionIdListener
{
    private SimpleDateFormat formatter =
            new SimpleDateFormat("EEE, d MMM yyyy HH:mm:ss");

    //sessionCreated将在创建新的会话时调用
    @Override
    public void sessionCreated(HttpSessionEvent e)
    {
        System.out.println(this.date() + ": Session " + e.getSession().getId() + " created.");
        SessionRegistry.addSession(e.getSession());
    }

    //sersionDestroyed将在会话无效时调用。
    //引起会话无效的原因可能是在代码中调用了会话的invalidate方法,也可能是由不活跃状态超时引起的隐式失效
    @Override
    public void sessionDestroyed(HttpSessionEvent e)
    {
        System.out.println(this.date() + ": Session " + e.getSession().getId() + " destroyed.");
        SessionRegistry.removeSession(e.getSession());
    }

    //当使用请求的changeSessionId方法改变会话ID时将会调用该方法。
    @Override
    public void sessionIdChanged(HttpSessionEvent e, String oldSessionId)
    {
        System.out.println(this.date() + ": Session ID " + oldSessionId + " changed to " + e.getSession().getId());
        SessionRegistry.updateSessionId(e.getSession(), oldSessionId);
    }

    private String date()
    {
        return this.formatter.format(new Date());
    }
}

现在编译、调试并访问应用程序。调试窗口中将立即出现一条日志消息显示已经创建了一个会话。登录到应用程序中,调式窗口将会出现另一条会话ID改变的日志消息。这是之前添加的代码,用于防止固定会话攻击。最后在退出应用程序时,调试窗口将会出现两条日志-一条表示会话已经销毁,另一条表示创建了新的会话(因为回到了登录页面)。

维持活跃会话列表

可以使用HttpSessionListenerHttpSessionIdListener在应用程序中维护一个活跃会话列表。

首先可以创建SessionRegistry类

package com.wrox;

import javax.servlet.http.HttpSession;
import java.util.ArrayList;
import java.util.Hashtable;
import java.util.List;
import java.util.Map;

public final class SessionRegistry
{
    private static final Map<String, HttpSession> SESSIONS = new Hashtable<>();

    public static void addSession(HttpSession session)
    {
        SESSIONS.put(session.getId(), session);
    }

    public static void updateSessionId(HttpSession session, String oldSessionId)
    {
        synchronized(SESSIONS)
        {
            SESSIONS.remove(oldSessionId);
            addSession(session);
        }
    }

    public static void removeSession(HttpSession session)
    {
        SESSIONS.remove(session.getId());
    }

    public static List<HttpSession> getAllSessions()
    {
        return new ArrayList<>(SESSIONS.values());
    }

    public static int getNumberOfSessions()
    {
        return SESSIONS.size();
    }

    private SessionRegistry()
    {

    }
}
SessionRegistry

该注册表保存了所有活跃会话的引用,但必须通过其他方式添加和删除会话。查看之前SessionListener中SessionRegistry相关代码。

 

将使用会话的应用程序集群化

在集群中使用会话ID

使用集群时立即会遇到的问题就是:会话以对象的方式存在于内存中,并且只存在于Web容器的单个实例中。在只能负载均衡的场景中,来自同一个客户端的两个连续请求将会访问不同的Web容器。第一个Web容器将会为它收到的第一个请求分配会话ID,然后第二个请求将会由另一个Web容器示例处理,第二个实例无法识别其中的会话ID,因此将重新创建并分配一个会话ID,此时会话就变得无用了。

解决该问题的方法是使用粘滞会话。粘滞会话的概念是:是负载均衡机制能够感知到会话,并且总是将来自于统一会话的请求发送到相同的服务器。

Tomcat环境中最常见的负载均衡方式是:使用Apache HTTPD或者Microsoft IIS Web服务器在Apache Tomcat势力之间处理服务期间的负载均衡请求。

使用粘滞会话的主要问题是,它可以支持扩展性,但不支持高可用性。如果创建特定会话的Tomcat实例终止服务,那么该会话将丢失,并且用户需要重新登录。更糟糕的时,用户可能会丢失未保存的工作。出于这个目的,会话可以再整个集群中复制,因此无论会话产生于哪个实例,它们对所有的Web容器实例都是可用的。在应用程序中启用会话复制是很简单的。只要在部署描述符中添加<distributable>标签即可。

该配置指示是应用程序支持分布式会话。并未在Web容器中配置会话复制机制。它也不意味着应用程序回自动遵守最佳实践。必须小心会话特性的设置以及如何更新那些会话特性。

@SuppressWarnings("unchecked") 
Map<Integer, Integer> cart = (Map<Integer, Integer>)session.getAttribute("cart"); 
if(!cart.containsKey(productId))
    cart.put(productId, 0); 
cart.put(productId, cart.get(productId) + 1);

 

对于上面的代码,Web容器无法得知会话中的Map已经发生了变化。因此,对会话的修改并未得到复制,这意味着其他实例容器无法得知购物车中的新产品。

可以通过以下方式解决:

@SuppressWarnings("unchecked") 
Map<Integer, Integer> cart = (Map<Integer, Integer>)session.getAttribute("cart"); 
if(!cart.containsKey(productId))
    cart.put(productId, 0); 
cart.put(productId, cart.get(productId) + 1);
session.setAttribute("cart", cart);

 

看起来很傻,因为只是使用同一对象替换了之前的cart会话特性。不过调用该方法将告诉容器会话发生了变化,需要将它复制到其他容器中。

任何时候对会话特性对象的修改,都需要重新调用setAttribute设置它,从而保证修改被复制到其他容器中。

任何添加到会话中的特性对象都可以实现HttpSessionActivationListener接口。

当会话被序列化并发送到其他服务器时,sessionWillPassivate方法将被调用,给绑定到会话的对象首先执行某些操作的机会。

当回话在另一个容器中反序列化是,sessionDidActivate方法将被调用,通知特性它已经被反序列化。

 

Chapter 5
Updated on 9/4/14.
128.73 KB Click to Download

 

 

 

 

摘录自:[美]Nicholas S.Williams著,王肖峰译 Java Web高级编程 [M]、清华大学出版社,2015、93-127、

 

posted @ 2016-11-16 22:07  guqiangjs  阅读(202)  评论(0)    收藏  举报