八、RememberMe
简介
RememberMe 这个功能非常常见,无论是在 QQ、邮箱…都有这个选项。提到 RememberMe,往往会有一些误解,认为 RememberMe 功能就是把 用户名/密码 用 Cookie 保存在浏览器中,下次登陆时不用再次输入 用户名/密码。这个理解显然是不对的。我们这里所说的 RememberMe 是一种服务器端的行为。传统的登录方式基于 Session 会话,一旦用户的会话超时过期,就要再次登录,这样太过于繁琐。如果有一种机制,让用户会话过期之后,还能继续保持认证状态,就会方便很多。RememberMe 就是为了解决这一需求而生
具体的实现思路就是通过 Cookie 来记录当前用户身份。当用户登陆成功之后,会通过一定算法,将用户信息、时间戳等进行加密,加密完成之后,通过响应头带回给前端存储在 Cookie 中,当浏览器会话过期之后,如果再次访问该网站,会自动将 Cookie 中的信息发送给服务器,服务器对 Cookie 中考的信息进行校验分析,进而确定出用户的身份,Cookie 中所保存的用户信息也是有效的,例如三天、一周等
8.1 基本使用
开启记住我
8.2 原理分析
RememberMeAuthenticationFilter
从上图中,当在 SecurityConfig 配置中开启了"记住我"功能之后,在进行认证时如果勾选了"记住我"选项,此时打开浏览器控制台,查看network 中的请求头信息。首先我们登陆时,在登陆请求中多了一个 remember-me 的参数
很显然,这个参数就是告诉服务器应该开启 RememberMe 这个功能的。如果自定义登陆页面开启 Remember 功能应该多加入一个一样的请求参数就可以了。请求最终会被 RememberMeAuthenticationFilter 进行拦截,然后自动登录具体参见源码
记住我: <input type="checkbox" name="remember-me">
- 流程
- 请求到达过滤器之后,首先判断 SecurityContextHolder 中是否有值,没有值的话表示用户尚未登陆,此时调用 autoLogin() 方法进行自动登录
- 当自动登录成功后返回的
rememberMeAuth
不为null
时,表示自动登陆成功,此时调用 authenticate() 方法对 key 进行校验,并将登陆成功的用户信息保存到 SecurityContextHolder 对象中,然后发布登录成功事件,调用登陆成功回调。需要关注的是,登陆成功的回调并不包含 RememberMeServices 中的 loginSuccess() 方法 - 如果自动登陆失败,则调用 rememberMeServices.loginFail 方法处理登陆失败的回调。onUnSuccessfulAuthentication 和 onSuccessfulAuthentication 都是该过滤器中定义的空方法,并没有任何实现,这就是 RememberMeAuthenticationFilter 过滤器所做的事情,成功将 RememberMeServices 的服务集成进来
RememberMeServices
RememberMeServices 一共定义了三个方法
- autoLogin:可以从请求中提取需要的参数,完成自动登录功能
- loginFail:方法是自动登陆失败的回调
- loginSuccess:方法是自动登录成功的回调
TokenBasedRememberMeServices
在开启记住我后,如果没有加入额外配置默认实现就是由 TokenBasedRememberMeServices 进行实现的。查看这个类源码中 processAutoLoginCookie() 方法实现(用于使用 Cookie 进行自动登录)
processAutoLoginCookie() 方法主要用来验证 Cookie 中的令牌信息是否合法
- 流程
- 首先判断 cookieTokens 长度是否为3,如果不为3说明格式不符合,直接抛出异常
- 从 cookieTokens 数组中提取出第 1 项,也就是过期时间,判断令牌是否过期,如果已经过期,则抛出异常
- 根据用户名(cookieTokens 数组的第0项)查询当前用户的对象
- 调用 makeTokenSignature 方法生成一个签名,签名生成的过程如下
- 首先将用户名、令牌过期时间、用户密码以及 key 组成一个字符串,中间用
:
隔开 - 然后通过 MD5 消息摘要算法对该字符串进行加密,并将密码结果转为一个字符串返回
- 首先将用户名、令牌过期时间、用户密码以及 key 组成一个字符串,中间用
- 判断第4步生成的签名和通过 Cookie 传来的签名是否相等(即 cookieTokens 数组的第2项),如果相等,表示令牌合法,则直接返回用户对象,否则抛出异常
-
成功登录回调过程
- 在这个回调中,首先获取用户名和密码信息,如果用户密码在用户登录成功后从 successfulAuthentication 对象中擦除,则从数据库中重新加载出用户密码
- 计算出令牌的过期时间,令牌默认有效期是两周
- 根据令牌的过期时间、用户名以及用户密码,计算出一个签名
- 调用 setCookie() 方法设置 Cookie,参数是一个字符串数组,数组中一共包含三项。用户名、过期时间以及签名,在 setCookie() 方法中会将数组转为字符串,并进行 Base64 编码后响应给前端
-
生成 token
- 登陆认证成功之后的操作
- 将生成的 token 存储到 Cookie 中
- 对传递的 token 进行编码后存入当前 cookie 中
总结
当用户通过 用户名/密码 的形式登录成功后,系统会根据用户的用户名、密码以及令牌的过期时间计算出一个签名,这个签名使用 MD5 消息摘要算法生成,是不可逆的。然后再将用户名,令牌过期时间以及签名拼接成一个字符串,中间用:
隔开,对拼接好的字符串进行 Base64 编码,然后将编码后的结果返回到前端,也就是我们在浏览器中看到的令牌。当关闭浏览器再次打开,访问系统资源时会自动携带上 Cookie 中的令牌,服务端拿到 Cookie 中的令牌后,先进行 Bae64 解码,解码后分别提取出令牌中的三项数据:接着根据令牌中的数据判断令牌是否已经过期,如果没有过期,则根据令牌中的用户名查询出用户信息;接着再计算出一个签名和令牌中的签名进行对比,如果一致,表示会牌是合法令牌,自动登录成功,否则自动登录失败
8.3 内存令牌
PersistentTokenBasedRememberMeServices
- 流程
- 不同于 TokonBasedRememberMeServices 中的 processAutologinCookie 方法,这里
cookieTokens
数组的长度为2,第一项是 series,第二项是 token - 从 cookieTokens 数组中分到提取出
series
和token
然后根据series
去内存中查询出一个 PersistentRememberMeToken 对象。如果查询出来的对象为 null,表示内存中并没有series
对应的值,本次自动登录失败。如果查询出来的token
和从cookieTokens
中解析出来的token
不相同,说明自动登录会牌已经泄漏(恶意用户利用令牌登录后,内存中的token
变了),此时移除当前用户的所有自动登录记录并抛出异常 - 根据数据库中查询出来的结果判断令牌是否过期。如果过期就抛出异常
- 生成一个新的 PersistentRememberMeToken 对象,用户名和
series
不变,token
重新生成,date
也使用当前时间。 newToken 生成后,根据series
去修改内存中的token
和date
(即每次自动登录后都会产生新的token和date) - 调用addCookie()方法添加 Cookie,在addCookie()方法中,会调用到我们前面所说的setCookie()方法,但是要注意第一个数组参数中只有两项:
series
和token
(即返回到前端的令牌是通过对series
和token
进行Base64编码得到的) - 最后将根据用户名查询用户对象并返回
- 不同于 TokonBasedRememberMeServices 中的 processAutologinCookie 方法,这里
使用内存中令牌实现
package com.vinjcent.config.security;
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.web.authentication.RememberMeServices;
import org.springframework.security.web.authentication.rememberme.InMemoryTokenRepositoryImpl;
import org.springframework.security.web.authentication.rememberme.PersistentTokenBasedRememberMeServices;
import java.util.UUID;
/**
* 重写 WebSecurityConfigurerAdapter 类使得默认 DefaultWebSecurityCondition 条件失效
*/
@Configuration
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
// 构造注入使用@Autowired,set注入使用@Resource
private final DivUserDetailsService userDetailsService;
// UserDetailsService
@Autowired
public WebSecurityConfiguration(DivUserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}
// AuthenticationManager
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService);
}
// 拦配置http拦截
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeHttpRequests()
.mvcMatchers("/toLogin").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/toLogin")
.loginProcessingUrl("/login")
.usernameParameter("uname")
.passwordParameter("passwd")
.defaultSuccessUrl("/toIndex", true)
.failureUrl("/toLogin")
.and()
.logout()
.logoutUrl("/logout")
.logoutSuccessUrl("/toLogin")
.and()
.rememberMe()
.rememberMeServices(rememberMeServices())
// .rememberMeParameter("remember-me") // 用来接受请求中哪个参数作为开启记住我的参数
// .alwaysRemember(true) // 总是记住我,只针对服务后台设置
.and()
.csrf()
.disable();
}
// 指定记住我的实现
@Bean
public RememberMeServices rememberMeServices() {
return new PersistentTokenBasedRememberMeServices(
UUID.randomUUID().toString(), // 自定义一个生成令牌 key,默认 UUID
userDetailsService, // 认证数据源
new InMemoryTokenRepositoryImpl()); // 令牌存储方式(不建议使用内存的方式存储令牌,如果服务器重启,那么内存将全部失效)
}
}
8.4 持久化令牌(就如Shiro中的session缓存)
- 导入数据库相关依赖
<dependencies>
<!--mybatis-->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.2.2</version>
</dependency>
<!--mysql-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.22</version>
</dependency>
<!--druid-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.2.8</version>
</dependency>
</dependencies>
- 配置数据源
spring:
# 数据源
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/spring?useSSL=true&useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
username: root
password: 123456
mybatis:
# 注意 mapper 映射文件必须使用"/"
type-aliases-package: com.vinjcent.pojo
mapper-locations: com/vinjcent/mapper/**/*.xml
- 创建对应的表结构
CREATE TABLE `persistent_logins`
(username VARCHAR(64) NOT NULL,
series VARCHAR(64) PRIMARY KEY,
token VARCHAR(64) NOT NULL,
last_used TIMESTAMP NOT NULL
) ENGINE=INNODB DEFAULT CHARSET=utf8
- 对 RememberMeServices 进行持久化配置
// 指定记住我的实现
@Bean
public RememberMeServices rememberMeServices() {
// 配置 token 数据源,保证服务重启之后仍然有存储记录
JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
// 配置数据源
tokenRepository.setDataSource(dataSource);
// 设置第一次启动时,创建表结构(当对http请求的配置中不设置rememberMeServices()时,该设置生效,不然会报错)
// tokenRepository.setCreateTableOnStartup(true);
return new PersistentTokenBasedRememberMeServices(
UUID.randomUUID().toString(), // 自定义一个生成令牌 key,默认 UUID
userDetailsService, // 认证数据源
tokenRepository); // 令牌存储方式
}
- 总体配置
package com.vinjcent.config.security;
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.web.authentication.RememberMeServices;
import org.springframework.security.web.authentication.rememberme.InMemoryTokenRepositoryImpl;
import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl;
import org.springframework.security.web.authentication.rememberme.PersistentTokenBasedRememberMeServices;
import javax.sql.DataSource;
import java.util.UUID;
/**
* 重写 WebSecurityConfigurerAdapter 类使得默认 DefaultWebSecurityCondition 条件失效
*/
@Configuration
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
// 构造注入使用@Autowired,set注入使用@Resource
private final DivUserDetailsService userDetailsService;
// token 存储数据源
private final DataSource dataSource;
// UserDetailsService
@Autowired
public WebSecurityConfiguration(DivUserDetailsService userDetailsService, DataSource dataSource) {
this.userDetailsService = userDetailsService;
this.dataSource = dataSource;
}
// AuthenticationManager
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService);
}
// 拦配置http拦截
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeHttpRequests()
.mvcMatchers("/toLogin").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/toLogin")
.loginProcessingUrl("/login")
.usernameParameter("uname")
.passwordParameter("passwd")
.defaultSuccessUrl("/toIndex", true)
.failureUrl("/toLogin")
.and()
.logout()
.logoutUrl("/logout")
.logoutSuccessUrl("/toLogin")
.and()
.rememberMe()
.rememberMeServices(rememberMeServices())
// .rememberMeParameter("remember-me") // 用来接受请求中哪个参数作为开启记住我的参数
// .alwaysRemember(true) // 总是记住我,只针对服务后台设置
.and()
.csrf()
.disable();
}
// 指定记住我的实现
@Bean
public RememberMeServices rememberMeServices() {
// 配置 token 数据源,保证服务重启之后仍然有存储记录
JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
// 配置数据源
tokenRepository.setDataSource(dataSource);
// 设置第一次启动时,创建表结构(当对http请求的配置中不设置rememberMeServices()时,该设置生效,不然会报错)
// tokenRepository.setCreateTableOnStartup(true);
return new PersistentTokenBasedRememberMeServices(
UUID.randomUUID().toString(), // 自定义一个生成令牌 key,默认 UUID
userDetailsService, // 认证数据源
tokenRepository); // 令牌存储方式(不建议使用内存的方式存储令牌)
}
}
- 测试效果
第一次登录
重启服务测试,发现依然可以自动登录
8.5 自定义记住我(传统web版)
- 导入依赖
<dependencies>
<!--web-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--thymeleaf-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!--thymeleaf-security-->
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity5</artifactId>
<version>3.0.4.RELEASE</version>
</dependency>
<!--security-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!--mybatis-->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.2.2</version>
</dependency>
<!--mysql-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.22</version>
</dependency>
<!--druid-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.2.8</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
application.yml
配置文件
# 端口号
server:
port: 3035
servlet:
session:
# 设置session过期时间
timeout: 1
# 服务应用名称
spring:
application:
name: SpringSecurity08
# 关闭thymeleaf缓存(用于修改完之后立即生效)
thymeleaf:
cache: false
# thymeleaf默认配置
prefix: classpath:/templates/
suffix: .html
encoding: UTF-8
mode: HTML
# 数据源
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/spring?useSSL=true&useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
username: root
password: 123456
mybatis:
# 注意 mapper 映射文件必须使用"/"
type-aliases-package: com.vinjcent.pojo
mapper-locations: com/vinjcent/mapper/**/*.xml
# 日志处理,为了展示 mybatis 运行 sql 语句
logging:
level:
com:
vinjcent:
debug
- 实体类(这里就不说持久层、业务逻辑层了,之前已经写过了,可以往前面章节翻翻~)
- Role
package com.vinjcent.pojo;
import java.io.Serializable;
public class Role implements Serializable {
private Integer id;
private String name;
private String nameZh;
public Role() {
}
public Role(Integer id, String name, String nameZh) {
this.id = id;
this.name = name;
this.nameZh = nameZh;
}
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getNameZh() {
return nameZh;
}
public void setNameZh(String nameZh) {
this.nameZh = nameZh;
}
@Override
public String toString() {
return "Role{" +
"id=" + id +
", name='" + name + '\'' +
", nameZh='" + nameZh + '\'' +
'}';
}
}
- User
package com.vinjcent.pojo;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.*;
// 自定义用户User
public class User implements UserDetails {
private Integer id; // 用户id
private String username; // 用户名
private String password; // 密码
private boolean enabled; // 是否可用
private boolean accountNonExpired; // 账户过期
private boolean accountNonLocked; // 账户锁定
private boolean credentialsNonExpired; // 凭证过期
private List<Role> roles = new ArrayList<>(); // 用户角色信息
// 返回权限信息
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Set<SimpleGrantedAuthority> authorities = new HashSet<>();
roles.forEach(role -> {
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(role.getName());
authorities.add(simpleGrantedAuthority);
});
return authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@Override
public boolean isAccountNonExpired() {
return accountNonExpired;
}
@Override
public boolean isAccountNonLocked() {
return accountNonLocked;
}
@Override
public boolean isCredentialsNonExpired() {
return credentialsNonExpired;
}
@Override
public boolean isEnabled() {
return enabled;
}
public void setId(Integer id) {
this.id = id;
}
public void setUsername(String username) {
this.username = username;
}
public void setPassword(String password) {
this.password = password;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public void setAccountNonExpired(boolean accountNonExpired) {
this.accountNonExpired = accountNonExpired;
}
public void setAccountNonLocked(boolean accountNonLocked) {
this.accountNonLocked = accountNonLocked;
}
public void setCredentialsNonExpired(boolean credentialsNonExpired) {
this.credentialsNonExpired = credentialsNonExpired;
}
public void setRoles(List<Role> roles) {
this.roles = roles;
}
public Integer getId() {
return id;
}
public List<Role> getRoles() {
return roles;
}
}
- 视图页面
- login.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>登录页面</title>
</head>
<body>
<h1>用户登录</h1>
<form th:action="@{/login}" method="post">
用户名: <input type="text" name="uname"> <br>
密码: <input type="password" name="passwd"> <br>
<!-- value 可选值有:true yes on 1 -->
记住我: <input type="checkbox" name="remember-me" value="true">
<input type="submit" value="登录">
</form>
<h3>
<div th:text="${session.SPRING_SECURITY_LAST_EXCEPTION}"></div>
</h3>
</body>
</html>
- index.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
<meta charset="UTF-8">
<title>系统主页</title>
</head>
<body>
<h1>欢迎<span sec:authentication="principal.username"></span>,进入我的主页!</h1>
<hr>
<h1>获取认证用户信息</h1>
<ul>
<li sec:authentication="principal.username"></li>
<li sec:authentication="principal.authorities"></li>
<li sec:authentication="principal.accountNonExpired"></li>
<li sec:authentication="principal.accountNonLocked"></li>
<li sec:authentication="principal.credentialsNonExpired"></li>
</ul>
<a th:href="@{/logout}">退出登录</a>
</body>
</html>
- 自定义认证数据源 UserDetailsService
package com.vinjcent.config.security;
import com.vinjcent.pojo.Role;
import com.vinjcent.pojo.User;
import com.vinjcent.service.RoleService;
import com.vinjcent.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;
import java.util.List;
@Component
public class DivUserDetailsService implements UserDetailsService {
// dao ===> springboot + mybatis
private final UserService userService;
private final RoleService roleService;
@Autowired
public DivUserDetailsService(UserService userService, RoleService roleService) {
this.userService = userService;
this.roleService = roleService;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 1.查询用户
User user = userService.queryUserByUsername(username);
if (ObjectUtils.isEmpty(user)) throw new UsernameNotFoundException("用户名不正确!");
// 2.查询权限信息
List<Role> roles = roleService.queryRolesByUid(user.getId());
user.setRoles(roles);
return user;
}
}
- 配置拦截请求 WebSecurityConfigurerAdapter
package com.vinjcent.config.security;
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.web.authentication.RememberMeServices;
import org.springframework.security.web.authentication.rememberme.InMemoryTokenRepositoryImpl;
import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl;
import org.springframework.security.web.authentication.rememberme.PersistentTokenBasedRememberMeServices;
import javax.sql.DataSource;
import java.util.UUID;
/**
* 重写 WebSecurityConfigurerAdapter 类使得默认 DefaultWebSecurityCondition 条件失效
*/
@Configuration
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
// 构造注入使用@Autowired,set注入使用@Resource
private final DivUserDetailsService userDetailsService;
// token 存储数据源
private final DataSource dataSource;
// UserDetailsService
@Autowired
public WebSecurityConfiguration(DivUserDetailsService userDetailsService, DataSource dataSource) {
this.userDetailsService = userDetailsService;
this.dataSource = dataSource;
}
// AuthenticationManager
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService);
}
// 拦配置http拦截
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeHttpRequests()
.mvcMatchers("/toLogin").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/toLogin")
.loginProcessingUrl("/login")
.usernameParameter("uname")
.passwordParameter("passwd")
.defaultSuccessUrl("/toIndex", true) // 重定向
.failureUrl("/toLogin") // 失败重定向
.and()
.logout()
.logoutUrl("/logout")
.logoutSuccessUrl("/toLogin")
.and()
.rememberMe()
.rememberMeServices(rememberMeServices())
// .rememberMeParameter("remember-me") // 用来接受请求中哪个参数作为开启记住我的参数,注意前端传递的参数
// .alwaysRemember(true) // 总是记住我,只针对服务后台设置,无论前端是否点击"记住我"都默认使用记住我
.and()
.csrf()
.disable();
}
// 指定记住我的实现
@Bean
public RememberMeServices rememberMeServices() {
// 配置 token 数据源,保证服务重启之后仍然有存储记录
JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
// 配置数据源
tokenRepository.setDataSource(dataSource);
// 设置第一次启动时,创建表结构(当对http请求的配置中不设置rememberMeServices()时,该设置生效,不然会报错)
// tokenRepository.setCreateTableOnStartup(true);
return new PersistentTokenBasedRememberMeServices(
UUID.randomUUID().toString(), // 自定义一个生成令牌 key,默认 UUID
userDetailsService, // 认证数据源
tokenRepository); // 令牌存储方式(不建议使用内存的方式存储令牌)
}
}
8.6 自定义记住我(前后端分离)
在根据之前源码分析中,发现是根据 remember-me 设置记住我的参数,但是如果使用前后端分离,请求中的类型为 JSON 数据,又如何提取出来 remember-me 的参数呢?而又要如何在 Cookie 中设置我们的 token 令牌呢?
对于登录认证成功之后的操作,见如下图
这里调用了 rememberMeRequested()方法,传递的是一个 HttpServletRequest 和 String 类型的参数,而这个 rememberMeRequested()函数是在 AbstractRememberMeServices 抽象类中的,所以我们需要对其进行重写
- 导入依赖
pom.xml
<dependencies>
<!--web-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--security-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!--mybatis-->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.2.2</version>
</dependency>
<!--mysql-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.22</version>
</dependency>
<!--druid-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.2.8</version>
</dependency>
</dependencies>
application.yml
配置文件
# 端口号
server:
port: 3035
servlet:
session:
# 设置session过期时间
timeout: 1
# 服务应用名称
spring:
application:
name: SpringSecurity09security
# 数据源
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/spring?useSSL=true&useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
username: root
password: 123456
mybatis:
# 注意 mapper 映射文件必须使用"/"
type-aliases-package: com.vinjcent.pojo
mapper-locations: com/vinjcent/mapper/**/*.xml
# 日志处理,为了展示 mybatis 运行 sql 语句
logging:
level:
com:
vinjcent:
debug
- 实体类(这里就不说持久层、业务逻辑层了,之前已经写过了,可以往前面章节翻翻~)
- Role
package com.vinjcent.pojo;
import java.io.Serializable;
public class Role implements Serializable {
private Integer id;
private String name;
private String nameZh;
public Role() {
}
public Role(Integer id, String name, String nameZh) {
this.id = id;
this.name = name;
this.nameZh = nameZh;
}
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getNameZh() {
return nameZh;
}
public void setNameZh(String nameZh) {
this.nameZh = nameZh;
}
@Override
public String toString() {
return "Role{" +
"id=" + id +
", name='" + name + '\'' +
", nameZh='" + nameZh + '\'' +
'}';
}
}
- User
package com.vinjcent.pojo;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.*;
// 自定义用户User
public class User implements UserDetails {
private Integer id; // 用户id
private String username; // 用户名
private String password; // 密码
private boolean enabled; // 是否可用
private boolean accountNonExpired; // 账户过期
private boolean accountNonLocked; // 账户锁定
private boolean credentialsNonExpired; // 凭证过期
private List<Role> roles = new ArrayList<>(); // 用户角色信息
// 返回权限信息
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Set<SimpleGrantedAuthority> authorities = new HashSet<>();
roles.forEach(role -> {
SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(role.getName());
authorities.add(simpleGrantedAuthority);
});
return authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@Override
public boolean isAccountNonExpired() {
return accountNonExpired;
}
@Override
public boolean isAccountNonLocked() {
return accountNonLocked;
}
@Override
public boolean isCredentialsNonExpired() {
return credentialsNonExpired;
}
@Override
public boolean isEnabled() {
return enabled;
}
public void setId(Integer id) {
this.id = id;
}
public void setUsername(String username) {
this.username = username;
}
public void setPassword(String password) {
this.password = password;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public void setAccountNonExpired(boolean accountNonExpired) {
this.accountNonExpired = accountNonExpired;
}
public void setAccountNonLocked(boolean accountNonLocked) {
this.accountNonLocked = accountNonLocked;
}
public void setCredentialsNonExpired(boolean credentialsNonExpired) {
this.credentialsNonExpired = credentialsNonExpired;
}
public void setRoles(List<Role> roles) {
this.roles = roles;
}
public Integer getId() {
return id;
}
public List<Role> getRoles() {
return roles;
}
}
- 自定义登录过滤器
- LoginFilter
package com.vinjcent.filter;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.rememberme.AbstractRememberMeServices;
import org.springframework.util.ObjectUtils;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Map;
/**
* 自定义前后端分离的 Filter,重写 UsernamePasswordAuthenticationFilter
*/
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
// 用于指定请求类型
private boolean postOnly = true;
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if (this.postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
if (request.getContentType().equalsIgnoreCase(MediaType.APPLICATION_JSON_VALUE)) {
// 如果是json格式,需要转化成对象并从中获取用户输入的用户名和密码进行认证 {"username": "root", "password": "123", "remember-me": "true"}
try {
Map<String, String> userInfo = new ObjectMapper().readValue(request.getInputStream(), Map.class);
String username = userInfo.get(getUsernameParameter());
String password = userInfo.get(getPasswordParameter());
// 可以进行修改,使其成为动态参数
String rememberMe = userInfo.get(AbstractRememberMeServices.DEFAULT_PARAMETER);
// 如果 rememberMe 不为空
if (!ObjectUtils.isEmpty(rememberMe)) {
// 将其存储request作用域
request.setAttribute(AbstractRememberMeServices.DEFAULT_PARAMETER, rememberMe);
}
System.out.println("用户名: " + username + " 密码: " + password + " 是否记住我: " + rememberMe);
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, password);
setDetails(request, token);
return this.getAuthenticationManager().authenticate(token);
} catch (IOException e) {
e.printStackTrace();
}
}
return super.attemptAuthentication(request, response);
}
@Override
public void setPostOnly(boolean postOnly) {
this.postOnly = postOnly;
}
}
- 自定义记住我 services 实现类
- DivPersistentTokenBasedRememberMeServices
package com.vinjcent.config.security;
import org.springframework.core.log.LogMessage;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.rememberme.PersistentTokenBasedRememberMeServices;
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
import javax.servlet.http.HttpServletRequest;
/**
* 自定义记住我 services 实现类
*/
public class DivPersistentTokenBasedRememberMeServices extends PersistentTokenBasedRememberMeServices {
/**
* 自定义前后端分离获取 rememberMe 请求参数
* @param request 请求
* @param rememberMe 记住我参数
* @return 返回boolean
*/
@Override
protected boolean rememberMeRequested(HttpServletRequest request, String rememberMe) {
String paramValue = (String) request.getAttribute(rememberMe);
if (paramValue != null) {
if (paramValue.equalsIgnoreCase("true") || paramValue.equalsIgnoreCase("on")
|| paramValue.equalsIgnoreCase("yes") || paramValue.equals("1")) {
return true;
}
}
this.logger.debug(
LogMessage.format("Did not send remember-me cookie (principal did not set parameter '%s')", paramValue));
return false;
}
public DivPersistentTokenBasedRememberMeServices(String key, UserDetailsService userDetailsService, PersistentTokenRepository tokenRepository) {
super(key, userDetailsService, tokenRepository);
}
}
- 认证数据源
package com.vinjcent.config.security;
import com.vinjcent.pojo.Role;
import com.vinjcent.pojo.User;
import com.vinjcent.service.RoleService;
import com.vinjcent.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;
import java.util.List;
@Component
public class DivUserDetailsService implements UserDetailsService {
// dao ===> springboot + mybatis
private final UserService userService;
private final RoleService roleService;
@Autowired
public DivUserDetailsService(UserService userService, RoleService roleService) {
this.userService = userService;
this.roleService = roleService;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 1.查询用户
User user = userService.queryUserByUsername(username);
if (ObjectUtils.isEmpty(user)) throw new UsernameNotFoundException("用户名不正确!");
// 2.查询权限信息
List<Role> roles = roleService.queryRolesByUid(user.getId());
user.setRoles(roles);
return user;
}
}
- 过滤器适配器
- WebSecurityConfiguration
package com.vinjcent.config.security;
import com.vinjcent.filter.LoginFilter;
import com.vinjcent.handler.DivAuthenticationFailureHandler;
import com.vinjcent.handler.DivAuthenticationSuccessHandler;
import com.vinjcent.handler.DivLogoutSuccessHandler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.AuthenticationManager;
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.web.authentication.RememberMeServices;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl;
import org.springframework.security.web.authentication.rememberme.PersistentTokenBasedRememberMeServices;
import javax.sql.DataSource;
import java.util.UUID;
@Configuration
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
// 注入数据源认证
private final DivUserDetailsService userDetailsService;
// 注入数据源
private final DataSource dataSource;
@Autowired
public WebSecurityConfiguration(DivUserDetailsService userDetailsService, DataSource dataSource) {
this.userDetailsService = userDetailsService;
this.dataSource = dataSource;
}
// 自定义AuthenticationManager(自定义需要暴露该bean)
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService);
}
// 暴露AuthenticationManager,使得这个bean能在组件中进行注入
@Override
@Bean
public AuthenticationManager authenticationManager() throws Exception {
return super.authenticationManager();
}
@Bean
public LoginFilter loginFilter() throws Exception {
// 1.创建自定义的LoginFilter对象
LoginFilter loginFilter = new LoginFilter();
// 2.设置登陆操作的请求
loginFilter.setFilterProcessesUrl("/login");
// 3.动态设置传递的参数key
loginFilter.setUsernameParameter("uname"); // 指定 json 中的用户名key
loginFilter.setPasswordParameter("passwd"); // 指定 json 中的密码key
// 4.设置自定义的用户认证管理者
loginFilter.setAuthenticationManager(authenticationManager());
// 5.配置认证成功/失败处理(前后端分离)
loginFilter.setAuthenticationSuccessHandler(new DivAuthenticationSuccessHandler()); // 认证成功处理
loginFilter.setAuthenticationFailureHandler(new DivAuthenticationFailureHandler()); // 认证失败处理
// 6.设置认证成功时使用自定义 rememberMeServices
// 下面也设置了一次,因为第一次认证需要生成token传递给客户端,第二次是因为,当session过期之后,能够从数据库中去查找对应的持久化记录(二者缺一不可)
loginFilter.setRememberMeServices(rememberMeServices());
return loginFilter;
}
// 自定义rememberMeServices
@Bean
public RememberMeServices rememberMeServices() {
// 使用持久化存储数据
JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
// 设置持久化数据源
tokenRepository.setDataSource(dataSource);
return new DivPersistentTokenBasedRememberMeServices(UUID.randomUUID().toString(), userDetailsService, tokenRepository);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeHttpRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.rememberMe() // 开启记住我功能
// 1.认证成功之后根据记住我,将 cookie 保存到客户端
// 2.只有 cookie 写入到客户端成功才能实现自动登录功能
.rememberMeServices(rememberMeServices()) // 设置自动登录使用哪个 rememberMeServices
.and()
.logout()
.logoutUrl("/logout")
.logoutSuccessHandler(new DivLogoutSuccessHandler())
.and()
.exceptionHandling()
.authenticationEntryPoint(((req, resp, ex) -> {
resp.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
resp.setStatus(HttpStatus.UNAUTHORIZED.value());
resp.getWriter().println("请认证之后再操作!");
}))
.and()
.csrf()
.disable();
// 替换原始 UsernamePasswordAuthenticationFilter 过滤器
http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class);
/**
http.addFilter(); // 添加一个过滤器
http.addFilterAt(); // at: 添加一个过滤器,将过滤链中的某个过滤器进行替换
http.addFilterBefore(); // before: 添加一个过滤器,追加到某个具体过滤器之前
http.addFilterAfter(); // after: 添加一个过滤器,追加到某个具体过滤器之后
*/
}
}
- 测试登陆后,将服务停止,再次开启访问系统资源能够正常访问