65.9K
CodeProject 正在变化。 阅读更多。
Home

AngularJS 安全会话超时管理教程

starIconstarIconstarIconstarIconstarIcon

5.00/5 (1投票)

2021年6月22日

MIT

17分钟阅读

viewsIcon

11246

downloadIcon

68

在本教程中,我将讨论如何在 AngularJS 应用程序中处理安全的会话超时。

引言

设想一个场景,用户通过基于表单的身份验证登录了一个基于 Web 的应用程序。然后两个小时用户什么都没做。当用户决定恢复其活动时,长时间的不活动导致会话超时。在这种情况下,无论如何都应该显示登录页面。我自己的项目(Spring Boot、Spring Security、Spring MVC 和 Spring REST)的挑战在于,我将 MVC 代码和 RESTFul API 代码混合在一起,这会给正确处理会话超时带来一个小问题,尤其是对于 AngularJS 调用 RESTFul API 的会话超时处理。

让我解释一下为什么会发生这种情况。当您在浏览器端的 F12 开发工具中检查网络流量时,您会认为会话超时会导致 403 响应,前端 API 调用可以知道超时发生了。事实并非如此。相反,您会看到服务返回 302,重定向到登录页面,然后登录页面的内容被发送回前端。最后,前端收到一个 HTML 页面,状态码为 200。前端期望的是一个有意义的对象或数据数组,但它收到的是一个 HTML 页面。在这种情况下,我将其处理为未知错误。这并不理想。正确的方法是识别会话超时并强制浏览器显示登录页面。

“这似乎是一个配置问题。不是吗?”有人可能会问。你是对的。这是 Spring Security 的一个配置问题。这个问题最棘手的地方在于,MVC 处理和 REST API 处理被组合在一个应用程序中。如果我将两者分开,前端应用程序使用 Spring MVC,后端数据处理仅使用 Spring REST,我就不会担心配置问题。但它会带来其他同样复杂的问题。所以对于我自己的项目,目前我仍然将两者合并在同一个应用程序中。本教程将讨论区分同一应用程序中 MVC 和 RESTFul API 会话超时处理所需的其他配置更改。

还有两个概念我将讨论,一个是为 Web 应用程序设置会话超时的配置。而不是默认的 30 分钟,我可以将其设置为 90 秒、90 分钟或任何其他合理的数字。另一个我想讨论的概念是解决另一个小问题的简单方法。我注意到当用户登录后,然后导航回登录页面,登录页面会再次显示并允许用户再次登录。这是一个令人讨厌的场景,可以通过检查用户是否已登录来避免。如果是,则浏览器将重定向到索引页面。我将从示例应用程序的概述开始,然后是应用程序设计的详细信息。

应用程序架构

此示例应用程序是一个单页应用程序。要访问此页面,用户必须先登录。我重复使用了我过去教程中的许多材料,使用 ThymeLeaf 页面模板引擎与 Spring Security。我需要 Spring Security 的配置,以便可以使用登录页面进行安全访问。用户登录后,索引页面将有一个从 60 开始的计数器。每 3 秒,计数器将减少 1,直到达到 0。还有一个按钮。单击此按钮时,客户端将调用一个简单的 RESTFul 服务。此服务仅在用户登录后才能调用。单击按钮将导致浏览器重定向到登录页面。但是,只要在计数器达到 29 之前单击按钮,计数器就会重置为 60。同样,如果用户在计数器大于 29 时导航到登录页面,浏览器将重定向到登录页面。

我在此应用程序中使用了 Spring Boot。所有页面都由 Spring Boot 和 Spring MVC 提供。有一个简单的服务端点,提供一个简单的心跳消息(客户端发送一个虚拟请求,服务器返回一个简单的消息作为确认)。该页面是一个单页应用程序。用户登录后才能查看。我需要对 Spring Security 添加一些额外的配置,以区分会话超时的处理,如下所示:

  • 如果操作是加载页面,则会话过期时应导致浏览器显示登录页面。
  • 如果操作是执行到后端服务器的 RESTFul API 调用,则会话超时应创建状态码为 403 的响应和一个包含有意义信息的 JSON 对象。

我之前的问题是,没有这个额外的配置,无论对服务器的操作是什么,默认的会话过期处理都是将会话过期检测为安全异常,然后提供默认的 302 重定向到登录页面的操作。对于前端 AngularJS 代码,RESTFul API 调用将获得一个网页而不是有意义且可识别的对象。这就是为什么我必须以某种方式区分 MVC 页面和 RESTFul API 的会话过期处理。下一节,我将向您展示如何做到这一点。这是本教程最重要的方面的第一部分。

重用组件的简要回顾

本节将介绍我在上一篇教程中使用的一些重要组件。如果读者不想先阅读其他教程,我将提供简要回顾。在本节中,我将回顾 Spring Security 配置和简单的用户身份验证服务。

首先,这是 Spring Security 的配置

package org.hanbo.boot.app.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.
       builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.
       configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.
       WebSecurityConfigurerAdapter;
import org.springframework.security.web.authentication.
       SavedRequestAwareAuthenticationSuccessHandler;
import org.hanbo.boot.app.security.UserAuthenticationService;

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
public class WebAppSecurityConfig extends WebSecurityConfigurerAdapter
{
   @Autowired
   private UserAuthenticationService authenticationProvider;
   
   @Autowired
   private MyAuthenticationEntryPoint accessDeniedHandler;
   
   @Override
   protected void configure(HttpSecurity http) throws Exception
   {
      http.csrf().disable()
      .authorizeRequests()
          .antMatchers("/assets/**", "/public/**").permitAll()
          .anyRequest().authenticated()
      .and()
      .formLogin()
          .loginPage("/login")
          .permitAll()
          .usernameParameter("username")
          .passwordParameter("userpass")
          .successHandler(new SavedRequestAwareAuthenticationSuccessHandler())
          .defaultSuccessUrl("/secure/index", true).failureUrl("/public/authFailed")
          .and()
      .logout().logoutSuccessUrl("/public/logout")
          .permitAll()
          .and()
          .exceptionHandling()
          .authenticationEntryPoint(accessDeniedHandler);
   }
   
   @Override
   protected void configure(AuthenticationManagerBuilder authMgrBuilder)
      throws Exception
   {
      authMgrBuilder.authenticationProvider(authenticationProvider);
   }
} 

我使用粗体字体来突出显示我修改过的部分。这一行与上一教程的源代码不同。在这种情况下,我正在捕获与 Spring Security 相关的异常(通过调用 .exceptionHandling()),然后指定我想自定义会话过期的处理(通过调用 .authenticationEntryPoint(accessDeniedHandler))。这是本教程的关键点,登录调用的处理可以自定义。在这里,我指定当会话不存在或会话过期时显示登录页面,或者返回一个显示访问被拒绝的 JSON 对象。该调用需要一个 AuthenticationEntryPoint 类型的参数。我已经提供了我的实现,这是下一个重要点。

这是我对 AuthenticationEntryPoint 接口的实现

package org.hanbo.boot.app.config;

import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Date;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;

@Component
public class MyAuthenticationEntryPoint
   implements AuthenticationEntryPoint
{
   @Override
   public void commence(HttpServletRequest request, 
                        HttpServletResponse response, AuthenticationException ex)
         throws IOException, ServletException
   {
      String url = request.getRequestURI();
      System.out.println(url);
      
      if (url != null && url.startsWith("/secure/api/"))
      {
         String jsonVal = createJsonResponse(false, "Access Denied");
         
         response.setContentType("application/json;charset=UTF-8");
         response.setStatus(HttpServletResponse.SC_FORBIDDEN);
         response.getWriter().write(jsonVal);
         response.getWriter().flush();
         response.getWriter().close();
      }
      else
      {
         response.sendRedirect("/login");
      }
   }
   
   private static String createJsonResponse(boolean isSuccess, String msgVal) 
           throws JsonProcessingException
   {
      ObjectMapper mapper = new ObjectMapper();
      ObjectNode rootNode = mapper.createObjectNode();
      
      Date dateNow = new Date();
      SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
      String dateNowVal = dateFormat.format(dateNow);
      
      rootNode.put("success", isSuccess);
      rootNode.put("timestamp", dateNowVal);
      rootNode.put("message", msgVal);
      String retVal = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(rootNode);
      
      return retVal;
   }
}

实现 AuthenticationEntryPoint 是为了解决一个问题,对于某些 URL,尤其是页面显示,在会话过期后调用时,必须显示登录页面。但对于任何 RESTFul API URL,在会话过期时调用这些 URL,应该只收到一个状态码为 403 的错误响应和一个详细说明错误的 JSON 对象。我尝试了所有方法来使用 HttpSecurity 对象为不同的 URL 分开这两种处理。当 RESTFul API 方法和 MVC 处理方法都混合在一起时,这是无法完成的。所以我想到一个技巧来解决这个问题。在上面的实现中,commence() 方法接受 HttpServletRequest 对象和 HttpServletResponse 对象,这意味着我可以确定请求 URI 是什么,并在响应中做出不同的决定。

以下是我基于请求 URI 模式做出决定的方法

...
String url = request.getRequestURI();
System.out.println(url);

if (url != null && url.startsWith("/secure/api/"))
{
...
}
else
{
...
}
...AuthenticationEntryPoint

我使用 HttpServletRequest 对象获取 URI (.getRequestURI()),它是一个 string。然后我检查 string 是否为 NULL。如果不是 null,我检查 string 是否以前缀 "/secure/api/" 开头。如果此前缀是 string 的一部分,那么我将返回一个错误代码为 403 的响应和一个描述错误的 JSON 响应,即

String jsonVal = createJsonResponse(false, "Access Denied");

response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.getWriter().write(jsonVal);
response.getWriter().flush();
response.getWriter().close();

如果请求 URI 没有前缀,那么我将执行重定向以返回登录页面,操作方式如下:

response.sendRedirect("/login");

我还提供了一个用于创建 JSON 对象的辅助方法,您可以在上面看到。我在这里不再列出。这只是整个应用程序的一部分。这是用于显示公共可访问页面的 MVC 控制器,例如登录页面、注销页面、访问错误页面

package org.hanbo.boot.app.controllers;

import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.servlet.ModelAndView;

@Controller
public class LoginController
{
   @RequestMapping(value="/login", method = RequestMethod.GET)
   public ModelAndView login()
   {      
      boolean isLoggedIn = SecurityContextHolder.getContext().getAuthentication() != null &&
            SecurityContextHolder.getContext().getAuthentication().isAuthenticated() &&
            !(SecurityContextHolder.getContext().getAuthentication() 
                  instanceof AnonymousAuthenticationToken);
      if (isLoggedIn) {
         ModelAndView retVal = new ModelAndView();
         retVal.setViewName("redirect:/secure/index");
         return retVal;
      }
      
      ModelAndView retVal = new ModelAndView();
      retVal.setViewName("loginPage");
      return retVal;
   }
   
   @RequestMapping(value="/public/logout", method = RequestMethod.GET)
   public ModelAndView logout()
   {
      ModelAndView retVal = new ModelAndView();
      retVal.setViewName("logoutPage");
      return retVal;
   }
   
   @RequestMapping(value="/public/authFailed", method = RequestMethod.GET)
   public ModelAndView authFailed()
   {
      ModelAndView retVal = new ModelAndView();
      retVal.setViewName("authFailedPage");
      return retVal;
   }
   
   @RequestMapping(value="/public/accessDenied", method = RequestMethod.GET)
   public ModelAndView accessDenied()
   {
      ModelAndView retVal = new ModelAndView();
      retVal.setViewName("accessDeniedPage");
      return retVal;
   }
}

名为 login() 的方法将显示登录页面。正如我在本教程开头所述,当用户已登录时,再次向用户展示登录页面没有意义。因此,在我实现此方法时,我添加了一些逻辑来检测这种情况,然后将用户重定向到安全的索引页面,而不是显示登录页面。这是如何操作的

boolean isLoggedIn = SecurityContextHolder.getContext().getAuthentication() != null &&
      SecurityContextHolder.getContext().getAuthentication().isAuthenticated() &&
      !(SecurityContextHolder.getContext().getAuthentication() 
            instanceof AnonymousAuthenticationToken);
if (isLoggedIn) {
   ModelAndView retVal = new ModelAndView();
   retVal.setViewName("redirect:/secure/index");
   return retVal;
}

如果上面的检查失败,那么用户未登录,将看到浏览器显示的登录页面。这还不是最酷的部分,但应该有效。删除这部分然后重试相同的场景,已登录用户在输入登录页面 URL 时仍将看到登录页面。这是本教程的第一部分。接下来,我将解释如何配置会话持续时间为一个任意数字,以便我们更容易测试此示例应用程序。

会话持续时间配置

将默认会话持续时间从 30 分钟更改为不同的数字非常容易。尤其是对于默认的会话管理。我所要做的就是向 application.properties 文件添加一行,如下所示:

server.servlet.session.timeout=90s

值有一个单字符后缀 "s",表示秒。如果我将其更改为 "m",则表示分钟。在上面的代码中,我指定会话持续时间为 90 秒。这使我可以在登录后运行应用程序,等待 90 秒以上,然后查看会话过期如何处理。

请注意,如果我使用 MySQL 和 JDBC 进行会话存储和检索,这将不起作用。会话持续时间必须硬编码在 JDBC 配置的注释中才能进行会话存储。

这里的另一个陷阱是,您不能指定小于 1 分钟或 60 秒的值。这样,我可以轻松地测试我的会话超时处理,我可以登录,并将页面闲置 140 秒,然后再次尝试访问页面,这将触发超时。对于此示例应用程序,会话超时持续时间设置为 90 秒。

单页应用程序如何处理安全会话超时

我发现了一些关于 Spring Security、会话超时等方面的事情。我尝试的第一个想法是使用隐藏的心跳消息。这个心跳消息大约每分钟发送一次,当它未能收到预期响应时,它将重定向到登录页面。这个想法失败了。安全会话会缓存每一次客户端服务器交互,它会记录最近一次交互发生的时间,以及会话将要过期的时间,这是根据最近一次交互发生的时间计算的。因此,每一次心跳消息的发生,都会将过期时间推迟到未来的某个时刻,这意味着安全会话永远不会过期。所以,如果我想让我的会话永远不过期,我会使用对服务器的重复心跳消息。

在我尝试(心跳到服务器)的那个晚上,我从未见过会话过期,而且我无法弄清楚,因为使用默认的会话管理,我不知道我的会话数据存储在哪里(可能在文件系统或内存中)。我最终能够通过我最近的一篇教程来弄清楚,我使用 MySQL 来存储会话数据,这是可查询的。一旦我意识到会话正在自动延长,我就能够检查我目前的解决方案并找出为什么它不起作用。总之,我离题了,重点是,不要使用持续的心跳来检查会话是否即将过期,否则您的会话将永远不会过期。

由于我已经正确配置了后端以两种不同的方式处理会话过期。因此,我在前端处理会话过期要容易得多。前端主要调用后端 API。当会话过期时,API 调用将返回 HTTP 状态码 403(访问被拒绝错误),并且响应将包含一个 JSON 对象。这允许前端识别访问被拒绝错误。然后 AngularJS 代码可以强制浏览器重定向到登录页面。

现在您已经了解了概念,我们可以通过前端设计来查看它是如何识别会话过期并做出适当决定的。首先,AngularJS 应用程序声明

(function () {
   "use strict";
   var mod = angular.module("testSampleApp", [ "ngResource" ]);
   ...
})();

接下来,我定义了使用 ngResource 的心跳 API 调用。这是页面上的按钮用于测试会话过期。它是这样的

...
mod.factory("testService", ["$resource",
   function ($resource) {
      var retVal = {
         getHeartbeat: getHeartbeat
      };
      
      var apiRes = $resource(null, null, {
         heartbeat: {
            url: "/secure/api/heartbeat",
            method: "GET",
            isArray: false
         }
      });
      
      function getHeartbeat() {
         return apiRes.heartbeat().$promise;
      }
      
      return retVal;
   }
]);
...

接下来是应用程序使用的控制器。它是这样的

...
mod.controller("testSampleController", 
[ "$scope", "$interval", "httpErrCheckService", "testService",
   function ($scope, $interval, httpErrCheckService, testService) {
      var vm = this;
      vm.secsLeft = 60;
      vm.intvProm = null;
      
      vm.intvProm = $interval(function() {
         if (vm.secsLeft > 0) {
            vm.secsLeft -= 1;
         } else {
            if (vm.intvProm != null) {
               $interval.cancel(vm.intvProm);
            }
         }
      }, 3000);
      
      vm.clickBtn = function () {
         testService.getHeartbeat().then(
            function (result) {
               if (result && result.success) {
                  vm.secsLeft = 60;
                  console.log(result);
                  console.log("Heartbeat successful.");
               } else {
                  console.log(result);
                  console.log("Heartbeat failed.");
               }
            }, function (error) {
               if (error) {
                  console.log(error);
               }
               console.log("Error happened.");
               httpErrCheckService.checkAccessDenied(error);
            }
         );
      };
   }
]);

关于这个控制器有几点要讨论。第一个是应用程序中使用的 interval 组件。它每 3 秒递减计数器,直到计数器达到 0。一旦达到 0,interval 组件就会自行取消。

...
vm.secsLeft = 60;
vm.intvProm = null;

vm.intvProm = $interval(function() {
   if (vm.secsLeft > 0) {
      vm.secsLeft -= 1;
   } else {
      if (vm.intvProm != null) {
         $interval.cancel(vm.intvProm);
      }
   }
}, 3000);
...

另一个要讨论的是 click 事件处理程序方法。这个方法是会话过期可能发生的地方。如果会话未过期,单击事件将向服务器发送心跳,然后返回响应,这将重置会话,从而重置计数器。但如果会话已过期,响应将是访问被拒绝错误 403。在这种情况下,前端应强制浏览器重定向到登录页面。

...
vm.clickBtn = function () {
   testService.getHeartbeat().then(
      function (result) {
         if (result && result.success) {
            vm.secsLeft = 60;
            console.log(result);
            console.log("Heartbeat successful.");
         } else {
            console.log(result);
            console.log("Heartbeat failed.");
         }
      }, function (error) {
         if (error) {
            console.log(error);
         }
         console.log("Error happened.");
         httpErrCheckService.checkAccessDenied(error);
      }
   );
};
...

再次,我突出显示了重要的那一行。这一行位于心跳响应处理的错误处理部分。传递的错误对象是一个原始的 http 对象,它有一个 status 属性(状态码)和一个 data 对象。在 403 错误的情况下,data 对象将是一个自定义的 JSON 对象。我突出显示的那一行可以检查状态码是否等于 403,如果是 403,它将强制浏览器重定向到登录页面。这部分工作可以移到一个服务中。这是 httpErrCheckService 服务,它将执行 403 检查和浏览器重定向

...
mod.factory("httpErrCheckService", ["$window",
   function ($window) {
      var retVal = {
         checkAccessDenied: checkAccessDenied
      };
      
      function checkAccessDenied(errResp) {
         if (resp && resp.status === 403) {
            $window.open("/login", "_self");
         }
      }
      
      return retVal;
   }
]);
...

以上就是这个应用程序的所有前端设置。加上我们在此部分之前看到的所有后端代码,我们已经具备了所有基本要素。接下来的两部分将展示示例应用程序的一些杂项部分,以及如何构建和测试此应用程序。

示例应用程序的杂项部分

除了我上面已经涵盖的最基本部分之外,示例应用程序还有一些值得一看的部分。首先,让我向您展示索引页面的 HTML 标记

<!DOCTYPE HTML>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
   <meta charset="utf-8">
   <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

   <title>Login</title>
   <link rel="stylesheet" th:href="@{/assets/bootstrap/css/bootstrap.min.css}"/>
   <link rel="stylesheet" th:href="@{/assets/bootstrap/css/bootstrap-theme.min.css}"/>
   <link rel="stylesheet" th:href="@{/assets/css/index.css}"/>
</head>
<body>
   <div class="container" ng-app="testSampleApp" ng-controller="testSampleController as vm">
      <div class="row">
         <div class="col-xs-12">
            <div class="panel panel-default">
               <div class="panel-body">
                  <div class="row">
                     <div class="col-xs-12 text-center">
                        <p><span ng-class="{'text-danger': vm.secsLeft 
                        < vm.secsThreshold}">{{vm.secsLeft}}</span> seconds left</p>
                        <p>{{vm.secsLeft}} seconds left</p>
                        <p>After login wait for 3 minutes, then click the button. 
                        The login session is expired will redirect to login page.</p>
                        <button class="btn btn-default" ng-click="vm.clickBtn()">
                         Click Me</button>
                     </div>
                  </div>
               </div>
            </div>
         </div>
      </div>
   </div>
   
   <script type="text/javascript" th:src="@{/assets/jquery/js/jquery.min.js}"></script>
   <script type="text/javascript" 
    th:src="@{/assets/bootstrap/js/bootstrap.min.js}"></script>
   <script type="text/javascript" 
    th:src="@{/assets/angularjs/1.7.5/angular.min.js}"></script>
   <script type="text/javascript" 
    th:src="@{/assets/angularjs/1.7.5/angular-resource.min.js}"></script>
   <script type="text/javascript" 
    th:src="@{/assets/angularjs/1.7.5/angular-route.min.js}"></script>
   <script type="text/javascript" th:src="@{/assets/app/js/app.js}"></script>
</body>
</html>

我使用了 Thymeleaf 模板标记,页面非常简单。有一个按钮,并且显示一个计数器。

这是用于心跳通信以及索引页面显示的 API 控制器

package org.hanbo.boot.app.controllers;

import org.hanbo.boot.app.models.*;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.servlet.ModelAndView;

@Controller
public class SecuredPageController
{
   @PreAuthorize("hasRole('USER')")
   @RequestMapping(value="/secure/index", method = RequestMethod.GET)
   public ModelAndView index1()
   {
        ModelAndView retVal = new ModelAndView();
        retVal.setViewName("indexPage");
        return retVal;  
   }
   
   @PreAuthorize("hasRole('USER')")
   @RequestMapping(value="/secure/api/heartbeat", method = RequestMethod.GET)
   public ResponseEntity<GenericResponse> heartbeat()
   {
      GenericResponse resp = new GenericResponse();
      resp.setSuccess(true);
      resp.setStatusMessage("Heartbeat message received successfully.");
      
      return ResponseEntity.ok(resp);
   }
}

此示例应用程序旨在演示如何为 AngularJS 应用程序处理会话超时,后端可以像这样简单。

我还为访问被拒绝错误需要一个数据模型。这是与 403 错误代码一起返回的响应 JSON 对象。它是这样的

package org.hanbo.boot.app.models;

public class GenericResponse
{
   private boolean success;
   
   private String statusMessage;

   public boolean isSuccess()
   {
      return success;
   }

   public void setSuccess(boolean success)
   {
      this.success = success;
   }

   public String getStatusMessage()
   {
      return statusMessage;
   }

   public void setStatusMessage(String statusMessage)
   {
      this.statusMessage = statusMessage;
   }
}

就是这样。这些是那些不太重要但仍然是示例应用程序关键部分的项目。在下一部分中,我将讨论如何构建和测试此示例应用程序。

如何构建和测试示例应用程序

获取源代码后,请先将 src/main/resources/static/ 子文件夹中的 *.sj 文件重命名为 *.js。之后,使用命令行提示符,将目录 cd 到示例应用程序的基文件夹。然后运行以下命令

mvn clean install

要编译,请使用 JDK 15。如果您想降级到 JDK 8,请更改 POM.xml 中的 JDK 版本。如果一切顺利,编译将成功完成。尽管下载依赖 jar 文件会花费一些时间。但编译和打包大约需要 3 到 4 秒。

要运行应用程序,请在基目录中使用以下命令

java -jar target/angular-spring-session-sample1-1.0.0.jar

如果您能够编译和打包应用程序,那么上面的应用程序将成功运行。接下来,您可以在浏览器中使用以下 URL 运行该应用程序

https://:8080/secure/index

此链接将显示登录页面,如下所示

您可以使用凭据“user1”和密码“user12345”登录到安全索引页面。登录后,您会看到

当计数器低于 29 时,会话将过期。但由于页面与后端服务之间没有交互,您仍然可以看到这个安全的索引页面。

如果单击“Click Me”按钮。您将被重定向到登录页面,如下所示

如果您再次登录,然后将以下 URL 复制并粘贴到浏览器中

https://:8080/login

您将被重定向到安全的索引页面。我知道这个应用程序实际上没有任何有用的功能。其目的是演示这些功能

  • 用户登录后,访问登录页面将重定向到安全的索引页面。没有必要再次登录页面。
  • 当用户会话过期时,下一次服务器 API 调用应导致服务器返回 HTTP 错误 403 并附带一些有意义的消息。

至此,我认为示例应用程序的功能足以演示这两点。

摘要

本教程到此结束,一个简单的教程。示例应用程序没有任何有用的功能。其目的是演示 AngularJS 应用程序如何处理安全会话和会话过期的几种方式。为了使演示正常工作,我必须将会话持续时间配置为 90 秒而不是默认的 30 分钟。这可以通过 application.properties 文件中的一行完成。接下来,我讨论了如何配置登录操作,以便在用户已经处于安全会话时重定向到安全页面。当用户已登录时,允许用户再次登录没有意义。这大约需要 4 到 6 行代码。

本教程最重要的部分是处理会话过期异常的代码逻辑。这可以通过创建一个实现 AuthenticationEntryPoint 接口的类来完成。然后将其发送到 .authenticationEntryPoint() 方法(Spring Security 配置的一部分)。在我的实现类中,我使用硬编码的逻辑来检查请求 URI,以识别何时通过服务器重定向发送登录页面,以及何时发送 403 错误,并附加额外信息到 JSON 对象。

通过一些仔细的设计,可以通过文件或数据库的配置来处理访问被拒绝错误,而不是仅仅在类本身中硬编码更改。除此之外,我还解释了为什么使用持续的心跳检查无法检测会话过期。以及为什么使用 RESTFul API 调用处理会话过期(登录页面返回 200 而不是 HTTP 错误 403)如此困难。此问题只能通过实现 AuthenticationEntryPoint 接口来解决。一旦这一点清晰,一切都会变得容易解决。我希望这会有所帮助。祝您好运!

历史

  • 2021 年 6 月 5 日 - 初稿
  • 2021 年 6 月 18 日 - 第二稿
© . All rights reserved.