目录
用户认证通过后,为了避免用户的每次操作都进行认证可将用户的信息保存在会话中。spring security 提供会话管理,认证通过后将身份信息放入SecurityContextHolder上下文,SecurityContext与当前线程进行绑定,方便获取用户身份。

- private String getUsername() {
- // 从 SecurityContext 中获取当前登录的用户信息
- Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
- if (!authentication.isAuthenticated()) {
- return null;
- }
- Object principal = authentication.getPrincipal();
- String username = null;
- if (principal instanceof UserDetails) {
- username = ((UserDetails) principal).getUsername();
- } else {
- username = principal.toString();
- }
- return username;
- }
我们可以通过以下选项准确控制会话何时创建以及Spring Security如何与之交互:
| 机制 | 描述 |
| always | 如果session不存在总是需要创建 |
| ifRequired | 如果需要就创建一个session(默认)登录时 |
| never | Spring Security 将不会创建session,但是如果应用中其他地方创建了session,那么Spring Security将会使用它 |
| stateless | Spring Security将绝对不会创建session,也不使用session。并且它会暗示不使用 cookie,所以每个请求都需要重新进行身份验证。这种无状态架构适用于REST API 及其无状态认证机制。 |
- @Override
- protected void configure(HttpSecurity http) throws Exception {
- http.formLogin() //表单提交
- .successHandler(new MyAuthenticationSuccessHandler("/main.html"));
-
- http.sessionManagement() // session 策略
- .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED);
-
- http.authorizeRequests()
- .antMatchers("/error.html","/main.html").permitAll() // 不需要认证
- .anyRequest()
- .authenticated() // 认证拦截
- .and().csrf().disable(); //关闭csrf防护
- }
默认情况下,Spring Security 会为每个登录成功的用户会新建一个Session,就是ifRequired 。在执行认证过程之前,spring security将运行SecurityContextPersistenceFilter过滤器负责存储安全请求上下文,上下文根据策略进行存储,默认为HttpSessionSecurityContextRepository ,其使用http session 作为存储器。
可以在 sevlet 容器中设置 Session 的超时时间,如下设置 Session 有效期为 600s ;
spring boot配置文件:
- server:
- servlet:
- session:
- timeout: 60s
注意:session最低60s,参考源码 TomcatServletWebServerFactory#configureSession:
- private void configureSession(Context context) {
- // 设置超时时间
- long sessionTimeout = getSessionTimeoutInMinutes();
- context.setSessionTimeout((int) sessionTimeout);
- Boolean httpOnly = getSession().getCookie().getHttpOnly();
- if (httpOnly != null) {
- context.setUseHttpOnly(httpOnly);
- }
- if (getSession().isPersistent()) {
- Manager manager = context.getManager();
- if (manager == null) {
- manager = new StandardManager();
- context.setManager(manager);
- }
- configurePersistSession(manager);
- }
- else {
- context.addLifecycleListener(new DisablePersistSessionListener());
- }
- }
设置超时时间,最小超时时间为 1 分钟
- private long getSessionTimeoutInMinutes() {
- Duration sessionTimeout = getSession().getTimeout();
- if (isZeroOrLess(sessionTimeout)) {
- return 0;
- }
- // 比较取最大值
- return Math.max(sessionTimeout.toMinutes(), 1);
- }
session 超时之后,可以通过Spring Security 设置跳转的路径。
- http.sessionManagement()
- .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
- .invalidSessionUrl("/session/invalid");
对应路径接口的代码
- @RestController
- @RequestMapping("/session")
- public class AdminController {
-
- @GetMapping("/invalid")
- @ResponseStatus(code = HttpStatus.UNAUTHORIZED)
- public String sessionInvalid() {
- return "session失效";
- }
- }
用户在这个手机登录后,他又在另一个手机登录相同账户,对于之前登录的账户是否需要被挤兑,或者说在第二次登录时限制它登录,更或者像腾讯视频 VIP 账号一样,最多只能五个人同时登录,第六个人将限制登录。
- @Override
- protected void configure(HttpSecurity http) throws Exception {
- http.formLogin() //表单提交
- .successHandler(new MyAuthenticationSuccessHandler("/main.html"));
-
- http.sessionManagement()
- .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
- .maximumSessions(1) // 只能有一个session 在线, 最大会话数
- .expiredSessionStrategy(new MyExpiredSessionStrategy()); // session过期策略
-
- http.authorizeRequests()
- .antMatchers("/error.html","/main.html").permitAll() // 不需要认证
- .anyRequest()
- .authenticated() // 认证拦截
- .and().csrf().disable(); //关闭csrf防护
- }
配置 session 失效拒绝策略
- import org.springframework.security.web.session.SessionInformationExpiredEvent;
- import org.springframework.security.web.session.SessionInformationExpiredStrategy;
-
- import javax.servlet.http.HttpServletResponse;
- import java.io.IOException;
-
- public class MyExpiredSessionStrategy implements SessionInformationExpiredStrategy {
- @Override
- public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException {
- HttpServletResponse response = event.getResponse();
- response.setContentType("application/json;charset=UTF-8");
- response.getWriter().write("您已被挤兑下线!");
- }
- }
1. 使用chrome浏览器,先登录,再访问 http://localhost:8080/admin/test
2. 使用ie浏览器,再登录,再访问 http://localhost:8080/admin/test
3. 使用chrome浏览器,重新访问 http://localhost:8080/admin/test,会执行expiredSessionStrategy,页面上显示”您已被挤兑下线!“

阻止用户第二次登录
sessionManagement 也可以配置 maxSessionsPreventsLogin:boolean值,当达到maximumSessions 设置的最大会话个数时阻止登录。
- http.sessionManagement()
- .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
- .maximumSessions(1) // 只能有一个session 在线, 最大会话数
- .expiredSessionStrategy(new MyExpiredSessionStrategy()) // session过期策略
- .maxSessionsPreventsLogin(true); // 阻止 会话超过最大值,防止被踢
当限制 session 个数为 1 时,同一个账号第二次登陆,将会被阻止

实际场景中一个服务会至少有两台服务器在提供服务,在服务器前面会有一个nginx做负载均衡,用户访问 nginx,nginx 再决定去访问哪一台服务器。当一台服务宕机了之后,另一台服务器也可以继续提供服务,保证服务不中断。此时,用户登录的会话信息就不能再保存到 Web 服务器中,而是保存到一个单独的库(redis、mongodb、mysql等)中,所有服务器都访问同一个库,都从同一个库来获取用户的session信息。

引入spring session依赖
- <dependency>
- <groupId>org.springframework.session</groupId>
- <artifactId>spring-session-data-redis</artifactId>
- </dependency>
- <dependency>
- <groupId>redis.clients</groupId>
- <artifactId>jedis</artifactId>
- </dependency>
修改 application.yaml 配置,spring 就会自动把 session 存入到 redis 当中
- spring:
- datasource: # 数据库配置
- driver-class-name: com.mysql.jdbc.Driver
- url: jdbc:mysql://localhost:3306/security?useSSL=false
- password: root
- username: root
- session: # session 配置
- store-type: redis
- redis: # redis 配置
- host: localhost
- port: 6379
-
- server:
- servlet:
- session:
- timeout: 60s # session 过期时间
redis 中存放的 session

再次访问时,请求头中会带上 session 信息

session 的自动存储源码
找到 SessionRepositoryFilter.java 这个过滤器,SessionRepositoryFilter#doFilterInternal 方法源码如下
- @Override
- protected void doFilterInternal(HttpServletRequest request,
- HttpServletResponse response, FilterChain filterChain)
- throws ServletException, IOException {
- request.setAttribute(SESSION_REPOSITORY_ATTR, this.sessionRepository);
-
- SessionRepositoryRequestWrapper wrappedRequest = new SessionRepositoryRequestWrapper(
- request, response, this.servletContext);
- SessionRepositoryResponseWrapper wrappedResponse = new SessionRepositoryResponseWrapper(
- wrappedRequest, response);
-
- try {
- filterChain.doFilter(wrappedRequest, wrappedResponse);
- }
- finally {
- // 提交session
- wrappedRequest.commitSession();
- }
- }
其中 wrappedRequest.commitSession(); 便执行了 session 存储的逻辑
- private void commitSession() {
- HttpSessionWrapper wrappedSession = getCurrentSession();
- if (wrappedSession == null) {
- if (isInvalidateClientSession()) {
- SessionRepositoryFilter.this.httpSessionIdResolver.expireSession(this,
- this.response);
- }
- }
- else {
- S session = wrappedSession.getSession();
- clearRequestedSessionCache();
- // 存储 session
- SessionRepositoryFilter.this.sessionRepository.save(session);
- String sessionId = session.getId();
- if (!isRequestedSessionIdValid()
- || !sessionId.equals(getRequestedSessionId())) {
- SessionRepositoryFilter.this.httpSessionIdResolver.setSessionId(this,
- this.response, sessionId);
- }
- }
- }
其中 sessionRepository ,就是类中的以下这个属性
private final SessionRepository<S> sessionRepository;
SessionRepository 接口的其中有一个实现就是 redis 的

最终会调用 RedisOperationsSessionRepository#save 进行保存
- public void save(RedisOperationsSessionRepository.RedisSession session) {
- session.saveDelta();
- if (session.isNew()) {
- String sessionCreatedKey = this.getSessionCreatedChannel(session.getId());
- this.sessionRedisOperations.convertAndSend(sessionCreatedKey, session.delta);
- session.setNew(false);
- }
- }
安全会话cookie
我们可以使用 httpOnly 和 secure 标签来保护我们的会话 cookie:
spring boot配置文件:
- server.servlet.session.cookie.http-only=true
- server.servlet.session.cookie.secure=true
Spring Security 中 Remember Me 为“记住我”功能,用户只需要在登录时添加 remember-me复选框,取值为true。Spring Security 会自动把用户信息存储到数据源中,以后就可以不登录进行访问。
RememberMe 配置完整版
- import com.swadian.userdemo.filter.MyExpiredSessionStrategy;
- import com.swadian.userdemo.service.MyUserDetailsService;
- import org.springframework.beans.factory.annotation.Autowired;
- import org.springframework.context.annotation.Bean;
- import org.springframework.context.annotation.Configuration;
- import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
- import org.springframework.security.config.annotation.web.builders.HttpSecurity;
- import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
- import org.springframework.security.config.http.SessionCreationPolicy;
- import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
- import org.springframework.security.crypto.password.PasswordEncoder;
- import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl;
- import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
-
- import javax.sql.DataSource;
-
- /**
- * @author swadian
- */
- @Configuration // 标记为注解类
- public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {
-
- @Autowired
- private MyUserDetailsService userService;
-
- @Bean
- public PasswordEncoder passwordEncoder() {
- return new BCryptPasswordEncoder();
- }
-
- @Override
- protected void configure(AuthenticationManagerBuilder auth) throws Exception {
- //设置UserDetailsService的实现类
- auth.userDetailsService(userService);
- }
-
- @Override
- protected void configure(HttpSecurity http) throws Exception {
- http.formLogin() //表单提交
- .loginPage("/login.html") //自定义登录页面
- .loginProcessingUrl("/my-user/login");//登录访问路径,必须和表单提交接口一样
-
- // 记住我
- http.rememberMe().tokenRepository(persistentTokenRepository())//设置持久化仓库
- .tokenValiditySeconds(3600) //超时时间,单位s 默认两周
- .userDetailsService(userService); //设置自定义登录逻辑
-
- http.authorizeRequests()
- .antMatchers("/login.html", "/error.html", "/main.html").permitAll() // 不需要认证
- .anyRequest()
- .authenticated() // 认证拦截
- .and().csrf().disable(); //关闭csrf防护
- }
-
- @Autowired // rememberMe -> 需要引入数据源
- public DataSource dataSource;
-
- public PersistentTokenRepository persistentTokenRepository() {
- JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
- // rememberMe -> 设置数据源
- jdbcTokenRepository.setDataSource(dataSource);
- return jdbcTokenRepository;
- }
-
- }
创建数据库表
- CREATE TABLE persistent_logins (
- username VARCHAR ( 64 ) NOT NULL,
- series VARCHAR ( 64 ) PRIMARY KEY,
- token VARCHAR ( 64 ) NOT NULL,
- last_used TIMESTAMP NOT NULL
- )
在客户端登录页面 login.html 中添加 remember-me 的复选框,只要用户勾选了复选框下次就不需要进行登录了。
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8">
- <title>Title</title>
- </head>
- <body>
- <form action="/my-user/login" method="post">
- 用户名:<input type="text" name="username"/><br/>
- 密码: <input type="password"name="password"/><br/>
- <input type="checkbox" name="remember-me" value="true"/><br/>
- <input type="submit" value="提交"/></form>
- </body>
- </html>
成功登陆后,我们可以看到数据库表中多了一行记录

spring security 很多功能都是基于过滤器实现的,因此我们可以去代码中找 RememberMe 过滤器的代码实现。
在源码中可以找到这个方法 RememberMeAuthenticationFilter # doFilter
- public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
- throws IOException, ServletException {
- HttpServletRequest request = (HttpServletRequest) req;
- HttpServletResponse response = (HttpServletResponse) res;
-
- if (SecurityContextHolder.getContext().getAuthentication() == null) {
- // 自动登陆
- Authentication rememberMeAuth = rememberMeServices.autoLogin(request,
- response);
-
- if (rememberMeAuth != null) {
- // Attempt authenticaton via AuthenticationManager
- try {
- // 认证逻辑
- rememberMeAuth = authenticationManager.authenticate(rememberMeAuth);
-
- // Store to SecurityContextHolder
- SecurityContextHolder.getContext().setAuthentication(rememberMeAuth);
-
- onSuccessfulAuthentication(request, response, rememberMeAuth);
-
- if (logger.isDebugEnabled()) {
- logger.debug("SecurityContextHolder populated with remember-me token: '"
- + SecurityContextHolder.getContext().getAuthentication()
- + "'");
- }
-
- // Fire event
- if (this.eventPublisher != null) {
- eventPublisher
- .publishEvent(new InteractiveAuthenticationSuccessEvent(
- SecurityContextHolder.getContext()
- .getAuthentication(), this.getClass()));
- }
-
- if (successHandler != null) {
- successHandler.onAuthenticationSuccess(request, response,
- rememberMeAuth);
-
- return;
- }
-
- }
- catch (AuthenticationException authenticationException) {
- if (logger.isDebugEnabled()) {
- logger.debug(
- "SecurityContextHolder not populated with remember-me token, as "
- + "AuthenticationManager rejected Authentication returned by RememberMeServices: '"
- + rememberMeAuth
- + "'; invalidating remember-me token",
- authenticationException);
- }
-
- rememberMeServices.loginFail(request, response);
-
- onUnsuccessfulAuthentication(request, response,
- authenticationException);
- }
- }
-
- chain.doFilter(request, response);
- }
- else {
- if (logger.isDebugEnabled()) {
- logger.debug("SecurityContextHolder not populated with remember-me token, as it already contained: '"
- + SecurityContextHolder.getContext().getAuthentication() + "'");
- }
-
- chain.doFilter(request, response);
- }
- }
Spring security默认实现了 logout 退出,用户只需要向 Spring Security 项目中发送 /logout 退出请求即可。
默认的退出 url 为 /logout ,退出成功后跳转到 /login?logout 。进入 LogoutConfigurer.java 可以看到如下配置

自定义退出逻辑
如果不希望使用默认值,可以通过下面的方法进行修改。
- http.logout()
- .logoutUrl("/logout")
- .logoutSuccessUrl("/login.html"); // 退出后跳转到登陆页面
执行 http://localhost:8080/logout 可以看到退出效果
退出登录源码
同样是从过滤器开始,LogoutFilter # doFilter
- public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
- throws IOException, ServletException {
- HttpServletRequest request = (HttpServletRequest) req;
- HttpServletResponse response = (HttpServletResponse) res;
-
- if (requiresLogout(request, response)) {
- // 1-获取用户信息
- Authentication auth = SecurityContextHolder.getContext().getAuthentication();
-
- if (logger.isDebugEnabled()) {
- logger.debug("Logging out user '" + auth
- + "' and transferring to logout destination");
- }
- // 2-退出登陆 -> SecurityContextLogoutHandler#logout
- this.handler.logout(request, response, auth);
- // 3-拓展点,成功退出后的操作
- logoutSuccessHandler.onLogoutSuccess(request, response, auth);
-
- return;
- }
-
- chain.doFilter(request, response);
- }
SecurityContextLogoutHandler 实现了 LogoutHandler 接口
SecurityContextLogoutHandler # logout 实现了具体的退出逻辑
当退出操作出发时,将发生:
- public void logout(HttpServletRequest request, HttpServletResponse response,
- Authentication authentication) {
- Assert.notNull(request, "HttpServletRequest required");
- if (invalidateHttpSession) {
- HttpSession session = request.getSession(false);
- if (session != null) {
- logger.debug("Invalidating session: " + session.getId());
- // 1-失效 session
- session.invalidate();
- }
- }
-
- if (clearAuthentication) {
- // 2-清空用户信息
- SecurityContext context = SecurityContextHolder.getContext();
- context.setAuthentication(null);
- }
- // 3-清空Security上下文
- SecurityContextHolder.clearContext();
- }
至此,退出登陆分析结束。