SpringSecurity
Spring Security
1 介绍
Java 领域的权限管理框架:Shiro、Spring Security。
Shiro 的优势:
- 轻量
- 简单
- 易于集成
Shiro 的劣势:
- 对 OAuth2 支持不足
- 相对于 Spring Security 而言,Shiro 在 Spring Boot 和 Spring Cloud 的潮流下无法充分展示自己的优势
1.1 过往
Spring Security 早期名为 Acegi Security,Acegi Security 一开始就是为 Spring 提供安全管理框架。
Acegi Security 基于 Spring,可以为项目建立丰富的角色和权限管理,但由于它臃肿繁琐的配置一直被使用者诟病。
Acegi Security 之后更名为 Spring Security,配置也得到了极大的简化,但是与 Shiro 相比,Spring Security 的标签依然是重量级、配置繁琐。
直到 Spring Boot 的登场,Spring Security 终于成功逆袭。
1.2 功能
对于一个权限管理框架而言,无论是 Shiro 还是 Spring Security,最核心的功能,无非就是两方面:
- 认证(登录)
- 授权(鉴权)
Spring Security 支持多种不同的认证方式,这些认证方式有的是 Spring Security 自己提供,有的是由第三方标准组织制订。
比较常见的认证方式:
- HTTP BASIC authentication headers:基于IETF RFC 标准。
- HTTP Digest authentication headers:基于IETF RFC 标准。
- HTTP X.509 client certificate exchange:基于IETF RFC 标准。
- LDAP:跨平台身份认证。
- Form-based authentication:基于表单的身份认证。
- Run-as authentication:用户临时以某一个身份登录。
- OpenID authentication:去中心化认证。
比较冷门的认证方式:
- Jasig Central Authentication Service:单点登录。
- Automatic “remember-me” authentication:记住我登录(允许一些非敏感操作)。
- Anonymous authentication:匿名登录。
- ……
作为开放平台,Spring Security 提供的认证机制不止这些。如果这些认证机制无法满足需求,Spring Security 也支持自己定制认证逻辑。当需要和一些旧的系统进行集成时,自定义认证逻辑就显得非常重要了。
Spring Security 支持基于 URL 的请求授权、方法访问授权以及对象访问授权。
1.3 学习
安全是一个永不过时的话题,这篇文章不仅学习 Spring Security 功能用法,并从安全的角度来了解各种网络攻击,如 XSS(跨站脚本攻击)、CSRF(跨站请求伪造) 等。
2 体验
2.1 新建项目
新建 Spring Boot 项目,引入 Spring Web 和 Spring Security 依赖。
项目创建成功后,添加一个 HelloController 测试接口
1
2
3
4
5
6
7
8
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "Hello SpringSecurity";
}
}
启动项目
在项目启动过程中,控制台会打印如下一行日志:
Using generated security password: 22832bd4-bee9-424d-9fc9-398ee5a733d6
这是 Spring Security 为默认用户 user 生成的临时密码,是一个 UUID 字符串。
接下来在浏览器中去访问 http://localhost:8080/hello
,可以看到浏览器会自动重定向到登录页面。
默认用户名:user
默认密码:项目启动时控制台打印的密码
登录成功后,就可以访问到 hello 接口了
在 Spring Security 中,默认的登录页面和登录接口,都是 /login
,只不过一个是 get 请求(登录页面),另一个是 post 请求(登录接口)。
非常方便,一个依赖就保护了所有接口
🔍源码分析🔎
通过全局搜索控制台打印的 Using generated security password:
查看源码
ReactiveUserDetailsServiceAutoConfiguration#getOrDeducePassword
1
2
3
if (user.isPasswordGenerated()) {
logger.info(String.format("%n%nUsing generated security password: %s%n", user.getPassword()));
}
在 SecurityProperties
中,有如下定义:
1
2
3
4
5
6
7
8
9
/**
* Default user name.
*/
private String name = "user";
/**
* Password for the default user name.
*/
private String password = UUID.randomUUID().toString();
private boolean passwordGenerated = true;
可以看出,默认用户名是 user,默认密码是 UUID,而默认情况下,passwordGenerated 也为 true。
2.2 用户配置
项目重启后默认密码就会改变
因为暂时没有连接数据库,先介绍两种非主流的用户名/密码配置方案
2.2.1 配置文件
可以在 application.properties 中配置默认的用户名和密码。
SecurityProperties 中定义了默认的用户名和密码,它是一个静态内部类,如果想要定义自己的用户名密码,必然是要去覆盖默认的配置。
@ConfigurationProperties(prefix = "spring.security")
public class SecurityProperties {
...
public static class User {
/**
* Default user name.
*/
private String name = "user";
/**
* Password for the default user name.
*/
private String password = UUID.randomUUID().toString();
...
}
}
从 SecurityProperties 配置类中可以清晰的看出需要以 spring.security.user 为前缀,去定义用户名和密码即可
1
2
spring.security.user.name=yueyazhui
spring.security.user.password=123
在 properties 中定义的用户名和密码最终是通过 set 方法注入到属性中去的 SecurityProperties.User#setPassword
1
2
3
4
5
6
7
public void setPassword(String password) {
if (!StringUtils.hasLength(password)) {
return;
}
this.passwordGenerated = false;
this.password = password;
}
可以看到,application.properties 中定义的密码在注入进来之前,顺便设置 passwordGenerated 属性为 false,这个属性设置为 false 之后,控制台就不会打印默认密码了。
此时重启项目,就可以使用自己定义的用户名和密码登录了。
2.2.2 配置类
除了配置文件这种方式之外,也可以在配置类中配置用户名和密码。
在配置类中配置,需要指定 PasswordEncoder
- PasswordEncoder
Spring Security 提供了多种密码加密方案,官方推荐使用 BCryptPasswordEncoder,BCryptPasswordEncoder 使用 BCrypt 强哈希函数,开发者在使用时可以选择提供 strength 和 SecureRandom 实例。strength 越大,密钥的迭代次数越多,密钥迭代次数为 2^strength。strength 取值在 4~31 之间,默认为 10。
不同于 Shiro,Shiro 需要自己处理密码加盐,在 Spring Security 中,BCryptPasswordEncoder 就自带了盐,处理起来非常方便。
而 BCryptPasswordEncoder 就是 PasswordEncoder 接口的实现类。
PasswordEncoder 接口中就定义了三个方法:
1
2
3
4
5
6
7
public interface PasswordEncoder {
String encode(CharSequence rawPassword);
boolean matches(CharSequence rawPassword, String encodedPassword);
default boolean upgradeEncoding(String encodedPassword) {
return false;
}
}
- encode 方法用来对明文密码进行加密,返回加密之后的密文。
- matches 方法是一个密码校对方法,在用户登录时,将用户传来的明文密码和数据库中保存的密文密码作为参数,传入到这个方法中去,根据返回的 Boolean 值判断用户密码是否输入正确。
- upgradeEncoding 是否还要进行再次加密,一般情况下不需要。
通过下图可以看到 PasswordEncoder 的实现类:
- 配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("yue")
.password("123").roles("admin");
}
}
- 自定义 SecurityConfig 继承 WebSecurityConfigurerAdapter,重写 configure 方法。
- 提供了一个 PasswordEncoder 实例,因为目前的案例比较简单,因此暂时先不给密码进行加密,所以返回 NoOpPasswordEncoder 的实例即可。
- configure 方法中,通过 inMemoryAuthentication 来开启在内存中定义用户,withUser 中的是用户名,password 中的是密码,roles 中的是角色。
- 如果需要配置多个用户,用 and 相连。
❓and 相连❓
在 SSM 中使用 Spring Security 时,Spring Security 是在 XML 文件中配置的,既然是 XML 文件,标签就有开始有结束,现在的 and 符号相当于 XML 标签的结束符,表示结束当前标签,这个时候上下文会回到 inMemoryAuthentication 方法中,然后开启新用户的配置。
配置完成后,再次启动项目,Java 代码中的配置会覆盖掉 properties 文件中的配置,此时再去访问 hello 接口,就会发现只有 Java 代码中配置的用户名和密码才能访问成功。
3 自定义表单登录页
默认的表单登录页有点朴素
3.1 服务端定义
首先要完善 SecurityConfig 类,继续重写 configure(WebSecurity web)
和 configure(HttpSecurity http)
方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override
public void configure(WebSecurity web) {
web.ignoring().antMatchers("/js/**", "/css/**","/images/**");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login.html")
.permitAll()
.and()
.csrf().disable();
}
- web.ignoring() 用来配置忽略掉的 URL 地址,一般指静态文件。
- 如果使用 XML 来配置 Spring Security ,这里边会有一个重要的标签
<http></http>
,HttpSecurity 提供的配置方法对应了该标签。- authorizeRequests 对应了
<intercept-url></intercept-url>
。 - formLogin 对应了
<formlogin></formlogin>
。 - and 方法表示结束当前标签,上下文回到 HttpSecurity,开启新一轮的配置。
- permitAll 表示登录相关的页面或接口不要被拦截。
- 最后记得关闭 csrf(跨站请求伪造)。
- authorizeRequests 对应了
当登录页面定义为 /login.html 时,Spring Security 会自动注册一个 /login.html 的接口,这个接口是 POST 请求,用来处理登录逻辑。
3.2 前端定义
准备一个登录页面并将其相关静态文件拷贝到 Spring Boot 项目的 resources/static 目录下
前端核心代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<form action="/login.html" method="post">
<div class="input">
<label for="name">用户名</label>
<input type="text" name="username" id="name">
<span class="spin"></span>
</div>
<div class="input">
<label for="pass">密码</label>
<input type="password" name="password" id="pass">
<span class="spin"></span>
</div>
<div class="button login">
<button type="submit">
<span>登录</span>
<i class="fa fa-check"></i>
</button>
</div>
</form>
在登录 form 表单中,注意 action 为 /login.html
。
配置完成后,重启项目。
4 定制 Spring Security 中的表单登录
4.1 登录接口
在 Spring Security 中,如果不做任何配置,默认登录页面和登录接口的地址都是 /login
- 登录页面 GET http://localhost:8080/login
- 登录接口 POST http://localhost:8080/login
之前,在 SecurityConfig 中自定义了登录页面地址,如下:
1
2
3
.formLogin()
.loginPage("/login.html")
.permitAll()
当将 loginPage 配置为 /login.html
时,登录页面的地址就变为 /login.html
。实际上它还有一个隐藏的操作,那就是登录接口的地址也变为了 /login.html
。
- 登录页面 GET http://localhost:8080/login.html
- 登录接口 POST http://localhost:8080/login.html
登录页面和登录接口分开配置
在 SecurityConfig 中,可以通过 loginProcessingUrl 方法来指定登录接口地址,如下:
1
2
3
4
.formLogin()
.loginPage("/login.html")
.loginProcessingUrl("/login")
.permitAll()
此时还需修改登录页面中 action 属性,改为 /login
,如下:
1
2
3
<form action="/login" method="post">
<!-- 省略 -->
</form>
重启登录。
❓默认情况下登录页面和登录接口的地址是一样的❓
表单登录的相关配置在 FormLoginConfigurer 中,该类继承 AbstractAuthenticationFilterConfigurer ,所以当 FormLoginConfigurer 初始化时,AbstractAuthenticationFilterConfigurer 也会初始化,在 AbstractAuthenticationFilterConfigurer 的构造方法中,可以看到:
1
2
3
protected AbstractAuthenticationFilterConfigurer() {
setLoginPage("/login");
}
这就是 loginPage 的默认配置。
另一方面,FormLoginConfigurer 的初始化 init 方法中也调用了父类的初始化 init 方法:
1
2
3
4
public void init(H http) throws Exception {
super.init(http);
initDefaultLoginFilter(http);
}
而在父类的初始化 init 方法中,又调用了 updateAuthenticationDefaults 方法:
1
2
3
4
5
6
protected final void updateAuthenticationDefaults() {
if (loginProcessingUrl == null) {
loginProcessingUrl(loginPage);
}
...
}
从这个方法的逻辑中就可以看到,如果用户没有给 loginProcessingUrl 赋值的话,默认就使用 loginPage 作为 loginProcessingUrl。
而如果用户配置了 loginPage,在配置完 loginPage 之后,updateAuthenticationDefaults 方法还是会被调用,此时如果没有配置 loginProcessingUrl,则使用新配置的 loginPage 作为 loginProcessingUrl。
4.2 登录参数
登录表单中的默认参数是 username 和 password
1
2
3
4
5
6
7
<form action="/login.html" method="post">
<input type="text" name="username" id="name">
<input type="password" name="password" id="pass">
<button type="submit">
<span>登录</span>
</button>
</form>
在 FormLoginConfigurer 类的构造方法中,可以看到有配置用户名和密码的方法:
1
2
3
4
5
public FormLoginConfigurer() {
super(new UsernamePasswordAuthenticationFilter(), null);
usernameParameter("username");
passwordParameter("password");
}
在这里,首先调用了父类的构造方法,传入了 UsernamePasswordAuthenticationFilter 实例,该实例将被赋值给父类的 authFilter 属性。
接下来 usernameParameter 方法如下:
1
2
3
4
public FormLoginConfigurer<H> usernameParameter(String usernameParameter) {
getAuthenticationFilter().setUsernameParameter(usernameParameter);
return this;
}
getAuthenticationFilter 实际上是父类的方法,在这个方法中返回了 authFilter 属性,也就是一开始设置的 UsernamePasswordAuthenticationFilter 实例,然后调用该实例的 setUsernameParameter 方法去设置登录用户名的参数:
1
2
3
public void setUsernameParameter(String usernameParameter) {
this.usernameParameter = usernameParameter;
}
当登录请求从浏览器到服务端之后,需要从请求的 HttpServletRequest 中取出来登录用户的用户名和密码
❓怎么取❓
在 UsernamePasswordAuthenticationFilter 类中,有如下两个方法:
1
2
3
4
5
6
protected String obtainPassword(HttpServletRequest request) {
return request.getParameter(passwordParameter);
}
protected String obtainUsername(HttpServletRequest request) {
return request.getParameter(usernameParameter);
}
可以看到,在这个时候,就用到默认配置的 username 和 password 了。
自定义这两个参数:
1
2
3
4
5
6
.formLogin()
.loginPage("/login.html")
.loginProcessingUrl("/login")
.usernameParameter("name")
.passwordParameter("pass")
.permitAll()
修改前端页面:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<form action="/login" method="post">
<div class="input">
<label for="name">用户名</label>
<input type="text" name="name" id="name">
<span class="spin"></span>
</div>
<div class="input">
<label for="pass">密码</label>
<input type="password" name="pass" id="pass">
<span class="spin"></span>
</div>
<div class="button login">
<button type="submit">
<span>登录</span>
<i class="fa fa-check"></i>
</button>
</div>
</form>
重启登录。
5 登录回调
在登录成功之后,就需要分情况处理,有以下两种情况:
- 前后端一体登录
- 前后端分离登录
这两种情况的处理方式不一样。
5.1 登录成功回调
在 Spring Security 中,与登录成功重定向 URL 相关的方法有两个:
- defaultSuccessUrl
- successForwardUrl
首先在配置的时候,defaultSuccessUrl 和 successForwardUrl 只需要配置一个即可,两者区别如下:
- defaultSuccessUrl 有一个重载的方法
- 一个参数的 defaultSuccessUrl 方法;如果在 defaultSuccessUrl 中指定登录成功的跳转页面为
/index
,此时分两种情况,如果是在浏览器中输入登录地址,登录成功后,就直接跳转到/index
,如果是在浏览器中输入其他地址,例如http://localhost:8080/hello
,结果因为没有登录,又重定向到登录页面,此时登录成功后,就不会来到/index
,而是来到/hello
页面。 - 两个参数的 defaultSuccessUrl 方法;第二个参数如果不设置默认为 false,也就是上面的情况,如果设置第二个参数为 true,则 defaultSuccessUrl 的效果和 successForwardUrl 一致。
- 一个参数的 defaultSuccessUrl 方法;如果在 defaultSuccessUrl 中指定登录成功的跳转页面为
- successForwardUrl 表示不管是从哪里来的,登录后一律跳转到 successForwardUrl 指定的地址。例如 successForwardUrl 指定的地址为
/index
,在浏览器地址栏输入http://localhost:8080/hello
,结果因为没有登录,重定向到登录页面,当登录成功后,就会服务端跳转到/index
页面;或者直接在浏览器输入了登录页面地址,登录成功后也是跳转到/index
。
相关配置如下:
1
2
3
4
5
6
7
8
9
10
.formLogin()
// 登录页面
.loginPage("/login.html")
// 登录接口
.loginProcessingUrl("/login")
.usernameParameter("name")
.passwordParameter("pass")
.defaultSuccessUrl("/index", false)
// .successForwardUrl("/index")
.permitAll()
注意:在实际操作中,defaultSuccessUrl 和 successForwardUrl 只需配置一个即可。
5.2 登录失败回调
与登录成功相似,登录失败也是有两个方法:
- failureForwardUrl
- failureUrl
failureForwardUrl 是登录失败后会发生服务端跳转,failureUrl 则是在登录失败之后,会发生重定向。
注意:在实际操作中,failureForwardUrl 和 failureUrl 只需配置一个即可。
6 注销登录
注销登录的默认接口是 /logout
,也可以自己配置。
1
2
3
4
5
6
7
8
.logout()
// .logoutUrl("/logout")
.logoutRequestMatcher(new AntPathRequestMatcher("/logout","POST"))
.logoutSuccessUrl("/index")
.deleteCookies()
.clearAuthentication(true)
.invalidateHttpSession(true)
.permitAll()
注销登录的配置:
- 默认的注销 URL 是
/logout
,是一个 GET 请求,可以通过 logoutUrl 方法来修改默认的注销 URL。 - logoutRequestMatcher 方法不仅可以修改注销 URL,还可以修改请求方式。
- logoutSuccessUrl 表示注销成功后要跳转的页面。
- deleteCookies 用来清除 cookie。
- clearAuthentication 和 invalidateHttpSession 分别表示清除认证信息和使 HttpSession 失效,可以不用配置,默认就会清除。
7 JSON 交互
有状态登录:session
无状态登录:jwt
7.1 无状态登录
7.1.1 有状态
有状态服务,即服务端需要记录每次会话的客户端信息,从而识别客户端身份,根据用户身份进行请求的处理,典型的设计如 Tomcat 中的 session。例如登录:用户登录后,我们把用户的信息保存在服务端 session 中,并且给用户一个 cookie 值,记录对应的 session,然后下次请求,用户携带 cookie 值来(这一步由浏览器自动完成),我们就能识别到对应 session,从而找到用户的信息。
缺点:
- 服务端保存大量数据,增加服务端压力
- 服务端保存用户状态,不支持集群化部署
7.1.2 无状态
在微服务集群中,每个服务对外提供的都是 RESTful 风格的接口。而 RESTful 风格的一个最重要的规范就是:服务的无状态性,即:
- 服务端不会保存客户端请求者的信息
- 客户端的每次请求必须具备自描述信息,通过这些信息识别客户端身份
优点:
- 客户端请求不依赖服务端的信息,多次请求不需要必须访问到同一台服务器
- 服务端的集群和状态对客户端透明
- 服务端可以任意的迁移和伸缩(方便进行集群化部署)
- 减小服务端存储压力
7.1.3 实现无状态
无状态登录的流程:
- 首先客户端发送用户名/密码到服务端进行认证
- 认证通过后,服务端将用户信息加密并编码成一个 token,返回给客户端
- 以后客户端每次发送请求,都需要携带认证的 token
- 服务端对客户端发送来的 token 进行解密,判断是否有效,并且获取用户登录信息
7.1.4 各自优缺点
session
优点:方便,默认即可。
缺点:Android、IOS、小程序默认是没有 cookie 的,如果想用 session,就需要在各自的设备上做适配,一般是指模拟 cookie。
jwt
优点:灵活,token 可以通过普通参数传递,也可以通过请求头传递。
7.2 登录交互
登录请求是一个 POST 请求,但是数据传输格式是 key/value 的形式,这种情况在整个项目中只有一次,其他 POST 请求的数据传输格式都是 JSON;因为在 Spring Security 中,登录的默认数据传输格式就是 key/value 的形式。
7.2.1 默认的数据传输格式
1. 登录成功
之前配置登录成功的处理是通过如下两个方法来配置的:
- defaultSuccessUrl
- successForwardUrl
这两个方法都是配置跳转地址的,适用于前后端一体的开发。
除了这两个方法之外,还有登录成功回调 successHandler。
successHandler 的功能十分强大,甚至已经囊括了 defaultSuccessUrl 和 successForwardUrl 的功能。
1
2
3
4
5
6
7
8
.successHandler((request, response, authentication) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpServletResponse.SC_OK);
PrintWriter writer = response.getWriter();
writer.write(new ObjectMapper().writeValueAsString(authentication.getPrincipal()));
writer.flush();
writer.close();
})
successHandler 方法的参数是一个 AuthenticationSuccessHandler 对象,在这个对象中需要实现 onAuthenticationSuccess 方法。
onAuthenticationSuccess 方法有三个参数,分别是:
- HttpServletRequest
- HttpServletResponse
- Authentication
有了前两个参数,就可以在这里随心所欲的返回数据。
利用 HttpServletRequest 可以做服务端跳转,利用 HttpServletResponse 可以做客户端跳转,当然,也可以返回 JSON 数据。
第三个参数 Authentication 保存着刚刚登录成功的用户信息。
配置完成后,去登录,就可以看到登录成功的用户信息通过 JSON 返回到前端,如下图:
2. 登录失败
登录失败也有一个回调
1
2
3
4
5
6
7
.failureHandler((request, response, exception) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
PrintWriter writer = response.getWriter();
writer.write(new ObjectMapper().writeValueAsString(exception.getMessage()));
writer.flush();
writer.close();
})
第三个参数 Exception,保存了登录失败的信息;
💡注意💡
当用户登录时,用户名或者密码输入错误,一般只会给一个模糊的提示,如【用户名或者密码输入错误,请重新输入】,而不会给一个明确的提示,如【用户名输入错误】或【密码输入错误】,这样做可以防止黑客通过密码字典暴力破解;关于这一方面的安全防护 Spring Security 做的很好。
在 Spting Security 中,用户名查找失败对应的异常是:
- UsernameNotFoundException
密码匹配失败对应的异常是:
- BadCredentialsException
但是在登录失败的回调中,却总是看不到 UsernameNotFoundException 异常,无论用户名输入错误还是密码输入错误,抛出的异常都是 BadCredentialsException。
🔎源码分析🔍
org.springframework.security.authentication.dao.AbstractUserDetailsAuthenticationProvider#authenticate
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
() -> this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.onlySupports",
"Only UsernamePasswordAuthenticationToken is supported"));
String username = determineUsername(authentication);
boolean cacheWasUsed = true;
UserDetails user = this.userCache.getUserFromCache(username);
if (user == null) {
cacheWasUsed = false;
try {
user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
}
catch (UsernameNotFoundException ex) {
this.logger.debug("Failed to find user '" + username + "'");
if (!this.hideUserNotFoundExceptions) {
throw ex;
}
throw new BadCredentialsException(this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract");
}
try {
this.preAuthenticationChecks.check(user);
additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
}
catch (AuthenticationException ex) {
if (!cacheWasUsed) {
throw ex;
}
// There was a problem, so try again after checking
// we're using latest data (i.e. not from the cache)
cacheWasUsed = false;
user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
this.preAuthenticationChecks.check(user);
additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
}
this.postAuthenticationChecks.check(user);
if (!cacheWasUsed) {
this.userCache.putUserInCache(user);
}
Object principalToReturn = user;
if (this.forcePrincipalAsString) {
principalToReturn = user.getUsername();
}
return createSuccessAuthentication(principalToReturn, authentication, user);
}
从这段代码中,可以看出,在查找用户时,如果抛出了 UsernameNotFoundException,这个异常会被捕获,捕获之后,如果 hideUserNotFoundExceptions 属性的值为 true,就抛出一个 BadCredentialsException;相当于将 UsernameNotFoundException 异常隐藏了,默认情况下,hideUserNotFoundExceptions 的值为 true。
一般来说,这个配置是不需要修改的,如果想要区别出 UsernameNotFoundException 和 BadCredentials Exception 异常,思路如下:
-
自定义 DaoAuthenticationProvider 代替系统默认的,在定义时将 hideUserNotFoundExceptions 属性设置为 false。
-
当用户名查找失败时,不抛出 UsernameNotFoundException 异常,而是抛出一个自定义异常,自定义异常是不会被隐藏的。
-
当用户名查找失败时,直接抛出 BadCredentialsException 异常,但异常信息改为【用广名不存在】。
3. 未认证处理方案
没有认证就访问数据,系统默认行为:重定向到登录页面;
但是在前后端分离中,这个逻辑是有问题的,如果用户没有登录就访问一个需要认证后才能访问的页面,这个时候,不应该让用户重定向到登录页面,而是给用户一个尚未登录的提示,前端收到提示后,再自行决定页面跳转。
要解诀这个问题,就涉及到 Spring Security 中的一个接口 AuthenticationEntryPoint,该接口有一个实现类:LoginUrlAuthenticationEntryPoint,该类中有一个方法 commence,如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
* Performs the redirect (or forward) to the login form URL.
* 执行重定向(或转发)到登录表单 URL。
*/
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
if (!this.useForward) {
// redirect to login page. Use https if forceHttps true
String redirectUrl = buildRedirectUrlToLoginPage(request, response, authException);
this.redirectStrategy.sendRedirect(request, response, redirectUrl);
return;
}
String redirectUrl = null;
if (this.forceHttps && "http".equals(request.getScheme())) {
// First redirect the current request to HTTPS. When that request is received,
// the forward to the login page will be used.
redirectUrl = buildHttpsRedirectUrlForRequest(request);
}
if (redirectUrl != null) {
this.redirectStrategy.sendRedirect(request, response, redirectUrl);
return;
}
String loginForm = determineUrlToUseForThisRequest(request, response, authException);
logger.debug(LogMessage.format("Server side forward to: %s", loginForm));
RequestDispatcher dispatcher = request.getRequestDispatcher(loginForm);
dispatcher.forward(request, response);
return;
}
首先从这个方法的注释中可以看出,这个方法是用来决定到底是要重定向还是要 forward,通过 Debug 追踪,发现默认情况 下 useForward 的值为 false,所以请求走进了重定向。
那么解决问题的思路就很简单了,直接重写这个方法,在方法中返回 JSON 即可,不再做重定向操作,具体配置如下:
1
2
3
4
5
6
7
8
9
10
.csrf().disable()
.exceptionHandling()
.authenticationEntryPoint((request, response, authenticationEntryPoint) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpServletResponse.SC_GONE);
PrintWriter writer = response.getWriter();
writer.write(new ObjectMapper().writeValueAsString("尚未登录,请登录"));
writer.flush();
writer.close();
});
在 Spring Security 的配置中,加上自定义的 AuthenticationEntryPoint 处理方法,该方法中直接返回相应的 JSON 提示即可。
4. 注销登录
1
2
3
4
5
6
7
8
9
10
11
.logout()
.logoutRequestMatcher(new AntPathRequestMatcher("/logout", ServletUtil.METHOD_GET))
.logoutSuccessHandler((request, response, authentication) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpServletResponse.SC_OK);
PrintWriter writer = response.getWriter();
writer.write(new ObjectMapper().writeValueAsString("注销成功"));
writer.flush();
writer.close();
})
.permitAll()
SecurityConfig#configure 配置代码,如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
// 登录页面
.loginPage("/login.html")
// 登录接口
.loginProcessingUrl("/login")
.usernameParameter("username")
.passwordParameter("password")
.successHandler((request, response, authentication) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpServletResponse.SC_OK);
PrintWriter writer = response.getWriter();
writer.write(new ObjectMapper().writeValueAsString(authentication.getPrincipal()));
writer.flush();
writer.close();
})
.failureHandler((request, response, exception) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
PrintWriter writer = response.getWriter();
writer.write(new ObjectMapper().writeValueAsString(exception.getMessage()));
writer.flush();
writer.close();
})
.permitAll()
.and()
.logout()
.logoutRequestMatcher(new AntPathRequestMatcher("/logout", ServletUtil.METHOD_GET))
.logoutSuccessHandler((request, response, authentication) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpServletResponse.SC_OK);
PrintWriter writer = response.getWriter();
writer.write(new ObjectMapper().writeValueAsString("注销成功"));
writer.flush();
writer.close();
})
.permitAll()
.and()
.csrf().disable()
.exceptionHandling()
.authenticationEntryPoint((request, response, authenticationEntryPoint) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpServletResponse.SC_GONE);
PrintWriter writer = response.getWriter();
writer.write(new ObjectMapper().writeValueAsString("尚未登录,请登录"));
writer.flush();
writer.close();
});
}
7.2.2 服务端接口调整
用户登录的用户名/密码是在 UsernamePasswordAuthenticationFilter
类中处理的,处理代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@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());
}
String username = obtainUsername(request);
username = (username != null) ? username.trim() : "";
String password = obtainPassword(request);
password = (password != null) ? password : "";
UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username, password);
// Allow subclasses to set the "details" property
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
@Nullable
protected String obtainUsername(HttpServletRequest request) {
return request.getParameter(this.usernameParameter);
}
@Nullable
protected String obtainPassword(HttpServletRequest request) {
return request.getParameter(this.passwordParameter);
}
通过获取用户名/密码的方式(request.getParameter),可以看出 Spring Security 登录的默认数据传输格式是 key/value 形式。
如果想要把登录的传参格式换成 JSON,只需自定义一个过滤器代替 UsernamePasswordAuthenticationFilter
,然后在获取参数时,换一种方式即可。
注意,如果有验证码功能,要连同验证码一起处理。
7.2.3 自定义过滤器
自定义一个过滤器代替 UsernamePasswordAuthenticationFilter
,如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
* @description 登录的传参格式转换(key/value 2 json)
* @author yueyazhui
* @website yueyazhui.top
* @email yueyahzui@sina.com
* @date 2023-05-22 21:34
**/
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
@Override
@SneakyThrows
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if (!ServletUtil.METHOD_POST.equals(request.getMethod())) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
if (MediaType.APPLICATION_JSON_VALUE.equals(request.getContentType()) || MediaType.APPLICATION_JSON_UTF8_VALUE.equals(request.getContentType())) {
Map<String, String> loginData = new ObjectMapper().readValue(request.getInputStream(), Map.class);
String username = Optional.ofNullable(loginData.get(getUsernameParameter())).orElse(StrUtil.EMPTY).trim();
String password = Optional.ofNullable(loginData.get(getPasswordParameter())).orElse(StrUtil.EMPTY);
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
} else {
return super.attemptAuthentication(request, response);
}
}
}
- 登录请求的请求方式肯定是 POST,如果不是 POST ,直接抛异常。
- 处理验证码,从 session 中获取已经下发的验证码。
- 通过 contentType 来判断当前请求是否通过 JSON 来传递参数,如果是通过 JSON 传递参数,则按照 JSON 的方式来解析,如果不是,则调用 super.attemptAuthentication 方法,进入到父类的处理逻辑中,也就是说,这个类,既支持 JSON 的形式传递参数,也支持 key/value 的形式传递参数。
- 如果当前请求是通过 JSON 的形式来传递数据,就可以通过读取 request 中的 I/O 流,将 JSON 映射到一个 Map 上。
- 从 Map 中取出 code,判断验证码是否正确,如果验证码有误,直接抛异常。
- 从 Map 中取出 username 和 password,构造 UsernamePasswordAuthenticationToken 对象并作校验。
过滤器定义完成后,接下来用自定义的过滤器代替默认的 UsernamePasswordAuthenticationFilter
。
首先需要提供一个 LoginFilter 实例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Bean
LoginFilter loginFilter() throws Exception {
LoginFilter loginFilter = new LoginFilter();
loginFilter.setAuthenticationManager(authenticationManagerBean());
loginFilter.setFilterProcessesUrl("/login");
loginFilter.setUsernameParameter("username");
loginFilter.setPasswordParameter("password");
loginFilter.setAuthenticationSuccessHandler((request, response, authentication) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpServletResponse.SC_OK);
PrintWriter writer = response.getWriter();
writer.write(new ObjectMapper().writeValueAsString(Response.success("登录成功", authentication.getPrincipal())));
writer.flush();
writer.close();
});
loginFilter.setAuthenticationFailureHandler((request, response, exception) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
PrintWriter writer = response.getWriter();
Response resp = Response.error(exception.getMessage());
if (exception instanceof LockedException) {
resp.setMessage("账户被锁定,请联系管理员");
} else if (exception instanceof CredentialsExpiredException) {
resp.setMessage("密码过期,请联系管理员");
} else if (exception instanceof AccountExpiredException) {
resp.setMessage("账户过期,请联系管理员");
} else if (exception instanceof DisabledException) {
resp.setMessage("账户被禁用,请联系管理员");
} else if (exception instanceof BadCredentialsException) {
resp.setMessage("用户名或者密码输入错误,请重新输入");
}
writer.write(new ObjectMapper().writeValueAsString(resp));
writer.flush();
writer.close();
});
return loginFilter;
}
代替了 UsernamePasswordAuthenticationFilter
之后,原本在 SecurityConfig#configure 方法中关于 form 表单的配置就会失效,那些失效的属性,都可以在 LoginFilter 的实例中配置。
另外还要配置一个 AuthenticationManager,根据 WebSecurityConfigurerAdapter 中提供的配置即可。
FilterProcessUrl 可以根据实际情况配置,如果不配置,默认的就是 /login
。
最后,用自定义的 LoginFilter 实例代替 UsernamePasswordAuthenticationFilter
,如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.logout()
.logoutRequestMatcher(new AntPathRequestMatcher("/logout", ServletUtil.METHOD_GET))
.logoutSuccessHandler((request, response, authentication) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpServletResponse.SC_OK);
PrintWriter writer = response.getWriter();
writer.write(new ObjectMapper().writeValueAsString("注销成功"));
writer.flush();
writer.close();
})
.permitAll()
.and()
.csrf().disable()
.exceptionHandling()
.authenticationEntryPoint((request, response, authenticationEntryPoint) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpServletResponse.SC_GONE);
PrintWriter writer = response.getWriter();
writer.write(new ObjectMapper().writeValueAsString("尚未登录,请登录"));
writer.flush();
writer.close();
});
http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class);
}
调用 addFilterAt 方法完成替换操作。
登录成功
登录失败
7.2.4 前端修改
默认 key/value 请求,前端登录代码是这样的:
1
2
3
4
5
6
7
8
9
10
11
this.$refs.loginForm.validate((valid) => {
if (valid) {
this.loading = true
this.postKeyValueRequest('/login', this.loginForm).then(response => {
this.loading = false
// 省略
})
} else {
return false
}
})
首先去校验数据,在校验成功之后,通过 postKeyValueRequest 方法来发送登录请求,这个方法是已经封装好的通过 key/value 形式传递参数的 POST 请求,如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
export const postKeyValueRequest = (url, params) => {
return axios({
method: 'post',
url: `${base}${url}`,
data: params,
transformRequest: [function (data) {
let result = ''
for (let i in data) {
result += encodeURIComponent(i) + '=' + encodeURIComponent(data[i]) + '&'
}
return ret;
}],
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
})
}
export const postRequest = (url, params) => {
return axios({
method: 'post',
url: `${base}${url}`,
data: params
})
}
postKeyValueRequest 是通过 key/value 形式传递参数,postRequest 是通过 JSON 形式传递参数。
所以,前端只需对登录请求稍作调整即可,如下:
1
2
3
4
5
6
7
8
9
10
11
this.$refs.loginForm.validate((valid) => {
if (valid) {
this.loading = true;
this.postRequest('/login', this.loginForm).then(response => {
this.loading = false;
//省略
})
} else {
return false
}
})
配置完成,登录,在浏览器控制台中 ,就可以看到登录请求的参数形式了。
8 授权
8.1 授权
所谓的授权,就是用户访问某一个资源时,需要去检查该用户是否具备这样的权限,如果具备就允许访问,如果不具备,则不允许访问。
8.2 准备测试用户
因为现在还没有连接数据库,所以测试用户还是基于内存来配置。
基于内存配置测试用户,有两种方式;
其一:
1
2
3
4
5
6
7
8
9
10
11
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("yue")
.password("123")
.roles("admin")
.and()
.withUser("yueyazhui")
.password("123")
.roles("user");
}
其二:
由于 Spring Security 支持多种数据源,例如内存、数据库、LDAP1等,这些不同来源的数据被共同封装成了一个 UserDetailService 接口,任何实现了该接口的对象都可以作为认证数据源。
因此还可以通过重写 WebSecurityConfigurerAdapter 中的 userDetailsService 方法来提供一个 UserDetailService 实例进而配置多个用户:
1
2
3
4
5
6
7
8
@Bean
@Override
public UserDetailsService userDetailsServiceBean() throws Exception {
InMemoryUserDetailsManager inMemoryUserDetailsManager = new InMemoryUserDetailsManager();
inMemoryUserDetailsManager.createUser(User.withUsername("yue").password("123").roles("admin").build());
inMemoryUserDetailsManager.createUser(User.withUsername("yueyazhui").password("123").roles("user").build());
return inMemoryUserDetailsManager;
}
两种基于内存定义用户的方法,任选其一。
8.3 准备测试接口
接下来准备三个测试接口,如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* @description HelloController
* @author yueyazhui
* @website yueyazhui.top
* @email yueyahzui@sina.com
* @date 2023-04-24 23:42
**/
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "Hello";
}
@GetMapping("/admin/hello")
public String admin() {
return "Admin";
}
@GetMapping("/user/hello")
public String user() {
return "User";
}
}
这三个测试接口的规划是这样的:
- /hello 是任何用户都可以访问的接口
- /admin/hello 是具有 admin 身份的用户才能访问的接口
- /user/hello 是具有 user 身份的用户才能访问的接口
- 所有 user 能够访问的资源,admin 都能够访问
注意第四条规范意味着所有具备 admin 身份的用户自动具备 user 身份。
8.4 配置
接下来配置权限的拦截规则,在 Spring Security 的 configure(HttpSecurity http) 方法中,代码如下:
1
2
3
4
5
6
7
http.authorizeRequests()
.antMatchers("/admin/**").hasRole("admin")
.antMatchers("/user/**").hasRole("user")
.anyRequest().authenticated()
.and()
...
...
这里的匹配规则采用了 Ant 风格的路径匹配符,Ant 风格的路径匹配符在 Spring 家族中使用非常广泛,它的匹配规则也非常简单:
通配符 | 含义 |
---|---|
** | 匹配多层路径 |
* | 匹配一层路径 |
? | 匹配任意单个字符 |
上面配置的含义是:
- 如果请求路径满足
/admin/**
格式,则用户需要具备 admin 角色。 - 如果请求路径满足
/user/**
格式,则用户需要具备 user 角色。 - 剩余的其他格式的请求路径,只需要认证(登录)就可以访问。
注意
在代码中三条规则的配置顺序非常重要,和 Shiro 类似,Spring Security 在匹配的时候也是按照从上往下的顺序来匹配,一旦匹配到就不再继续匹配了。
另一方面,如果强制将 anyRequest 配置在 antMatchers 前面,如下:
1
2
3
4
5
http.authorizeRequests()
.anyRequest().authenticated()
.antMatchers("/admin/**").hasRole("admin")
.antMatchers("/user/**").hasRole("user")
.and()
此时项目在启动的时候,就会报错,会提示不能在 anyRequest 之后添加 antMatchers:
anyRequest 已经包含了所有请求,在它之后如果还配置其他请求便没有了意义。
anyRequest 放在最后,表示除了前面拦截规则之外,剩下的请求要如何处理。
在拦截规则的配置类 AbstractRequestMatcherRegistry 中,可以看到如下一些代码(部分源码):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public abstract class AbstractRequestMatcherRegistry<C> {
// 是否已经配置完成
private boolean anyRequestConfigured = false;
public C anyRequest() {
Assert.state(!this.anyRequestConfigured, "Can't configure anyRequest after itself");
this.anyRequestConfigured = true;
return configurer;
}
public C antMatchers(HttpMethod method, String... antPatterns) {
Assert.state(!this.anyRequestConfigured, "Can't configure antMatchers after anyRequest");
return chainRequestMatchers(RequestMatchers.antMatchers(method, antPatterns));
}
public C antMatchers(String... antPatterns) {
Assert.state(!this.anyRequestConfigured, "Can't configure antMatchers after anyRequest");
return chainRequestMatchers(RequestMatchers.antMatchers(antPatterns));
}
protected final List<MvcRequestMatcher> createMvcMatchers(HttpMethod method,
String... mvcPatterns) {
Assert.state(!this.anyRequestConfigured, "Can't configure mvcMatchers after anyRequest");
return matchers;
}
public C regexMatchers(HttpMethod method, String... regexPatterns) {
Assert.state(!this.anyRequestConfigured, "Can't configure regexMatchers after anyRequest");
return chainRequestMatchers(RequestMatchers.regexMatchers(method, regexPatterns));
}
public C regexMatchers(String... regexPatterns) {
Assert.state(!this.anyRequestConfigured, "Can't configure regexMatchers after anyRequest");
return chainRequestMatchers(RequestMatchers.regexMatchers(regexPatterns));
}
public C requestMatchers(RequestMatcher... requestMatchers) {
Assert.state(!this.anyRequestConfigured, "Can't configure requestMatchers after anyRequest");
return chainRequestMatchers(Arrays.asList(requestMatchers));
}
}
从这段源码中,可以看到,在任何拦截规则之前(包括 anyRequest 自身),都会先判断 anyRequest 是否已经配置,如果已经配置,则会抛出异常,系统启动失败。
8.5 启动测试
启动项目进行测试。
项目启动成功后,首先以 yueyazhui 的身份进行登录:
登录成功后,分别访问 /hello
,/admin/hello
以及 /user/hello
三个接口,其中:
然后以相同的方式以 yue 的身份登录。
8.6 角色继承
在前面提到过一点,所有 user 能够访问的资源,admin 都能够访问,很明显目前的代码还不具备这样的功能。
角色继承:上级具备下级的所有权限。只需在 SecurityConfig 中添加如下代码来配置角色继承关系即可:
1
2
3
4
5
6
@Bean
RoleHierarchy roleHierarchy() {
RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
roleHierarchy.setHierarchy("ROLE_admin > ROLE_user");
return roleHierarchy;
}
在配置时,需要给角色手动的加上 ROLE_
前缀。
上面的配置表示 ROLE_admin
自动具备 ROLE_user
的权限。
配置完成,重启项目,此时 yue 也能访问 /user/hello
这个接口了。
9 SpringSecurity 将用户数据存入数据库
到现在为止,用户都是写死在内存中,还没连上数据库,将用户存入到数据库中。
Spring Security 提供了一个它自己设计好的权限数据库,比较简单,在实际开发中几乎是用不到的,了解一下。
9.1 UserDetailService
Spring Security 支持多种不同的数据源,这些不同的数据源最终都将被封装成 UserDetailsService 的实例,在微人事项目中,是自己来创建一个类实现 UserDetailsService 接口,除了自己封装,也可以使用系统默认提供的 UserDetailsService 实例,例如之前在内存中配置用户的 InMemoryUserDetailsManager 。
UserDetailsService 的实现类:
可以看到,在几个能直接使用的实现类中,除了 InMemoryUserDetailsManager 之外,还有一个 JdbcUserDetailsManager,JdbcUserDetailsManager 可以通过 JDBC 的方式将数据库和 Spring Security 连接起来。
9.2 JdbcUserDetailsManager
JdbcUserDetailsManager 自己提供了一个数据库模型,这个数据库模型保存在如下位置:
1
org/springframework/security/core/userdetails/jdbc/users.ddl
这里存储的脚本内容如下:
1
2
3
create table users(username varchar_ignorecase(50) not null primary key,password varchar_ignorecase(500) not null,enabled boolean not null);
create table authorities (username varchar_ignorecase(50) not null,authority varchar_ignorecase(50) not null,constraint fk_authorities_users foreign key(username) references users(username));
create unique index ix_auth_username on authorities (username,authority);
可以看到,脚本中有一种数据类型 varchar_ignorecase,这个其实是针对 HSQLDB 数据库创建的,而 MySQL 并不支持这种数据类型,所以需要手动调整一下数据类型,将 varchar_ignorecase 改为 varchar 即可。
修改完成后,创建数据库,执行完成后的脚本。
执行完 SQL 脚本后,我们可以看到一共创建了两张表:users 和 authorities。
- users 表中保存用户的基本信息,包括用户名、用户密码以及账户是否可用。
- authorities 中保存了用户的角色。
- authorities 和 users 通过 username 关联的。
配置完成后,用 JdbcUserDetailsManager 代替 InMemoryUserDetailsManager 提供用户数据,如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private final DataSource dataSource;
@Autowired
public SecurityConfig(DataSource dataSource) {
this.dataSource = dataSource;
}
@Bean
@Override
public UserDetailsService userDetailsServiceBean() throws Exception {
JdbcUserDetailsManager jdbcUserDetailsManager = new JdbcUserDetailsManager(dataSource);
if (!jdbcUserDetailsManager.userExists("yue")) {
jdbcUserDetailsManager.createUser(User.withUsername("yue").password("123").roles("admin").build());
}
if (!jdbcUserDetailsManager.userExists("yueyazhui")) {
jdbcUserDetailsManager.createUser(User.withUsername("yueyazhui").password("123").roles("user").build());
}
return jdbcUserDetailsManager;
}
这段配置的含义如下:
- 用 DataSource 对象构建一个 JdbcUserDetailsManager 实例。
- 调用 userExists 方法判断用户是否存在,如果不存在,就创建用户(因为每次项目启动时这段代码都会执行,所以需要加一个判断,避免重复创建用户)。
- 用户的创建方法和之前 InMemoryUserDetailsManager 中的创建方法基本一致。
源码分析:这里的 createUser 或者 userExists 方法其实都是调用已经写好的 SQL 去执行的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class JdbcUserDetailsManager extends JdbcDaoImpl implements UserDetailsManager, GroupManager {
public static final String DEF_USER_EXISTS_SQL = "select username from users where username = ?";
private String userExistsSql = DEF_USER_EXISTS_SQL;
public boolean userExists(String username) {
List<String> users = getJdbcTemplate().queryForList(userExistsSql, new String[] { username }, String.class);
if (users.size() > 1) {
throw new IncorrectResultSizeDataAccessException("More than one user found with name '" + username + "'", 1);
}
return users.size() == 1;
}
}
从这段源码中就可以看出,userExists 方法的执行逻辑:调用 JdbcTemplate 来执行预定义好的 SQL 脚本,进而判断出用户是否存在。
9.3 数据库支持
添加依赖:
1
2
3
4
5
6
7
8
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
在 application.properties 中配置数据库连接:
1
2
3
4
5
6
7
8
9
10
# 数据库驱动
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
# 数据源名称
spring.datasource.name=defaultDataSource
# 数据库连接地址
spring.datasource.url=jdbc:mysql:///learn_spring_security?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowMultiQueries=true
# 数据库用户名
spring.datasource.username=root
# 数据库密码
spring.datasource.password=123456
配置完成,启动项目
项目启动成功后,就可以看到数据库中自动添加了两个用户进来,并且用户都配置好了角色。如下图:
9.4 测试
登录
登录成功后,分别访问 /hello
,/admin/hello
以及 /user/hello
三个接口,其中:
/hello
因为登录就可以访问,所以访问成功。/admin/hello
需要 admin 身份,所以访问失败。/user/hello
需要 user 身份,所以访问成功。
如果在数据库中将用户的 enabled 属性设置为 false,表示禁用该账户。
10 Spring Security + Spring Data Jpa
通过 UserDetailsService 的默认实现 JdbcUserDetailsManager 将用户数据保存在数据库中,但这样做使用起来依然不便,下面将换一种灵活的定义方式,那就是自己来定义授权数据库的模型。
为了操作简单,这里引入 Spring Data Jpa 来帮助完成数据库操作。
10.1 创建工程
创建一个新的 Spring Boot 工程,添加如下依赖:
10.2 准备模型
创建两个实体类,分别表示用户和角色:
实体类父类:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/**
* @description 实体类父类
* @author yueyazhui
* @website yueyazhui.top
* @email yueyahzui@sina.com
* @date 2023-05-27 19:03
**/
@Data
@MappedSuperclass
public class BaseEntity implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "idGenerator")
@GenericGenerator(name = "idGenerator", strategy = "top.yueyazhui.module.util.IdGenerator")
@Column(name = "id", columnDefinition = "varchar(32) COMMENT 'ID'")
private String id;
/**
* 创建时间
*/
@Column(name = "created_time", columnDefinition = "datetime COMMENT '创建时间'")
@CreationTimestamp
private LocalDateTime createdTime;
/**
* 创建人
*/
@Column(name = "created_by", columnDefinition = "varchar(32) DEFAULT '' COMMENT '创建人'")
private String createdBy;
/**
* 上一次修改时间
*/
@Column(name = "last_modified_time", columnDefinition = "datetime DEFAULT NULL COMMENT '上一次修改时间'")
@UpdateTimestamp
private LocalDateTime lastModifiedTime;
/**
* 上一次修改人
*/
@Column(name = "last_modified_by", columnDefinition = "varchar(32) DEFAULT '' COMMENT '上一次修改人'")
private String lastModifiedBy;
/**
* 逻辑删除
*/
@Column(name = "del_flag", columnDefinition = "tinyint(1) DEFAULT '0' COMMENT '逻辑删除'", insertable = false)
private Boolean delFlag;
/**
* 乐观锁
*/
@Version
@Column(name = "version", nullable = false, columnDefinition = "bigint(20) COMMENT '乐观锁'")
private Long version;
}
角色:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* @description 角色Entity
* @author yueyazhui
* @website yueyazhui.top
* @email yueyahzui@sina.com
* @date 2023-05-27 15:33
**/
@Data
@Entity
@Table(name = "sys_role")
@org.hibernate.annotations.Table(appliesTo = "sys_role", comment = "角色")
public class Role extends BaseEntity {
/**
* 编码
*/
@Column(name = "code", nullable = false, unique = true, columnDefinition = "varchar(20) COMMENT '编码'")
private String code;
/**
* 名称
*/
@Column(name = "name", columnDefinition = "varchar(20) DEFAULT NULL COMMENT '名称'")
private String name;
}
这个实体类用来描述角色信息,有角色 ID、角色编码、角色名称,@Entity 表示这是一个实体类,项目启动后,将会根据实体类的属性在数据库中自动创建角色表。
人员基本信息:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/**
* @description 人员基本信息Entity
* @author yueyazhui
* @website yueyazhui.top
* @email yueyahzui@sina.com
* @date 2023-05-28 12:47
**/
@Data
@MappedSuperclass
public class PersonnelBasicInfo extends BaseEntity {
/**
* 姓名
*/
@Column(name = "name", columnDefinition = "varchar(20) COMMENT '姓名'")
private String name;
/**
* 昵称
*/
@Column(name = "nickname", columnDefinition = "varchar(20) COMMENT '昵称'")
private String nickname;
/**
* 头像
*/
@Column(name = "avatar", columnDefinition = "varchar(255) COMMENT '头像'")
private String avatar;
/**
* 性别
*/
@Column(name = "gender", columnDefinition = "char(1) COMMENT '性别'")
private String gender;
/**
* 出生日期
*/
@Column(name = "birthday", columnDefinition = "date COMMENT '出生日期'")
private LocalDate birthday;
/**
* 邮箱
*/
@Column(name = "email", columnDefinition = "varchar(20) COMMENT '邮箱'")
private String email;
/**
* 手机号
*/
@Column(name = "mobile", columnDefinition = "varchar(11) COMMENT '手机号'")
private String mobile;
/**
* 身份证号
*/
@Column(name = "id_card", columnDefinition = "varchar(18) COMMENT '身份证号'")
private String idCard;
}
用户:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
/**
* @description 用户Entity
* @author yueyazhui
* @website yueyazhui.top
* @email yueyahzui@sina.com
* @date 2023-05-27 15:32
**/
@Data
@Entity
@Table(name = "sys_user")
@org.hibernate.annotations.Table(appliesTo = "sys_user", comment = "用户")
public class User extends PersonnelBasicInfo implements UserDetails {
/**
* 用户名
*/
@Column(name = "username", nullable = false, unique = true, columnDefinition = "varchar(20) COMMENT '用户名'")
private String username;
/**
* 密码
*/
@Column(name = "password", nullable = false, columnDefinition = "varchar(255) COMMENT '密码'")
private String password;
/**
* 账户没有过期
*/
@Column(name = "account_non_expired", nullable = false, columnDefinition = "tinyint(1) DEFAULT 1 COMMENT '账户没有过期'", insertable = false)
private boolean accountNonExpired;
/**
* 账户没有锁定
*/
@Column(name = "account_non_locked", nullable = false, columnDefinition = "tinyint(1) DEFAULT 1 COMMENT '账户没有锁定'")
private boolean accountNonLocked = true;
/**
* 凭证没有过期
*/
@Column(name = "credentials_non_expired", nullable = false, columnDefinition = "tinyint(1) DEFAULT 1 COMMENT '凭证没有过期'", insertable = false)
private boolean credentialsNonExpired;
/**
* 启用
*/
@Column(name = "enabled", nullable = false, columnDefinition = "tinyint(1) DEFAULT 1 COMMENT '启用'")
private boolean enabled = true;
/**
* 角色列表
*/
@ManyToMany(targetEntity = Role.class, fetch = FetchType.EAGER, cascade = CascadeType.PERSIST)
@JoinTable(name = "sys_user_role", joinColumns = {@JoinColumn(name = "user_id", referencedColumnName = "id", nullable = false, columnDefinition = "varchar(32) COMMENT '用户ID'")}, inverseJoinColumns = {@JoinColumn(name = "role_id", referencedColumnName = "id", nullable = false, columnDefinition = "varchar(32) COMMENT '角色ID'")})
private List<Role> roleList;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
for (Role role : getRoleList()) {
authorities.add(new SimpleGrantedAuthority(role.getCode()));
}
return authorities;
}
}
用户实体类需要实现 UserDetails 接口,并实现接口中的方法。
- isAccountNonExpired、isAccountNonLocked、isCredentialsNonExpired、isEnabled 这四个属性是用来描述用户状态的,依次表示账户是否没有过期、账户是否没有被锁定、密码是否没有过期、以及账户是否可用。
- roleList 属性表示用户的角色列表,User 和 Role 是多对多关系,用一个 @ManyToMany 和 @JoinTable 注解来描述。
- getAuthorities 方法返回用户的角色信息。
10.3 配置
数据模型准备好之后,再定义一个 UserDao:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* @description 用户Dao
* @author yueyazhui
* @website yueyazhui.top
* @email yueyahzui@sina.com
* @date 2023-05-27 16:23
**/
public interface UserDao extends JpaRepository<User, String> {
/**
* 根据用户名查询用户列表
* @param username
* @return
*/
User findUserByUsername(String username);
}
UserDao 这个接口类只需继承 JpaRepository 然后提供一个根据用户名查询用户列表的方法即可。
接下来定义 UserService ,如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* @description 用户Service
* @author yueyazhui
* @website yueyazhui.top
* @email yueyahzui@sina.com
* @date 2023-05-27 16:29
**/
@Service
public class UserService implements UserDetailsService {
private final UserDao userDao;
public UserService(UserDao userDao) {
this.userDao = userDao;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userDao.findUserByUsername(username);
if (ObjectUtil.isEmpty(user)) {
throw new UsernameNotFoundException("用户不存在");
}
return user;
}
}
自定义的 UserService 需要实现 UserDetailsService 接口并实现接口中的方法 loadUserByUsername ,该方法的参数就是用户在登录的时传入的用户名,根据用户名去查询用户信息(查出来之后,系统会自动进行密码比对)。
在 SecurityConfig 中,通过如下方式来配置用户:
1
2
3
4
5
6
7
8
9
10
private final UserService userService;
public SecurityConfig(UserService userService) {
this.userService = userService;
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userService);
}
注意,还是重写 configure 方法,只不过这次不是基于内存,也不是基于 JdbcUserDetailsManager,而是使用自定义的 UserService。
最后,在 application.properties 中配置数据库和 JPA 的基本信息,如下:
1
2
3
4
5
spring.jpa.database=mysql
spring.jpa.database-platform=mysql
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL57Dialect
10.4 测试
项目启动后,会发现数据库中多了三张表(根据实体类自动创建出来):
💡自动创建出来的表字段是无序的(默认根据字母的顺序排序)
原因:org.hibernate.cfg.PropertyContainer 对字段的处理采用的是 TreeMap,而 TreeMap 是无序的;
解决方案:利用 JVM 对类的加载顺序覆盖掉 org.hibernate.cfg.PropertyContainer,把 该类中的 TreeMap 全部替换成 LinkedHashMap。
💡有些开发者习惯把实体类父类字段(除 ID 之外)放在最后或者是想把某些字段放在最后
org.hibernate.cfg.InheritanceState.ElementsToProcess#ElementsToProcess
1 2 3 4 5 private ElementsToProcess(List<PropertyData> properties, int idPropertyCount) { // TODO 调整字段顺序(全局) this.properties = properties; this.idPropertyCount = idPropertyCount; }
首先添加两条测试数据,在单元测试中添加如下方法,执行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Autowired
UserDao userDao;
@Test
void test01() {
User user1 = new User();
user1.setUsername("yue");
user1.setPassword("123");
List<Role> roleList1 = new ArrayList<>();
Role role1 = new Role();
role1.setCode("ROLE_admin");
role1.setName("管理员");
roleList1.add(role1);
user1.setRoleList(roleList1);
userDao.save(user1);
User user2 = new User();
user2.setUsername("yueyazhui");
user2.setPassword("123");
List<Role> roleList2 = new ArrayList<>();
Role role2 = new Role();
role2.setCode("ROLE_user");
role2.setName("普通用户");
roleList2.add(role2);
user2.setRoleList(roleList2);
userDao.save(user2);
}
查看表中的数据
用户表:
角色表:
用户和角色关联表:
登录测试:
以 yueyazhui 的身份进行登录:
登录成功返回信息:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
{
"status": 200,
"message": "登录成功",
"data": {
"id": "1662808323959439360",
"createdTime": "2023-05-28 21:09:33",
"createdBy": null,
"lastModifiedTime": "2023-05-28 21:09:33",
"lastModifiedBy": null,
"delFlag": false,
"version": 0,
"name": null,
"nickname": null,
"avatar": null,
"gender": null,
"birthday": null,
"email": null,
"mobile": null,
"idCard": null,
"username": "yueyazhui",
"password": "123",
"accountNonExpired": true,
"accountNonLocked": true,
"credentialsNonExpired": true,
"enabled": true,
"roleList": [
{
"id": "1662808323959439361",
"createdTime": "2023-05-28 21:09:33",
"createdBy": null,
"lastModifiedTime": "2023-05-28 21:09:33",
"lastModifiedBy": null,
"delFlag": false,
"version": 0,
"code": "ROLE_user",
"name": "普通用户"
}
],
"authorities": [
{
"authority": "ROLE_user"
}
]
}
}
登录成功后,分别访问 /hello
,/admin/hello
以及 /user/hello
三个接口,其中:
/hello
因为登录就可以访问,所以访问成功。/admin/hello
需要 admin 身份,所以访问失败。/user/hello
需要 user 身份,所以访问成功。
10.5 自定义用户和角色关联表
通过 @ManyToMany 和 @JoinTable 注解生成出来的用户和角色关联表,只有两个字段用户ID和角色ID,这样的关联表很难在业务中扩展,在实际项目开发中,通常会自定义关联表。
- 用户和角色关联实体:UserRole.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* @description 用户和角色关联Entity
* @author yueyazhui
* @website yueyazhui.top
* @email yueyahzui@sina.com
* @date 2023-05-31 21:15
**/
@Data
@Entity
@Table(name = "sys_user_role")
@org.hibernate.annotations.Table(appliesTo = "sys_user_role", comment = "用户和角色关联")
public class UserRole extends BaseEntity {
/**
* 用户ID
*/
@Column(name = "user_id", nullable = false, columnDefinition = "varchar(32) COMMENT '用户ID'")
private String userId;
/**
* 角色ID
*/
@Column(name = "role_id", nullable = false, columnDefinition = "varchar(32) COMMENT '角色ID'")
private String roleId;
}
- 用户实体:User.java
角色列表 List
小编之所以留着,是因为,之前在 JpaTest#test1 方法中用到了该属性;
1
2
3
4
5
6
7
/**
* 角色列表
*/
// @ManyToMany(targetEntity = Role.class, fetch = FetchType.EAGER, cascade = CascadeType.PERSIST)
// @JoinTable(name = "sys_user_role", joinColumns = {@JoinColumn(name = "user_id", referencedColumnName = "id", nullable = false, columnDefinition = "varchar(32) COMMENT '用户ID'")}, inverseJoinColumns = {@JoinColumn(name = "role_id", referencedColumnName = "id", nullable = false, columnDefinition = "varchar(32) COMMENT '角色ID'")})
@Transient
private List<Role> roleList;
重写 User#getAuthorities 方法,之前因为 roleList 属性被 @ManyToMany 和 @JoinTable 注解标记,获取用户时会关联查询角色列表,而当下需要自己去查询角色列表。
1
2
3
4
5
6
7
8
9
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<Role> roleList = RoleService.findRoleListByUserId(getId());
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
for (Role role : roleList) {
authorities.add(new SimpleGrantedAuthority(role.getCode()));
}
return authorities;
}
角色Service:RoleService.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
**
* @description 角色Service
* @author yueyazhui
* @website yueyazhui.top
* @email yueyahzui@sina.com
* @date 2023-05-31 22:44
**/
@Service
public class RoleService {
private static RoleDao roleDao;
public RoleService(RoleDao roleDao) {
RoleService.roleDao = roleDao;
}
/**
* 根据用户ID查询角色列表
* @param userId
* @return
*/
public static List<Role> findRoleListByUserId(String userId) {
return roleDao.findRoleListByUserId(userId);
}
}
角色Dao:RoleDao.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* @description 角色Dao
* @author yueyazhui
* @website yueyazhui.top
* @email yueyahzui@sina.com
* @date 2023-05-27 16:23
**/
public interface RoleDao extends JpaRepository<Role, String> {
/**
* 根据用户ID查询角色列表
* @param userId
* @return
*/
@Query("SELECT r FROM Role r LEFT JOIN UserRole ur ON ur.roleId = r.id WHERE ur.userId = :uid")
List<Role> findRoleListByUserId(@Param("uid") String userId);
}
- 测试:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Test
void test2() {
User user1 = new User();
user1.setUsername("yue");
user1.setPassword("123");
userDao.save(user1);
Role role1 = new Role();
role1.setCode("ROLE_admin");
role1.setName("管理员");
roleDao.save(role1);
UserRole userRole1 = new UserRole();
userRole1.setUserId(user1.getId());
userRole1.setRoleId(role1.getId());
userRoleDao.save(userRole1);
User user2 = new User();
user2.setUsername("yueyazhui");
user2.setPassword("123");
userDao.save(user2);
Role role2 = new Role();
role2.setCode("ROLE_user");
role2.setName("普通用户");
roleDao.save(role2);
UserRole userRole2 = new UserRole();
userRole2.setUserId(user2.getId());
userRole2.setRoleId(role2.getId());
userRoleDao.save(userRole2);
}
登录成功响应:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
{
"status": 200,
"message": "登录成功",
"data": {
"id": "1664286888571944960",
"createdTime": "2023-06-01 23:04:50",
"createdBy": null,
"lastModifiedTime": "2023-06-01 23:04:50",
"lastModifiedBy": null,
"delFlag": false,
"version": 0,
"name": null,
"nickname": null,
"avatar": null,
"gender": null,
"birthday": null,
"email": null,
"mobile": null,
"idCard": null,
"username": "yue",
"password": "123",
"accountNonExpired": true,
"accountNonLocked": true,
"credentialsNonExpired": true,
"enabled": true,
"roleList": null,
"authorities": [
{
"authority": "ROLE_admin"
}
]
}
}
11 自动登录
11.1 实战代码
想要实现记住我这个功能,只需要在 SecurityConfig 中添加如下代码即可:
1
2
3
4
5
6
7
8
9
10
11
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.rememberMe()
.and()
.csrf().disable();
}
可以看到,有效代码只添加了一行代码 .rememberMe()
。
测试:
重启项目,访问 hello 接口,此时会自动跳转到登录页面:
默认的登录页面多了一个记住我的选项。
输入用户名密码,并勾选上记住我,然后点击登录按钮执行登录操作:
可以看到,登录传递参数中,除了 username 和 password 之外,还有一个 remember-me,如果需要自定义登录页面,RememberMe 这个选项的参数传递照着写就可以了。
登录成功之后,会自动跳转到 hello 接口了。
注意,系统访问 hello 接口时,携带的 Cookie:
这里多了一个 remember-me,这就是实现该功能的核心。
接下来,关闭浏览器,再重新打开浏览器。正常情况下,浏览器关闭再重新打开,如果需要再次访问 hello 接口,就需要重新登录。但此时,再去访问 hello 接口,就不需要重新登录,直接就可以访问,说明 RememberMe 的配置(自动登录功能)生效了。
11.2 原理分析
首先需要分析的是 cookie 中携带的参数 remember-me,它的值是一个 Base64 转码后的字符串,可以用Base64编码转换在线工具来解码,也可以用 Java 代码来解码:
1
2
3
4
5
6
7
8
9
10
11
@Slf4j
@SpringBootTest
public class Base64Test {
@Test
public void test1() {
String src = "eXVleWF6aHVpOjE2ODY5MzAwODg1OTk6NDQwYTA3NmI3YTRjNTA3NmI1NjkzMWM3YzU4YjdhZjg";
String decode = new String(Base64.getDecoder().decode(src), StandardCharsets.UTF_8);
log.info(StrUtil.format(")_({}", decode));
}
}
执行这段代码,输出结果如下:
1
)_(yueyazhui:1686930088599:440a076b7a4c5076b56931c7c58b7af8
可以看到,这段 Base64 字符串实际上是用:
隔开的,分成了三个部分:
-
第一部分是用户名。
-
第二部分是时间戳(后三位是毫秒),通过时间戳转换在线工具或者 Java 代码解析后发现,这是一个两周后的时间戳。
1 2 3 4 5 6 7 8 9
@Slf4j @SpringBootTest public class TimestampTest { @Test public void test1() { log.info(StrUtil.format(")_({}", DateUtil.format(new Date(1686930088599L), DatePattern.NORM_DATETIME_MS_PATTERN))); } }
1
)_(2023-06-16 23:41:28.599
-
第三部分是使用 MD5 散列函数算出来的值,它的明文格式是
username + ":" + tokenExpiryTime + ":" + password + ":" + key
,最后的 key 是一个散列盐值,用来防止令牌被修改。
了解了 cookie 中 remember-me 的含义之后,对于记住我的登录流程也就基本清楚了。
在浏览器关闭又重新打开之后,用户去访问 hello 接口,此时会携带 cookie 中的 remember-me 到服务端,服务端拿到值之后,可以获取到用户名和过期时间,再根据用户名查询到用户密码,然后通过 MD5 散列函数计算出散列值,再将计算出的散列值和浏览器传递过来的散列值进行对比,就能确认这个令牌是否有效。
11.3 源码分析
主要从两方面来分析,一个是 remember-me 这个令牌生成的过程,另一个则是它解析的过程。
11.3.1 生成
核心方法:TokenBasedRememberMeServices#onLoginSuccess
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Override
public void onLoginSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication successfulAuthentication) {
String username = retrieveUserName(successfulAuthentication);
String password = retrievePassword(successfulAuthentication);
// If unable to find a username and password, just abort as
// TokenBasedRememberMeServices is
// unable to construct a valid token in this case.
if (!StringUtils.hasLength(username)) {
this.logger.debug("Unable to retrieve username");
return;
}
if (!StringUtils.hasLength(password)) {
UserDetails user = getUserDetailsService().loadUserByUsername(username);
password = user.getPassword();
if (!StringUtils.hasLength(password)) {
this.logger.debug("Unable to obtain password for user: " + username);
return;
}
}
int tokenLifetime = calculateLoginLifetime(request, successfulAuthentication);
long expiryTime = System.currentTimeMillis();
// SEC-949
expiryTime += 1000L * ((tokenLifetime < 0) ? TWO_WEEKS_S : tokenLifetime);
String signatureValue = makeTokenSignature(expiryTime, username, password);
setCookie(new String[] { username, Long.toString(expiryTime), signatureValue }, tokenLifetime, request,
response);
if (this.logger.isDebugEnabled()) {
this.logger.debug(
"Added remember-me cookie for user '" + username + "', expiry: '" + new Date(expiryTime) + "'");
}
}
该方法的逻辑:
-
从登录成功的 Authentication 中提取出用户名/密码。
-
由于登录成功之后,密码可能被擦除,所以,如果一开始没有拿到密码,就再从 UserDetailsService 中重新加载用户并重新获取密码。
-
获取令牌的有效期,令牌有效期默认是两周。
-
调用 makeTokenSignature 方法去计算散列值,实际上就是根据 username、令牌有效期以及 password、key 一起计算出一个散列值。如果自己没有去设置这个 key,默认是在 RememberMeConfigurer#getKey 方法中进行设置的,它的值是一个 UUID 字符串。
TokenBasedRememberMeServices#makeTokenSignature
1 2 3 4 5 6 7 8 9 10
protected String makeTokenSignature(long tokenExpiryTime, String username, String password) { String data = username + ":" + tokenExpiryTime + ":" + password + ":" + getKey(); try { MessageDigest digest = MessageDigest.getInstance("MD5"); return new String(Hex.encode(digest.digest(data.getBytes()))); } catch (NoSuchAlgorithmException ex) { throw new IllegalStateException("No MD5 algorithm available!"); } }
RememberMeConfigurer#getKey
1 2 3 4 5 6 7 8 9 10 11
private String getKey() { if (this.key == null) { if (this.rememberMeServices instanceof AbstractRememberMeServices) { this.key = ((AbstractRememberMeServices) this.rememberMeServices).getKey(); } else { this.key = UUID.randomUUID().toString(); } } return this.key; }
-
将用户名、令牌有效期以及计算得到的散列值放入 Cookie 中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
protected void setCookie(String[] tokens, int maxAge, HttpServletRequest request, HttpServletResponse response) { String cookieValue = encodeCookie(tokens); Cookie cookie = new Cookie(this.cookieName, cookieValue); cookie.setMaxAge(maxAge); cookie.setPath(getCookiePath(request)); if (this.cookieDomain != null) { cookie.setDomain(this.cookieDomain); } if (maxAge < 1) { cookie.setVersion(1); } cookie.setSecure((this.useSecureCookie != null) ? this.useSecureCookie : request.isSecure()); cookie.setHttpOnly(true); response.addCookie(cookie); }
关于第4点,再补充一下
由于自己没有设置 key,key 的默认值是一个 UUID 字符串,这样会带来一个问题,就是如果服务端重启,这个 key 会变,这样就导致之前派发出去的所有 remember-me 自动登录的令牌失效,所以,在开启 remember-me 功能的同时一般需要指定这个 key。指定方式如下:
1
2
3
4
5
6
7
8
9
10
11
12
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.rememberMe()
.key("yueyazhui")
.and()
.csrf().disable();
}
如果自己配置了 key,即使服务端重启,即使浏览器关闭再打开,也不需要重新登录。
这就是 remember-me 令牌生成的过程。至于是如何走到 onLoginSuccess 方法的,下面是源码跟踪路线
graph TB
A(AbstractAuthenticationProcessingFilter#doFilter)
A --> B(AbstractAuthenticationProcessingFilter#successfulAuthentication)
B --> C(AbstractRememberMeServices#loginSuccess)
C --> D(TokenBasedRememberMeServices#onLoginSuccess)
11.3.2 解析
Spring Security 中的一系列功能都是通过一个过滤器链实现的,RememberMe 这个功能也不例外。
Spring Security 中提供了 RememberMeAuthenticationFilter 类专门用来做相关的事情。
RememberMeAuthenticationFilter#doFilter:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
if (SecurityContextHolder.getContext().getAuthentication() != null) {
this.logger.debug(LogMessage
.of(() -> "SecurityContextHolder not populated with remember-me token, as it already contained: '"
+ SecurityContextHolder.getContext().getAuthentication() + "'"));
chain.doFilter(request, response);
return;
}
Authentication rememberMeAuth = this.rememberMeServices.autoLogin(request, response);
if (rememberMeAuth != null) {
// Attempt authenticaton via AuthenticationManager
try {
rememberMeAuth = this.authenticationManager.authenticate(rememberMeAuth);
// Store to SecurityContextHolder
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(rememberMeAuth);
SecurityContextHolder.setContext(context);
onSuccessfulAuthentication(request, response, rememberMeAuth);
this.logger.debug(LogMessage.of(() -> "SecurityContextHolder populated with remember-me token: '"
+ SecurityContextHolder.getContext().getAuthentication() + "'"));
this.securityContextRepository.saveContext(context, request, response);
if (this.eventPublisher != null) {
this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(
SecurityContextHolder.getContext().getAuthentication(), this.getClass()));
}
if (this.successHandler != null) {
this.successHandler.onAuthenticationSuccess(request, response, rememberMeAuth);
return;
}
}
catch (AuthenticationException ex) {
this.logger.debug(LogMessage
.format("SecurityContextHolder not populated with remember-me token, as AuthenticationManager "
+ "rejected Authentication returned by RememberMeServices: '%s'; "
+ "invalidating remember-me token", rememberMeAuth),
ex);
this.rememberMeServices.loginFail(request, response);
onUnsuccessfulAuthentication(request, response, ex);
}
}
chain.doFilter(request, response);
}
这个方法最关键的地方在于,如果从 SecurityContextHolder 中无法获取到当前登录用户实例,那么就调用 AbstractRememberMeServices#autoLogin 方法进行登录。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@Override
public final Authentication autoLogin(HttpServletRequest request, HttpServletResponse response) {
String rememberMeCookie = extractRememberMeCookie(request);
if (rememberMeCookie == null) {
return null;
}
this.logger.debug("Remember-me cookie detected");
if (rememberMeCookie.length() == 0) {
this.logger.debug("Cookie was empty");
cancelCookie(request, response);
return null;
}
try {
String[] cookieTokens = decodeCookie(rememberMeCookie);
UserDetails user = processAutoLoginCookie(cookieTokens, request, response);
this.userDetailsChecker.check(user);
this.logger.debug("Remember-me cookie accepted");
return createSuccessfulAuthentication(request, user);
}
catch (CookieTheftException ex) {
cancelCookie(request, response);
throw ex;
}
catch (UsernameNotFoundException ex) {
this.logger.debug("Remember-me login was valid but corresponding user not found.", ex);
}
catch (InvalidCookieException ex) {
this.logger.debug("Invalid remember-me cookie: " + ex.getMessage());
}
catch (AccountStatusException ex) {
this.logger.debug("Invalid UserDetails: " + ex.getMessage());
}
catch (RememberMeAuthenticationException ex) {
this.logger.debug(ex.getMessage());
}
cancelCookie(request, response);
return null;
}
首先提取出 cookie 信息,并对 cookie 信息进行解码,解码之后,再调用 processAutoLoginCookie 方法去做校验。
TokenBasedRememberMeServices#processAutoLoginCookie
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Override
protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request,
HttpServletResponse response) {
if (cookieTokens.length != 3) {
throw new InvalidCookieException(
"Cookie token did not contain 3" + " tokens, but contained '" + Arrays.asList(cookieTokens) + "'");
}
long tokenExpiryTime = getTokenExpiryTime(cookieTokens);
if (isTokenExpired(tokenExpiryTime)) {
throw new InvalidCookieException("Cookie token[1] has expired (expired on '" + new Date(tokenExpiryTime)
+ "'; current time is '" + new Date() + "')");
}
// Check the user exists. Defer lookup until after expiry time checked, to
// possibly avoid expensive database call.
UserDetails userDetails = getUserDetailsService().loadUserByUsername(cookieTokens[0]);
Assert.notNull(userDetails, () -> "UserDetailsService " + getUserDetailsService()
+ " returned null for username " + cookieTokens[0] + ". " + "This is an interface contract violation");
// Check signature of token matches remaining details. Must do this after user
// lookup, as we need the DAO-derived password. If efficiency was a major issue,
// just add in a UserCache implementation, but recall that this method is usually
// only called once per HttpSession - if the token is valid, it will cause
// SecurityContextHolder population, whilst if invalid, will cause the cookie to
// be cancelled.
String expectedTokenSignature = makeTokenSignature(tokenExpiryTime, userDetails.getUsername(),
userDetails.getPassword());
if (!equals(expectedTokenSignature, cookieTokens[2])) {
throw new InvalidCookieException("Cookie token[2] contained signature '" + cookieTokens[2]
+ "' but expected '" + expectedTokenSignature + "'");
}
return userDetails;
}
processAutoLoginCookie 方法的核心流程:
首先判断 cookie 中 remember-me 属性值解码后的数组长度,再获取并判断过期时间,然后获取用户名,根据用户名查询用户(UserDetails),然后通过 MD5 散列函数计算出散列值,再将拿到的散列值和浏览器传递来的散列值进行对比,就能确认这个令牌是否有效,进而确认登录是否有效。
11.4 总结
RememberMe 功能,最核心的就是 cookie 中的令牌,这个令牌突破了 session 的限制,即使服务器重启、即使浏览器关闭再重新打开,只要这个令牌没有过期,就不需要再重新登录。
一旦令牌丢失,别人就可以拿着这个令牌随意登录系统,这是一个非常危险的操作。
但实际上这是一段悖论,为了提高用户体验(减少登录次数),系统不可避免的引出了一些安全性问题,不过可以通过技术将安全风险降到最小。
12 降低安全风险
- 持久化令牌
- 二次校验
12.1 持久化令牌
12.1.1 原理
持久化令牌:以基本的自动登录功能为基础,增加新的校验参数,来提高系统的安全性。
在持久化令牌中,新增两个经过 MD5 散列函数计算的校验参数,一个是 series,另一个是 token。其中,series 只有当用户在使用用户名/密码登录时,才会生成或更新,而 token 只要有新的会话,就会重新生成,这样就可以避免一个用户同时在多端登录,就像 QQ ,一台电脑上登录,就会踢掉在另外一台电脑上的登录,这样用户就会很容易发现账户是否泄漏。
持久化令牌的处理在 PersistentTokenBasedRememberMeServices 类中,之前说的自动化登录的处理是在 TokenBasedRememberMeServices 类中,它们有一个共同的父类:
而用来保存令牌的实体类则是 PersistentRememberMeToken:
1
2
3
4
5
6
7
@Data
public class PersistentRememberMeToken {
private final String username;
private final String series;
private final String tokenValue;
private final Date date;
}
date 表示上一次使用自动登录的时间。
12.1.2 用法
首先需要一张表来记录令牌信息,这张表可以自己定义,也可以使用系统默认提供的 JDBC 来操作,如果使用默认的 JDBC,即 JdbcTokenRepositoryImpl:
1
2
3
4
5
6
7
8
9
public class JdbcTokenRepositoryImpl extends JdbcDaoSupport implements
PersistentTokenRepository {
public static final String CREATE_TABLE_SQL = "create table persistent_logins (username varchar(64) not null, series varchar(64) primary key, "
+ "token varchar(64) not null, last_used timestamp not null)";
public static final String DEF_TOKEN_BY_SERIES_SQL = "select username,series,token,last_used from persistent_logins where series = ?";
public static final String DEF_INSERT_TOKEN_SQL = "insert into persistent_logins (username, series, token, last_used) values(?,?,?,?)";
public static final String DEF_UPDATE_TOKEN_SQL = "update persistent_logins set token = ?, last_used = ? where series = ?";
public static final String DEF_REMOVE_USER_TOKENS_SQL = "delete from persistent_logins where username = ?";
}
根据这些 SQL 定义,可以分析出表的结构:
1
2
3
4
5
6
7
CREATE TABLE `persistent_logins` (
`username` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,
`series` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,
`token` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,
`last_used` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`series`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
首先在数据库中准备好这张表
修改 SecurityConfig,如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
private final UserService userService;
private final DataSource dataSource;
public SecurityConfig(UserService userService, DataSource dataSource) {
this.userService = userService;
this.dataSource = dataSource;
}
@Bean
JdbcTokenRepositoryImpl jdbcTokenRepository() {
JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
jdbcTokenRepository.setDataSource(dataSource);
return jdbcTokenRepository;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.rememberMe()
.key("yueyazhui")
.tokenRepository(jdbcTokenRepository())
.and()
.csrf().disable();
}
提供一个 JdbcTokenRepositoryImpl 实例,并给其配置 DataSource 数据源,最后通过 tokenRepository 将 JdbcTokenRepositoryImpl 实例纳入配置中。
12.1.3 测试
先去访问 /hello
接口,此时会自动跳转到登录页面,然后勾选上“记住我”这个选项,执行登录操作,登录成功后,重启服务器、关闭浏览器再打开,再去访问 /hello 接口,发现依然可以访问,说明持久化令牌配置已经生效。
查看 remember-me 的令牌,如下:
这个令牌经过解析之后,格式如下:
其中,%3D 表示 =
,%2B 表示 +
,所以完全解析后应该是这样:
1
WkmT+wppnqKqH9FWsQgWHg==:+mjzcuCZVzcmpgEbN+FPaA==
此时,查看数据库,发现数据库中的记录与在控制台中看到的 remember-me 令牌解析后的一致。
12.1.4 源码分析
持久化令牌的流程与之前自动登录的流程基本一致,只不过实现类变了,也就是生成令牌/解析令牌的实现变了。
持久化令牌流程的实现类主要是:PersistentTokenBasedRememberMeServices。
令牌生成:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Override
protected void onLoginSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication successfulAuthentication) {
String username = successfulAuthentication.getName();
this.logger.debug(LogMessage.format("Creating new persistent login for user %s", username));
PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(username, generateSeriesData(),
generateTokenData(), new Date());
try {
this.tokenRepository.createNewToken(persistentToken);
addCookie(persistentToken, request, response);
}
catch (Exception ex) {
this.logger.error("Failed to save persistent token ", ex);
}
}
protected String generateSeriesData() {
byte[] newSeries = new byte[this.seriesLength];
this.random.nextBytes(newSeries);
return new String(Base64.getEncoder().encode(newSeries));
}
protected String generateTokenData() {
byte[] newToken = new byte[this.tokenLength];
this.random.nextBytes(newToken);
return new String(Base64.getEncoder().encode(newToken));
}
private void addCookie(PersistentRememberMeToken token, HttpServletRequest request, HttpServletResponse response) {
setCookie(new String[] { token.getSeries(), token.getTokenValue() }, getTokenValiditySeconds(), request,
response);
}
- 登录成功后,获取到用户名。
- 构造一个 PersistentRememberMeToken 实例,generateSeriesData 和 generateTokenData 方法分别用来获取 series 和 token,具体的生成过程就是调用 SecureRandom 生成随机数再进行 Base64 编码,不同于 Math.random 或者 java.util.Random 这种伪随机数,SecureRandom 则采用的是类似于密码学的随机数生成规则,其输出结果较难预测,适合在登录这样的场景下使用。
- 调用 tokenRepository 实例中的 createNewToken 方法,tokenRepository 实际上就是一开始配置的 JdbcTokenRepositoryImpl,所以这行代码实际上就是将 PersistentRememberMeToken 存入到数据库中。
- 最后 addCookie,可以看到,就是添加了 series 和 token。
令牌校验:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@Override
protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request,
HttpServletResponse response) {
if (cookieTokens.length != 2) {
throw new InvalidCookieException("Cookie token did not contain " + 2 + " tokens, but contained '"
+ Arrays.asList(cookieTokens) + "'");
}
String presentedSeries = cookieTokens[0];
String presentedToken = cookieTokens[1];
PersistentRememberMeToken token = this.tokenRepository.getTokenForSeries(presentedSeries);
if (token == null) {
// No series match, so we can't authenticate using this cookie
throw new RememberMeAuthenticationException("No persistent token found for series id: " + presentedSeries);
}
// We have a match for this user/series combination
if (!presentedToken.equals(token.getTokenValue())) {
// Token doesn't match series value. Delete all logins for this user and throw
// an exception to warn them.
this.tokenRepository.removeUserTokens(token.getUsername());
throw new CookieTheftException(this.messages.getMessage(
"PersistentTokenBasedRememberMeServices.cookieStolen",
"Invalid remember-me token (Series/token) mismatch. Implies previous cookie theft attack."));
}
if (token.getDate().getTime() + getTokenValiditySeconds() * 1000L < System.currentTimeMillis()) {
throw new RememberMeAuthenticationException("Remember-me login has expired");
}
// Token also matches, so login is valid. Update the token value, keeping the
// *same* series number.
this.logger.debug(LogMessage.format("Refreshing persistent login token for user '%s', series '%s'",
token.getUsername(), token.getSeries()));
PersistentRememberMeToken newToken = new PersistentRememberMeToken(token.getUsername(), token.getSeries(),
generateTokenData(), new Date());
try {
this.tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(), newToken.getDate());
addCookie(newToken, request, response);
}
catch (Exception ex) {
this.logger.error("Failed to update token: ", ex);
throw new RememberMeAuthenticationException("Autologin failed due to data access problem");
}
return getUserDetailsService().loadUserByUsername(token.getUsername());
}
- 从前端传来的 cookie 中解析出 series 和 token。
- 根据 series 从数据库中查询出一个 PersistentRememberMeToken 实例。
- 如果查出来的 token 与前端传来的 token 不同,说明账号可能被人盗用(别人用你的令牌登录之后,token 会变)。此时根据用户名移除相关的 token,相当于必须重新输入用户名密码登录才能获取新的自动登录权限。
- 校验 token 是否过期。
- 构造新的 PersistentRememberMeToken 对象,并且更新数据库中的 token(新的会话都会对应一个新的 token)。
- 将新的令牌重新添加到 cookie 中返回。
- 根据用户名查询用户信息,再走一波登录流程。
12.2 二次校验
相比于之前的自动登录,持久化令牌的方式已经安全很多了,但依然存在用户身份被盗用的问题,这个问题实际上很难完美解决,能做的,只有当用户身份被盗用时,将损失降低到最小。
另一种方案,二次校验。
二次校验,实现起来要稍微复杂一点。
思路:
为了让用户使用方便,开通了自动登录功能,但是自动登录功能又带来了安全风险,一个规避的办法就是如果用户使用了自动登录功能,可以只让他做一些常规的不敏感操作,例如数据浏览、查看,但是不允许做任何修改、删除操作,如果用户点击了修改、删除按钮,就跳转回登录页面,让用户重新输入密码进行身份认证,然后再允许他执行敏感操作。
这个功能在 Shiro 中有一个比较方便的过滤器可以配置,Spring Security 也一样。
例如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "Hello";
}
@GetMapping("/admin/hello")
public String admin() {
return "Admin";
}
@GetMapping("/user/hello")
public String user() {
return "User";
}
}
- 第一个 /hello 接口,只要认证后就可以访问,无论是通过用户名密码认证还是通过自动登录认证,只要认证了,就可以访问。
- 第二个 /admin/hello 接口,必须要用户名密码认证之后才能访问,如果用户是通过自动登录认证的,则必须重新输入用户名密码才能访问该接口。
- 第三个 /user/hello 接口,必须是通过自动登录认证后才能访问,如果用户是通过用户名密码认证的,则无法访问该接口。
配置:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/admin/hello").fullyAuthenticated()
.antMatchers("/user/hello").rememberMe()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.rememberMe()
.key("yueyazhui")
.tokenRepository(jdbcTokenRepository())
.and()
.csrf().disable();
}
- /user/hello 接口是需要 rememberMe 才能访问。
- /admin/hello 接口是需要 fullyAuthenticated,fullyAuthenticated 不同于 authenticated,fullyAuthenticated 不包含自动登录的形式,而 authenticated 包含自动登录的形式。
- /hello 接口是 authenticated 就能访问。
测试略。
13 登录验证码
- 创建一个简单的验证码生成器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
/**
* @description 验证码生成器
* @author yueyazhui
* @website yueyazhui.top
* @email yueyahzui@sina.com
* @date 2023-06-07 21:15
**/
public class VerificationCodeGenerator {
/**
* 验证码图片的格式
*/
private static final String FORMAT = "JPEG";
/**
* 验证码图片的宽度
*/
private final int WIDTH = 100;
/**
* 验证码图片的高度
*/
private final int HEIGHT = 30;
/**
* 验证码图片的背景颜色(白色)
*/
private final Color BG_COLOR = new Color(255, 255, 255);
/**
* 字体默认字号
*/
private final int FONT_SIZE_DEFAULT = 24;
/**
* 字体字号偏移量
*/
private final int FONT_SIZE_OFFSET = 4;
/**
* 图片字符向下偏移量
*/
private final int FONT_OFFSET = 8;
/**
* 可选字体名称
*/
private String[] fontNames = { "宋体", "楷体", "隶书", "微软雅黑" };
/**
* 可选字体样式
*/
private int[] fontStyles = { Font.PLAIN, Font.BOLD, Font.ITALIC };
/**
* 可选字符
*/
private String codes = "0123456789AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz";
/**
* 验证码字符串长度
*/
private int length = 4;
/**
* 验证码字符串
*/
private String text;
private Random random = RandomUtil.getRandom(false);
/**
* 获取一个随意颜色
* @return
*/
private Color randomColor() {
int red = random.nextInt(150);
int green = random.nextInt(150);
int blue = random.nextInt(150);
return new Color(red, green, blue);
}
/**
* 获取一个随机字体
* @return
*/
private Font randomFont() {
String name = fontNames[random.nextInt(fontNames.length)];
int style = fontStyles[random.nextInt(fontStyles.length)];
int size = random.nextInt(FONT_SIZE_OFFSET) + FONT_SIZE_DEFAULT;
return new Font(name, style, size);
}
/**
* 获取一个随机字符
* @return
*/
private char randomChar() {
return codes.charAt(random.nextInt(codes.length()));
}
/**
* 创建一个空白的 BufferedImage 对象
* @return
*/
private BufferedImage createImage() {
BufferedImage image = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_RGB);
Graphics2D graphics2D = (Graphics2D) image.getGraphics();
graphics2D.setColor(BG_COLOR);
graphics2D.fillRect(0, 0, WIDTH, HEIGHT);
return image;
}
/**
* 获取验证码图片
* @return
*/
public BufferedImage getImage() {
BufferedImage image = createImage();
Graphics2D graphics2D = (Graphics2D) image.getGraphics();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < length; i++) {
String str = CharUtil.toString(randomChar());
sb.append(str);
graphics2D.setColor(randomColor());
graphics2D.setFont(randomFont());
int x = (int) Math.ceil(NumberUtil.div(NumberUtil.mul(i, WIDTH), length));
graphics2D.drawString(str, x, HEIGHT - FONT_OFFSET);
}
this.text = sb.toString();
drawLine(image);
return image;
}
/**
* 绘制干扰线
* @param image
*/
private void drawLine(BufferedImage image) {
Graphics2D graphics2D = (Graphics2D) image.getGraphics();
int num = 5;
for (int i = 0; i < num; i++) {
int x1 = random.nextInt(WIDTH);
int y1 = random.nextInt(HEIGHT);
int x2 = random.nextInt(WIDTH);
int y2 = random.nextInt(HEIGHT);
graphics2D.setColor(randomColor());
graphics2D.setStroke(new BasicStroke(1.5f));
graphics2D.drawLine(x1, y1, x2, y2);
}
}
/**
* 获取验证码文本
* @return
*/
public String getText() {
return text;
}
/**
* 将验证码图片写出到输出流
* @param image
* @param outputStream
* @throws IOException
*/
public static void write(BufferedImage image, OutputStream outputStream) throws IOException {
ImageIO.write(image, FORMAT, outputStream);
}
}
- 获取验证码的接口,生成验证码并将验证码文本存入 Session。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* @description LoginController
* @author yueyazhui
* @website yueyazhui.top
* @email yueyahzui@sina.com
* @date 2023-06-08 21:31
**/
@RestController
public class LoginController {
/**
* 生成验证码
* @param httpSession
* @param httpServletResponse
* @throws IOException
*/
@GetMapping("/generateVerificationCode")
public void generateVerificationCode(HttpSession httpSession, HttpServletResponse httpServletResponse) throws IOException {
VerificationCodeGenerator verificationCodeGenerator = new VerificationCodeGenerator();
BufferedImage image = verificationCodeGenerator.getImage();
String text = verificationCodeGenerator.getText();
httpSession.setAttribute(LoginConstant.VERIFICATION_CODE_SESSION_KEY, text);
VerificationCodeGenerator.write(image, httpServletResponse.getOutputStream());
}
}
- 验证码过滤器,登录请求传递的验证码与 Session 中的验证码比较
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
* @description 验证码
* @author yueyazhui
* @website yueyazhui.top
* @email yueyahzui@sina.com
* @date 2023-06-07 21:05
**/
@Component
public class VerificationCodeFilter extends GenericFilter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
if (ServletUtil.METHOD_POST.equals(request.getMethod()) && LoginConstant.LOGIN_URL.equals(request.getServletPath())) {
String code = request.getParameter("code");
String verificationCode = (String) request.getSession().getAttribute(LoginConstant.VERIFICATION_CODE_SESSION_KEY);
if (StrUtil.isBlank(verificationCode) || !verificationCode.equalsIgnoreCase(code)) {
// 验证码不正确
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
PrintWriter writer = response.getWriter();
writer.write(new ObjectMapper().writeValueAsString(Response.error("验证码错误")));
writer.flush();
writer.close();
return;
}
}
filterChain.doFilter(request, response);
}
}
- 在 SecurityConfig 中配置过滤器,并对获取验证码的接口取消登录认证
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@Override
public void configure(WebSecurity web) {
web.ignoring().antMatchers("/js/**", "/css/**", "/images/**", "/generateVerificationCode");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.addFilterBefore(verificationCodeFilter, UsernamePasswordAuthenticationFilter.class);
http.authorizeRequests()
.antMatchers("/admin/**").hasRole("admin")
.antMatchers("/user/**").hasRole("user")
.anyRequest().authenticated()
.and()
.logout()
.logoutRequestMatcher(new AntPathRequestMatcher(LoginConstant.LOGOUT_URL, ServletUtil.METHOD_GET))
.logoutSuccessHandler((request, response, authentication) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpServletResponse.SC_OK);
PrintWriter writer = response.getWriter();
writer.write(new ObjectMapper().writeValueAsString("注销成功"));
writer.flush();
writer.close();
})
.permitAll()
.and()
.csrf().disable()
.exceptionHandling()
.authenticationEntryPoint((request, response, authenticationEntryPoint) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpServletResponse.SC_GONE);
PrintWriter writer = response.getWriter();
writer.write(new ObjectMapper().writeValueAsString("尚未登录,请登录"));
writer.flush();
writer.close();
});
http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class);
}
- 测试
14 自定义认证逻辑的第二种方式(高级玩法)
之前,LoginFilter 和 VerificationCodeFilter 都是使用了自定义过滤器的思路,这算是一种入门级的自定义认证逻辑,这种方式存在一些问题。
🌰:在添加登录验证码时,为了校验验证码,自定义了一个过滤器,并把这个自定义的过滤器放到 SpringSecurity 的过滤器链中,每次请求都会经过该过滤器。但实际上,只需要登录请求经过该过滤器即可,其他请求是不需要经过该过滤器的。
14.1 认证流程分析
AuthenticationProvider 定义了 Spring Security 中的认证逻辑:
1
2
3
4
5
6
public interface AuthenticationProvider {
Authentication authenticate(Authentication authentication) throws AuthenticationException;
boolean supports(Class<?> authentication);
}
- authenticate 方法用来做验证,就是验证用户身份。
- supports 用来判断当前的 AuthenticationProvider 是否支持对应的 Authentication。
在 Spring Security 中有一个非常重要的对象叫做 Authentication,可以在项目的任何地方注入 Authentication 进而获取当前登录用户信息,Authentication 本身是一个接口,它实际上是对 java.security.Principal 做的进一步封装:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public interface Authentication extends Principal, Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
Object getCredentials();
Object getDetails();
Object getPrincipal();
boolean isAuthenticated();
void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}
- getAuthorities 方法用来获取用户的权限。
- getCredentials 方法用来获取用户凭证(密码)。
- getDetails 方法用来获取用户携带的详细信息,可能是当前请求之类的东西。
- getPrincipal 方法用来获取当前用户,可能是一个用户名,也可能是一个用户对象。
- isAuthenticated 当前用户是否认证成功。
Authentication 作为一个接口,它定义了用户(Principal)的一些基本行为,它有很多实现类:
在这些实现类中,最常用的就是 UsernamePasswordAuthenticationToken,而每一个 Authentication 都有适合它的 AuthenticationProvider 去处理校验。
🌰:处理 UsernamePasswordAuthenticationToken 的 AuthenticationProvider 是 DaoAuthenticationProvider。
所以在 AuthenticationProvider 中看到一个 supports 方法,就是用来判断 AuthenticationProvider 是否支持处理当前的 Authentication。
在一次完整的认证中,可能包含多个 AuthenticationProvider,而这多个 AuthenticationProvider 则由 ProviderManager 进行统一管理。
重点看一下 DaoAuthenticationProvider,因为这是最常用的一个,当使用用户名/密码登录时,用的就是这个类,DaoAuthenticationProvider 的父类是 AbstractUserDetailsAuthenticationProvider:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
public abstract class AbstractUserDetailsAuthenticationProvider
implements AuthenticationProvider, InitializingBean, MessageSourceAware {
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
() -> this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.onlySupports",
"Only UsernamePasswordAuthenticationToken is supported"));
String username = determineUsername(authentication);
boolean cacheWasUsed = true;
UserDetails user = this.userCache.getUserFromCache(username);
if (user == null) {
cacheWasUsed = false;
try {
user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
}
catch (UsernameNotFoundException ex) {
this.logger.debug("Failed to find user '" + username + "'");
if (!this.hideUserNotFoundExceptions) {
throw ex;
}
throw new BadCredentialsException(this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract");
}
try {
this.preAuthenticationChecks.check(user);
additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
}
catch (AuthenticationException ex) {
if (!cacheWasUsed) {
throw ex;
}
cacheWasUsed = false;
user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
this.preAuthenticationChecks.check(user);
additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
}
this.postAuthenticationChecks.check(user);
if (!cacheWasUsed) {
this.userCache.putUserInCache(user);
}
Object principalToReturn = user;
if (this.forcePrincipalAsString) {
principalToReturn = user.getUsername();
}
return createSuccessAuthentication(principalToReturn, authentication, user);
}
@Override
public boolean supports(Class<?> authentication) {
return (UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication));
}
}
AbstractUserDetailsAuthenticationProvider 的代码很长,这里重点关注两个方法:authenticate 和 supports。
authenticate 方法就是用来做认证的方法:
- 从 Authentication 提取出登录用户名。
- 通过用户名去缓存中获取用户对象。
- 如果在缓存中获取不到,然后再调用 retrieveUser 方法去获取当前用户对象,这一步会调用到在登录时重写的 loadUserByUsername 方法,所以这里返回的 user 其实就是登录对象。
- 调用 preAuthenticationChecks.check 方法去校验 user 中的各个状态属性是否正常,例如账户是否被禁用、账户是否被锁定、账户是否过期等。
- additionalAuthenticationChecks 方法是做密码比对的,additionalAuthenticationChecks 方法是一个抽象方法,具体的实现是在 AbstractUserDetailsAuthenticationProvider 的子类中实现的,也就是 DaoAuthenticationProvider。因为 AbstractUserDetailsAuthenticationProvider 作为一个较为通用的父类,处理一些通用的行为,在登录的时候,有的登录方式并不需要密码,所以 additionalAuthenticationChecks 方法一般交给它的子类去实现,在 DaoAuthenticationProvider 类中,additionalAuthenticationChecks 方法就是做密码比对的,在其他的 AuthenticationProvider 中,additionalAuthenticationChecks 方法的作用就不一定了。
- postAuthenticationChecks.check 方法是检查密码是否过期。
- 将用户存入缓存。
- forcePrincipalAsString 属性:是否强制将 Authentication 中的 principal 属性设置为字符串,这个属性在一开始的 UsernamePasswordAuthenticationFilter 类中就是设置为字符串的(即 username),但默认情况下,当用户登录成功后, 该属性值就变成了当前用户对象。之所以会这样,是因为 forcePrincipalAsString 默认为 false,不过这块不用改,就用 false,这样在后期获取当前用户信息时反而方便很多。
- 通过 createSuccessAuthentication 方法构建一个新的 UsernamePasswordAuthenticationToken。
supports 方法主要用来判断当前的 Authentication 是否是 UsernamePasswordAuthenticationToken。
由于 AbstractUserDetailsAuthenticationProvider 已经把 authenticate 和 supports 方法实现了,所以在 DaoAuthenticationProvider 中,主要关注 additionalAuthenticationChecks 方法即可:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
@SuppressWarnings("deprecation")
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
if (authentication.getCredentials() == null) {
throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
String presentedPassword = authentication.getCredentials().toString();
if (!passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
}
}
可以看到,additionalAuthenticationChecks 方法主要用来做密码比对,逻辑也比较简单,就是调用 PasswordEncoder 的 matches 方法做比对,如果密码不对则直接抛出异常。
正常情况下,使用用户名/密码登录,最终都会走到这一步。
而 AuthenticationProvider 都是通过 ProviderManager#authenticate 方法来调用的。由于一次认证可能会存在多个 AuthenticationProvider,所以,在 ProviderManager#authenticate 方法中会逐个遍历 AuthenticationProvider,并调用其 authenticate 方法做认证:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
...
for (AuthenticationProvider provider : getProviders()) {
if (!provider.supports(toTest)) {
continue;
}
...
try {
result = provider.authenticate(authentication);
if (result != null) {
copyDetails(authentication, result);
break;
}
}
catch (AccountStatusException | InternalAuthenticationServiceException ex) {
prepareException(ex, authentication);
throw ex;
}
catch (AuthenticationException ex) {
lastException = ex;
}
}
...
}
可以看到,在这个方法中,会遍历所有的 AuthenticationProvider,并调用它的 authenticate 方法进行认证。
14.2 自定义认证思路
之前通过自定义过滤器,将自定义的过滤器加入到 Spring Security 过滤器链中,进而实现了添加登录验证码的功能,但是这种方式是有弊端的,就是破坏了原有的过滤器链,每次请求都要走一遍验证码过滤器,这样不合理。
改进的思路:
登录请求是调用 AbstractUserDetailsAuthenticationProvider#authenticate 方法进行认证的,在该方法中,又会调用到 DaoAuthenticationProvider#additionalAuthenticationChecks 方法做进一步的校验,去校验用户登录密码。改进的思路就是自定义一个 AuthenticationProvider 代替 DaoAuthenticationProvider,并重写它里边的 additionalAuthenticationChecks 方法,在重写的过程中,加入验证码的校验逻辑即可。
这样既不破坏原有的过滤器链,又实现了自定义认证功能。
常见的手机号码动态登录,也可以使用这种方式来认证。
14.3 代码实现
首先添加验证码库 kaptcha 的依赖,如下:
1
2
3
4
5
<dependency>
<groupId>com.github.penggle</groupId>
<artifactId>kaptcha</artifactId>
<version>2.3.2</version>
</dependency>
然后提供一个实体类用来描述验证码的基本信息:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Component
public class VerificationCodeConfig {
@Bean
Producer verificationCode() {
Properties properties = new Properties();
properties.setProperty("kaptcha.image.width", "150");
properties.setProperty("kaptcha.image.height", "50");
properties.setProperty("kaptcha.textproducer.char.string", "0123456789AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz");
properties.setProperty("kaptcha.textproducer.char.length", "4");
Config config = new Config(properties);
DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
defaultKaptcha.setConfig(config);
return defaultKaptcha;
}
}
这段配置就是提供了验证码图片的宽高、字符库以及生成的验证码字符长度。
接下来提供一个返回验证码图片的接口:
1
2
3
4
5
6
7
8
9
10
@GetMapping("/getVerificationCode")
public void getVerificationCode(HttpSession httpSession, HttpServletResponse httpServletResponse) throws IOException {
httpServletResponse.setContentType(MediaType.IMAGE_JPEG_VALUE);
String text = producer.createText();
httpSession.setAttribute(LoginConstant.VERIFICATION_CODE_SESSION_KEY, text);
BufferedImage image = producer.createImage(text);
try(ServletOutputStream sos = httpServletResponse.getOutputStream()) {
ImageIO.write(image, FileFormatConstant.IMAGE_JPEG, sos);
}
}
生成验证码图片,并将生成的验证码字符存入 HttpSession 中。
💡这里用到了 try-with-resources ,可以自动关闭流。
接下来自定义一个 MyAuthenticationProvider 继承自 DaoAuthenticationProvider,并重写 additionalAuthenticationChecks 方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
public class MyAuthenticationProvider extends DaoAuthenticationProvider {
@Override
protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
String code = request.getParameter(LoginConstant.VERIFICATION_CODE_PARAMETER_NAME);
String verificationCode = (String) request.getSession().getAttribute(LoginConstant.VERIFICATION_CODE_SESSION_KEY);
if (StrUtil.isBlank(code) || StrUtil.isBlank(verificationCode) || !code.equalsIgnoreCase(verificationCode)) {
throw new AuthenticationServiceException("验证码错误");
}
super.additionalAuthenticationChecks(userDetails, authentication);
}
}
- 获取当前请求。
- 从当前请求中拿到验证码参数。
- 从 Session 中获取生成的验证码字符串。
- 两者进行比较,如果验证码输入错误,则直接抛出异常。
- 最后通过 super 调用父类方法,也就是 DaoAuthenticationProvider#additionalAuthenticationChecks 方法,该方法中主要做密码的校验。
MyAuthenticationProvider 定义好之后,就是如何让 MyAuthenticationProvider 代替 DaoAuthenticationProvider。
之前说过,所有的 AuthenticationProvider 都是放在 ProviderManager 中统一管理的,所以接下来就需要自己提供一个 ProviderManager,然后注入自定义的 MyAuthenticationProvider,这一切操作都在 SecurityConfig 中完成:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final UserService userService;
private final DataSource dataSource;
private final VerificationCodeFilter verificationCodeFilter;
public SecurityConfig(UserService userService, DataSource dataSource, VerificationCodeFilter verificationCodeFilter) {
this.userService = userService;
this.dataSource = dataSource;
this.verificationCodeFilter = verificationCodeFilter;
}
@Bean
PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
@Bean
MyAuthenticationProvider myAuthenticationProvider() {
MyAuthenticationProvider myAuthenticationProvider = new MyAuthenticationProvider();
myAuthenticationProvider.setUserDetailsService(userService);
myAuthenticationProvider.setPasswordEncoder(passwordEncoder());
return myAuthenticationProvider;
}
@Bean
@Override
protected AuthenticationManager authenticationManager() throws Exception {
ProviderManager providerManager = new ProviderManager(Arrays.asList(myAuthenticationProvider()));
return providerManager;
}
@Bean
RoleHierarchy roleHierarchy() {
RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
roleHierarchy.setHierarchy("ROLE_admin > ROLE_user");
return roleHierarchy;
}
@Bean
LoginFilter loginFilter() throws Exception {
LoginFilter loginFilter = new LoginFilter();
loginFilter.setAuthenticationManager(authenticationManagerBean());
loginFilter.setFilterProcessesUrl(LoginConstant.LOGIN_URL);
loginFilter.setUsernameParameter("username");
loginFilter.setPasswordParameter("password");
loginFilter.setAuthenticationSuccessHandler((request, response, authentication) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpServletResponse.SC_OK);
PrintWriter writer = response.getWriter();
writer.write(new ObjectMapper().writeValueAsString(Response.success("登录成功", authentication.getPrincipal())));
writer.flush();
writer.close();
});
loginFilter.setAuthenticationFailureHandler((request, response, exception) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
PrintWriter writer = response.getWriter();
Response resp = Response.error(exception.getMessage());
if (exception instanceof LockedException) {
resp.setMessage("账户被锁定,请联系管理员");
} else if (exception instanceof CredentialsExpiredException) {
resp.setMessage("密码过期,请联系管理员");
} else if (exception instanceof AccountExpiredException) {
resp.setMessage("账户过期,请联系管理员");
} else if (exception instanceof DisabledException) {
resp.setMessage("账户被禁用,请联系管理员");
} else if (exception instanceof BadCredentialsException) {
resp.setMessage("用户名或者密码输入错误,请重新输入");
}
writer.write(new ObjectMapper().writeValueAsString(resp));
writer.flush();
writer.close();
});
return loginFilter;
}
@Bean
JdbcTokenRepositoryImpl jdbcTokenRepository() {
JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
jdbcTokenRepository.setDataSource(dataSource);
return jdbcTokenRepository;
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userService);
}
@Override
public void configure(WebSecurity web) {
web.ignoring().antMatchers("/js/**", "/css/**", "/images/**", "/generateVerificationCode", "/getVerificationCode");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// http.addFilterBefore(verificationCodeFilter, UsernamePasswordAuthenticationFilter.class);
http.authorizeRequests()
.antMatchers("/admin/**").hasRole("admin")
.antMatchers("/user/**").hasRole("user")
.anyRequest().authenticated()
.and()
.logout()
.logoutRequestMatcher(new AntPathRequestMatcher(LoginConstant.LOGOUT_URL, ServletUtil.METHOD_GET))
.logoutSuccessHandler((request, response, authentication) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpServletResponse.SC_OK);
PrintWriter writer = response.getWriter();
writer.write(new ObjectMapper().writeValueAsString("注销成功"));
writer.flush();
writer.close();
})
.permitAll()
.and()
.csrf().disable()
.exceptionHandling()
.authenticationEntryPoint((request, response, authenticationEntryPoint) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpServletResponse.SC_GONE);
PrintWriter writer = response.getWriter();
writer.write(new ObjectMapper().writeValueAsString("尚未登录,请登录"));
writer.flush();
writer.close();
});
http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class);
}
}
- 需要提供一个 MyAuthenticationProvider 的实例,创建该实例时,需要提供 UserDetailService(UserService) 和 PasswordEncoder 实例。
- 通过重写 authenticationManager 方法来提供一个自己的 AuthenticationManager,实际上就是 ProviderManager,在创建 ProviderManager 时,加入自己的 myAuthenticationProvider。
- 最后就简单配置一下各种回调即可,另外记得配置
/getVerificationCode
任何人都可以访问。
如此,在不需要修改原生过滤器链的情况下,嵌入了自己的认证逻辑。
14.4 测试
获取验证码:
登录时输入错误的验证码:
输入正确的验证码和错误的密码,再进行登录:
登录成功!
14.5 小结
上面的示例,使用了添加登录验证码的案例,实际上,其他的登录场景也可以考虑这种方案,例如目前广为流行的手机号码动态登录,就可以使用这种方式认证。
15 查看登录用户 IP 地址等信息
本文将在上文的基础上,继续探讨如何存储登录用户的详细信息。
15.1 Authentication
Authentication 接口用来保存登录的用户信息,实际上,它是对主体(java.security.Principal)做了进一步的封装。
1
2
3
4
5
6
7
8
public interface Authentication extends Principal, Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
Object getCredentials();
Object getDetails();
Object getPrincipal();
boolean isAuthenticated();
void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}
- getAuthorities 方法用来获取用户权限。
- getCredentials 方法用来获取用户凭证(密码)。
- getDetails 方法用来获取用户携带的详细信息。
- getPrincipal 方法用来获取当前用户,可能是一个用户名,也可能是一个用户对象。
- isAuthenticated 方法用来判断当前用户是否认证成功。
Authentication#getDetails
关于这个方法,源码的解释:
Stores additional details about the authentication request. These might be an IP address, certificate serial number etc.
存储有关身份认证请求的其他详细信息。这些可能是 IP 地址、证书序列号等。
在默认情况下,存储的是用户登录的 IP 地址和 SessionId。
15.2 源码分析
用户登录必然会经过的一个过滤器就是 UsernamePasswordAuthenticationFilter,在该类的 attemptAuthentication 方法中,会调用到 setDetails 方法。
UsernamePasswordAuthenticationFilter#setDetails:
1
2
3
protected void setDetails(HttpServletRequest request, UsernamePasswordAuthenticationToken authRequest) {
authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
}
UsernamePasswordAuthenticationToken 是 Authentication 的具体实现,所以这里实际上就是在设置 details,至于 details 的值,则是通过 authenticationDetailsSource 来构建的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class WebAuthenticationDetailsSource
implements AuthenticationDetailsSource<HttpServletRequest, WebAuthenticationDetails> {
@Override
public WebAuthenticationDetails buildDetails(HttpServletRequest context) {
return new WebAuthenticationDetails(context);
}
}
public class WebAuthenticationDetails implements Serializable {
private final String remoteAddress;
private final String sessionId;
public WebAuthenticationDetails(HttpServletRequest request) {
this(request.getRemoteAddr(), extractSessionId(request));
}
public WebAuthenticationDetails(String remoteAddress, String sessionId) {
this.remoteAddress = remoteAddress;
this.sessionId = sessionId;
}
private static String extractSessionId(HttpServletRequest request) {
HttpSession session = request.getSession(false);
return (session != null) ? session.getId() : null;
}
...
}
默认通过 WebAuthenticationDetailsSource 来构建 WebAuthenticationDetails,并将结果设置到 Authentication 的 details 属性中去。
那么用户登录的 IP 地址实际上是可以直接从 WebAuthenticationDetails 中获取到的。
🌰:登录成功后,可以通过如下方式获取用户 IP
1
2
3
4
@GetMapping("/ip")
public String ip() {
return ((WebAuthenticationDetails) SecurityContextHolder.getContext().getAuthentication().getDetails()).getRemoteAddress();
}
15.3 定制
当然,WebAuthenticationDetails 也可以自己定制,因为它默认只提供了 IP 和 SessionId 两个信息,如果想要保存有关 Http 请求的更多信息,就需要通过自定义 WebAuthenticationDetails 来实现。
如果要自定义 WebAuthenticationDetails,必须连同 WebAuthenticationDetailsSource 一起重新定义。
结合之前的验证码登录,自定义 WebAuthenticationDetails。
之前是在 MyAuthenticationProvider 类中进行验证码判断的:
1
2
3
4
5
6
7
8
9
10
11
12
13
public class MyAuthenticationProvider extends DaoAuthenticationProvider {
@Override
protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
String code = request.getParameter(LoginConstant.VERIFICATION_CODE_PARAMETER_NAME);
String verificationCode = (String) request.getSession().getAttribute(LoginConstant.VERIFICATION_CODE_SESSION_KEY);
if (StrUtil.isBlank(code) || StrUtil.isBlank(verificationCode) || !code.equalsIgnoreCase(verificationCode)) {
throw new AuthenticationServiceException("验证码错误");
}
super.additionalAuthenticationChecks(userDetails, authentication);
}
}
不过这个验证操作,也可以放在自定义的 WebAuthenticationDetails 中来做,定义如下两个类:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class MyWebAuthenticationDetails extends WebAuthenticationDetails {
private boolean isPassed;
public MyWebAuthenticationDetails(HttpServletRequest request) {
super(request);
String code = request.getParameter(LoginConstant.VERIFICATION_CODE_PARAMETER_NAME);
String verificationCode = (String) request.getSession().getAttribute(LoginConstant.VERIFICATION_CODE_SESSION_KEY);
if (StrUtil.isNotBlank(code) || StrUtil.isNotBlank(verificationCode) || code.equalsIgnoreCase(verificationCode)) {
isPassed = true;
}
}
public boolean isPassed() {
return isPassed;
}
}
@Component
public class MyWebAuthenticationDetailsSource implements AuthenticationDetailsSource<HttpServletRequest, MyWebAuthenticationDetails> {
@Override
public MyWebAuthenticationDetails buildDetails(HttpServletRequest context) {
return new MyWebAuthenticationDetails(context);
}
}
首先自定义 WebAuthenticationDetails(MyWebAuthenticationDetails),由于它的构造方法中,刚好提供了 HttpServletRequest 对象,所以可以直接用该对象进行验证码校验,并将校验结果让 isPassed 变量保存起来。
如果想要扩展属性,只需在 MyWebAuthenticationDetails 中定义属性,然后从 HttpServletRequest 中提取出来设置给对应的属性即可,这样,在登录成功之后就可以随时随地获取这些属性。
最后在 MyWebAuthenticationDetailsSource 中构造 MyWebAuthenticationDetails 并返回。
定义完成后,就可以直接在 MyAuthenticationProvider 中进行调用:
1
2
3
4
5
6
7
8
9
10
public class MyAuthenticationProvider extends DaoAuthenticationProvider {
@Override
protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
if (!((MyWebAuthenticationDetails) authentication.getDetails()).isPassed()) {
throw new AuthenticationServiceException("验证码错误");
}
super.additionalAuthenticationChecks(userDetails, authentication);
}
}
直接从 authentication 中获取到 details 并调用 isPassed 方法,有问题抛出异常即可。
最后的问题就是如何用自定义的 MyWebAuthenticationDetailsSource 代替系统默认的 WebAuthenticationDetailsSource,很简单,只需在 SecurityConfig 中稍作定义即可:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
private final MyWebAuthenticationDetailsSource myWebAuthenticationDetailsSource;
public SecurityConfig(MyWebAuthenticationDetailsSource myWebAuthenticationDetailsSource) {
this.myWebAuthenticationDetailsSource = myWebAuthenticationDetailsSource;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/admin/**").hasRole("admin")
.antMatchers("/user/**").hasRole("user")
.anyRequest().authenticated()
.and()
.formLogin()
.authenticationDetailsSource(myWebAuthenticationDetailsSource)
.successHandler((request, response, authentication) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpServletResponse.SC_OK);
PrintWriter writer = response.getWriter();
writer.write(new ObjectMapper().writeValueAsString(Response.success("登录成功", authentication.getPrincipal())));
writer.flush();
writer.close();
})
.failureHandler((request, response, exception) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
PrintWriter writer = response.getWriter();
Response resp = Response.error(exception.getMessage());
if (exception instanceof LockedException) {
resp.setMessage("账户被锁定,请联系管理员");
} else if (exception instanceof CredentialsExpiredException) {
resp.setMessage("密码过期,请联系管理员");
} else if (exception instanceof AccountExpiredException) {
resp.setMessage("账户过期,请联系管理员");
} else if (exception instanceof DisabledException) {
resp.setMessage("账户被禁用,请联系管理员");
} else if (exception instanceof BadCredentialsException) {
resp.setMessage("用户名或者密码输入错误,请重新输入");
}
writer.write(new ObjectMapper().writeValueAsString(resp));
writer.flush();
writer.close();
})
.permitAll()
.and()
.logout()
.logoutRequestMatcher(new AntPathRequestMatcher(LoginConstant.LOGOUT_URL, ServletUtil.METHOD_GET))
.logoutSuccessHandler((request, response, authentication) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpServletResponse.SC_OK);
PrintWriter writer = response.getWriter();
writer.write(new ObjectMapper().writeValueAsString("注销成功"));
writer.flush();
writer.close();
})
.permitAll()
.and()
.csrf().disable()
.exceptionHandling()
.authenticationEntryPoint((request, response, authenticationEntryPoint) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpServletResponse.SC_GONE);
PrintWriter writer = response.getWriter();
writer.write(new ObjectMapper().writeValueAsString("尚未登录,请登录"));
writer.flush();
writer.close();
});
}
将 MyWebAuthenticationDetailsSource 注入到 SecurityConfig 中,并在 formLogin 中配置 authenticationDetailsSource 即可。
1
2
3
4
@GetMapping("/ip")
public String ip() {
return ((MyWebAuthenticationDetails) SecurityContextHolder.getContext().getAuthentication().getDetails()).getRemoteAddress();
}
类型强转的时候,转为 MyWebAuthenticationDetails 即可。
16 自动踢掉前一个登录用户
16.1 需求分析
在一个系统中,可能只允许一个用户在一个终端上登录,此功能可能是出于安全方面的考虑,也可能是出于业务上的考虑。
实现一个用户不可以同时在两台设备上登录,有两种思路:
- 后面的登录自动踢掉前面的登录,🌰:QQ。
- 如果用户已经登录,则不允许后来者登录。
这两种思路都可以实现这个功能。而且在 Spring Security 中,这两种思路都很好实现,一个配置就可以搞定。
16.2 具体实现
16.2.1踢掉已经登录用户
想要后面的登录自动踢掉前面的登录,只需将最大会话数设置为 1 即可,配置如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/admin/**").hasRole("admin")
.antMatchers("/user/**").hasRole("user")
.anyRequest().authenticated()
.and()
.formLogin()
.authenticationDetailsSource(myWebAuthenticationDetailsSource)
.successHandler((request, response, authentication) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpServletResponse.SC_OK);
PrintWriter writer = response.getWriter();
writer.write(new ObjectMapper().writeValueAsString(Response.success("登录成功", authentication.getPrincipal())));
writer.flush();
writer.close();
})
.failureHandler((request, response, exception) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
PrintWriter writer = response.getWriter();
Response resp = Response.error(exception.getMessage());
if (exception instanceof LockedException) {
resp.setMessage("账户被锁定,请联系管理员");
} else if (exception instanceof CredentialsExpiredException) {
resp.setMessage("密码过期,请联系管理员");
} else if (exception instanceof AccountExpiredException) {
resp.setMessage("账户过期,请联系管理员");
} else if (exception instanceof DisabledException) {
resp.setMessage("账户被禁用,请联系管理员");
} else if (exception instanceof BadCredentialsException) {
resp.setMessage("用户名或者密码输入错误,请重新输入");
}
writer.write(new ObjectMapper().writeValueAsString(resp));
writer.flush();
writer.close();
})
.permitAll()
.and()
.logout()
.logoutRequestMatcher(new AntPathRequestMatcher(LoginConstant.LOGOUT_URL, ServletUtil.METHOD_GET))
.logoutSuccessHandler((request, response, authentication) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpServletResponse.SC_OK);
PrintWriter writer = response.getWriter();
writer.write(new ObjectMapper().writeValueAsString("注销成功"));
writer.flush();
writer.close();
})
.permitAll()
.and()
.csrf().disable()
.exceptionHandling()
.authenticationEntryPoint((request, response, authenticationEntryPoint) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpServletResponse.SC_GONE);
PrintWriter writer = response.getWriter();
writer.write(new ObjectMapper().writeValueAsString("尚未登录,请登录"));
writer.flush();
writer.close();
})
.and()
.sessionManagement()
.maximumSessions(1);
}
maximumSessions 表示最大会话数,将最大会话数设置为 1 后,后面的登录就会自动踢掉前面的登录。
配置完成后,使用 Chrome 中的多用户功能,进行测试。
Chrome 用户一:登录成功后,访问 /hello 接口。
1
fetch(new Request('http://localhost:8080/login?username=yueyazhui&password=123&code=wprw', {method:'POST'})).then(res => res.json()).then(data => console.log(data))
Chrome 用户二:登录成功后,访问 /hello 接口。
1
fetch(new Request('http://localhost:8080/login?username=yueyazhui&password=123&code=xpru', {method:'POST'})).then(res => res.json()).then(data => console.log(data))
Chrome 用户一:访问 /hello 接口,此时会看到如下提示:
错误信息:
1
This session has been expired (possibly due to multiple concurrent logins being attempted as the same user).
此会话已过期(可能是由于同一用户尝试多次并发登录)。
16.2.2 禁止新的登录
如果相同的用户已经登录,不想踢掉它,而是想禁止新的登录操作,配置方式如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/admin/**").hasRole("admin")
.antMatchers("/user/**").hasRole("user")
.anyRequest().authenticated()
.and()
.formLogin()
.authenticationDetailsSource(myWebAuthenticationDetailsSource)
.successHandler((request, response, authentication) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpServletResponse.SC_OK);
PrintWriter writer = response.getWriter();
writer.write(new ObjectMapper().writeValueAsString(Response.success("登录成功", authentication.getPrincipal())));
writer.flush();
writer.close();
})
.failureHandler((request, response, exception) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
PrintWriter writer = response.getWriter();
Response resp = Response.error(exception.getMessage());
if (exception instanceof LockedException) {
resp.setMessage("账户被锁定,请联系管理员");
} else if (exception instanceof CredentialsExpiredException) {
resp.setMessage("密码过期,请联系管理员");
} else if (exception instanceof AccountExpiredException) {
resp.setMessage("账户过期,请联系管理员");
} else if (exception instanceof DisabledException) {
resp.setMessage("账户被禁用,请联系管理员");
} else if (exception instanceof BadCredentialsException) {
resp.setMessage("用户名或者密码输入错误,请重新输入");
}
writer.write(new ObjectMapper().writeValueAsString(resp));
writer.flush();
writer.close();
})
.permitAll()
.and()
.logout()
.logoutRequestMatcher(new AntPathRequestMatcher(LoginConstant.LOGOUT_URL, ServletUtil.METHOD_GET))
.logoutSuccessHandler((request, response, authentication) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpServletResponse.SC_OK);
PrintWriter writer = response.getWriter();
writer.write(new ObjectMapper().writeValueAsString("注销成功"));
writer.flush();
writer.close();
})
.permitAll()
.and()
.csrf().disable()
.exceptionHandling()
.authenticationEntryPoint((request, response, authenticationEntryPoint) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpServletResponse.SC_GONE);
PrintWriter writer = response.getWriter();
writer.write(new ObjectMapper().writeValueAsString("尚未登录,请登录"));
writer.flush();
writer.close();
})
.and()
.sessionManagement()
.maximumSessions(1)
.maxSessionsPreventsLogin(true);
}
添加 maxSessionsPreventsLogin 配置,并提供一个 Bean:
1
2
3
4
@Bean
HttpSessionEventPublisher httpSessionEventPublisher() {
return new HttpSessionEventPublisher();
}
❓Bean❓
在 Spring Security 中,它是通过监听 session 的销毁事件,来及时清理 session 的记录。用户从不同的浏览器登录后,都会有对应的 session,当用户注销登录之后,session 就会失效,但默认的失效是通过调用 StandardSession#invalidate 方法来实现的,这个失效事件是无法被 Spring 容器所感知到的,进而导致当用户注销登录之后,Spring Security 没有及时清理会话信息表,以为用户还在线,进而导致用户无法重新登录进来。
为了解决这个问题,需要提供一个 HttpSessionEventPublisher ,该类实现了 HttpSessionListener 接口,在这个 Bean 中,可以将 session 的创建以及销毁及时感知到,并且调用 Spring 中的事件机制将相关的创建和销毁事件发布出去,进而被 Spring Security 感知到,该类部分源码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
public void sessionCreated(HttpSessionEvent event) {
extracted(event.getSession(), new HttpSessionCreatedEvent(event.getSession()));
}
@Override
public void sessionDestroyed(HttpSessionEvent event) {
extracted(event.getSession(), new HttpSessionDestroyedEvent(event.getSession()));
}
private void extracted(HttpSession session, ApplicationEvent e) {
Log log = LogFactory.getLog(LOGGER_NAME);
log.debug(LogMessage.format("Publishing event: %s", e));
getContext(session.getServletContext()).publishEvent(e);
}
配置完成后,使用 Chrome 中的多用户功能,进行测试。
Chrome 用户一:登录成功后,访问 /hello 接口。
1
fetch(new Request('http://localhost:8080/login?username=yueyazhui&password=123&code=j7yx', {method:'POST'})).then(res => res.json()).then(data => console.log(data))
Chrome 用户二:登录。
1
fetch(new Request('http://localhost:8080/login?username=yueyazhui&password=123&code=qwsg', {method:'POST'})).then(res => res.json()).then(data => console.log(data))
错误信息:
1
Maximum sessions of 1 for this principal exceeded
超出了该主体的最大会话数 1
Chrome 用户一:退出登录。
Chrome 用户二:登录成功后,访问 /hello 接口。
16.3 源码分析
在用户登录的过程中,会经过 UsernamePasswordAuthenticationFilter,而 UsernamePasswordAuthenticationFilter 中过滤方法的调用是在 AbstractAuthenticationProcessingFilter 中触发的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
if (!requiresAuthentication(request, response)) {
chain.doFilter(request, response);
return;
}
try {
Authentication authenticationResult = attemptAuthentication(request, response);
if (authenticationResult == null) {
// return immediately as subclass has indicated that it hasn't completed
return;
}
this.sessionStrategy.onAuthentication(authenticationResult, request, response);
// Authentication success
if (this.continueChainBeforeSuccessfulAuthentication) {
chain.doFilter(request, response);
}
successfulAuthentication(request, response, chain, authenticationResult);
}
catch (InternalAuthenticationServiceException failed) {
this.logger.error("An internal error occurred while trying to authenticate the user.", failed);
unsuccessfulAuthentication(request, response, failed);
}
catch (AuthenticationException ex) {
// Authentication failed
unsuccessfulAuthentication(request, response, ex);
}
}
在这段代码中,可以看到,调用 attemptAuthentication 方法走完认证流程回来之后,就是调用 sessionStrategy.onAuthentication 方法,这个方法就是用来处理 session 并发问题的。具体实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
public class ConcurrentSessionControlAuthenticationStrategy implements MessageSourceAware, SessionAuthenticationStrategy {
protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();
private final SessionRegistry sessionRegistry;
private boolean exceptionIfMaximumExceeded = false;
private int maximumSessions = 1;
public ConcurrentSessionControlAuthenticationStrategy(SessionRegistry sessionRegistry) {
Assert.notNull(sessionRegistry, "The sessionRegistry cannot be null");
this.sessionRegistry = sessionRegistry;
}
@Override
public void onAuthentication(Authentication authentication, HttpServletRequest request,
HttpServletResponse response) {
int allowedSessions = getMaximumSessionsForThisUser(authentication);
if (allowedSessions == -1) {
// We permit unlimited logins
return;
}
List<SessionInformation> sessions = this.sessionRegistry.getAllSessions(authentication.getPrincipal(), false);
int sessionCount = sessions.size();
if (sessionCount < allowedSessions) {
// They haven't got too many login sessions running at present
return;
}
if (sessionCount == allowedSessions) {
HttpSession session = request.getSession(false);
if (session != null) {
// Only permit it though if this request is associated with one of the
// already registered sessions
for (SessionInformation si : sessions) {
if (si.getSessionId().equals(session.getId())) {
return;
}
}
}
// If the session is null, a new one will be created by the parent class,
// exceeding the allowed number
}
allowableSessionsExceeded(sessions, allowedSessions, this.sessionRegistry);
}
protected int getMaximumSessionsForThisUser(Authentication authentication) {
return this.maximumSessions;
}
protected void allowableSessionsExceeded(List<SessionInformation> sessions, int allowableSessions,
SessionRegistry registry) throws SessionAuthenticationException {
if (this.exceptionIfMaximumExceeded || (sessions == null)) {
throw new SessionAuthenticationException(
this.messages.getMessage("ConcurrentSessionControlAuthenticationStrategy.exceededAllowed",
new Object[] { allowableSessions }, "Maximum sessions of {0} for this principal exceeded"));
}
// Determine least recently used sessions, and mark them for invalidation
sessions.sort(Comparator.comparing(SessionInformation::getLastRequest));
int maximumSessionsExceededBy = sessions.size() - allowableSessions + 1;
List<SessionInformation> sessionsToBeExpired = sessions.subList(0, maximumSessionsExceededBy);
for (SessionInformation session : sessionsToBeExpired) {
session.expireNow();
}
}
public void setExceptionIfMaximumExceeded(boolean exceptionIfMaximumExceeded) {
this.exceptionIfMaximumExceeded = exceptionIfMaximumExceeded;
}
public void setMaximumSessions(int maximumSessions) {
Assert.isTrue(maximumSessions != 0,
"MaximumLogins must be either -1 to allow unlimited logins, or a positive integer to specify a maximum");
this.maximumSessions = maximumSessions;
}
@Override
public void setMessageSource(MessageSource messageSource) {
Assert.notNull(messageSource, "messageSource cannot be null");
this.messages = new MessageSourceAccessor(messageSource);
}
}
- 调用 sessionRegistry.getAllSessions 方法获取当前用户的所有 session,该方法在调用时,传递两个参数,一个是当前用户,另一个参数 false 表示不包含已经过期的 session(在用户登录成功后,会将用户的 sessionId 保存起来,其中 key 是用户的主体(principal),value 则是该主体对应的 sessionId 组成的一个集合)。
- 计算出当前用户已经有几个有效 session,同时获取允许的 session 并发数。
- 如果当前 session 数(sessionCount)小于 session 并发数(allowedSessions),则不做任何处理;如果 allowedSessions 的值为 -1,表示对 session 数量不做任何限制。
- 如果当前 session 数(sessionCount)等于 session 并发数(allowedSessions),那就先判断当前 session 是否不为 null,并且是否已经存在于 sessions 中,如果已经存在,不做任何处理;如果当前 session 为 null,那么意味着将有一个新的 session 被创建出来,届时当前 session 数(sessionCount)就会超过 session 并发数(allowedSessions)。
- 如果前面的代码中都没能 return 掉,那么将进入策略判断方法 allowableSessionsExceeded 中。
- 在 allowableSessionsExceeded 方法中,首先会有 exceptionIfMaximumExceeded 属性,该属性就是在 SecurityConfig 中配置的 maxSessionsPreventsLogin 的值,默认为 false,如果为 true,就直接抛出异常,那么此次登录将会失败(对应 2.2 小节的效果),如果为 false,则对 sessions 按照请求时间进行排序,然后再使多余的 session 过期即可(对应 2.1 小节的效果)。
16.4 问题分析
❓Spring Security 是怎么保存用户对象和 session 的❓
Spring Security 是通过 SessionRegistryImpl 类来实现对会话信息的统一管理。
源码分析:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
public class SessionRegistryImpl implements SessionRegistry, ApplicationListener<SessionDestroyedEvent> {
// <principal:Object,SessionIdSet>
private final ConcurrentMap<Object, Set<String>> principals;
// <sessionId:Object,SessionInformation>
private final Map<String, SessionInformation> sessionIds;
@Override
public void registerNewSession(String sessionId, Object principal) {
Assert.hasText(sessionId, "SessionId required as per interface contract");
Assert.notNull(principal, "Principal required as per interface contract");
if (getSessionInformation(sessionId) != null) {
removeSessionInformation(sessionId);
}
if (this.logger.isDebugEnabled()) {
this.logger.debug(LogMessage.format("Registering session %s, for principal %s", sessionId, principal));
}
this.sessionIds.put(sessionId, new SessionInformation(principal, sessionId, new Date()));
this.principals.compute(principal, (key, sessionsUsedByPrincipal) -> {
if (sessionsUsedByPrincipal == null) {
sessionsUsedByPrincipal = new CopyOnWriteArraySet<>();
}
sessionsUsedByPrincipal.add(sessionId);
this.logger.trace(LogMessage.format("Sessions used by '%s' : %s", principal, sessionsUsedByPrincipal));
return sessionsUsedByPrincipal;
});
}
@Override
public void removeSessionInformation(String sessionId) {
Assert.hasText(sessionId, "SessionId required as per interface contract");
SessionInformation info = getSessionInformation(sessionId);
if (info == null) {
return;
}
if (this.logger.isTraceEnabled()) {
this.logger.debug("Removing session " + sessionId + " from set of registered sessions");
}
this.sessionIds.remove(sessionId);
this.principals.computeIfPresent(info.getPrincipal(), (key, sessionsUsedByPrincipal) -> {
this.logger.debug(
LogMessage.format("Removing session %s from principal's set of registered sessions", sessionId));
sessionsUsedByPrincipal.remove(sessionId);
if (sessionsUsedByPrincipal.isEmpty()) {
// No need to keep object in principals Map anymore
this.logger.debug(LogMessage.format("Removing principal %s from registry", info.getPrincipal()));
sessionsUsedByPrincipal = null;
}
this.logger.trace(
LogMessage.format("Sessions used by '%s' : %s", info.getPrincipal(), sessionsUsedByPrincipal));
return sessionsUsedByPrincipal;
});
}
}
- 一开始声明了一个 principals 对象,这是一个支持并发访问的 map 集合,集合的 key 就是用户的主体(principal),正常来说,用户的 principal 其实就是用户对象,而集合的 value 则是一个 set 集合,这个 set 集合中保存了这个用户对应的 sessionId。
- 如果有新的 session 需要添加,就在 registerNewSession 方法中进行添加,具体是调用 principals.compute 方法进行添加,key 就是 principal。
- 如果用户注销登录,sessionId 需要移除,相关操作在 removeSessionInformation 方法中完成,具体也是调用 principals.computeIfPresent 方法。
这里有一个问题,ConcurrentMap 集合的 key 是 principal 对象,用对象做 key,一定要重写 equals 方法和 hashCode 方法,否则第一次存完数据,下次就找不到了。
如果是使用基于内存的用户,Spring Security 中的定义:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class User implements UserDetails, CredentialsContainer {
private String password;
private final String username;
private final Set<GrantedAuthority> authorities;
private final boolean accountNonExpired;
private final boolean accountNonLocked;
private final boolean credentialsNonExpired;
private final boolean enabled;
@Override
public boolean equals(Object obj) {
if (obj instanceof User) {
return this.username.equals(((User) obj).username);
}
return false;
}
@Override
public int hashCode() {
return this.username.hashCode();
}
}
可以看到,它实际上是重写了 equals 和 hashCode 方法的。
所以在使用基于内存的用户时没有问题,而使用自定义的用户(不重写 equals 和 hashCode 方法)就会有问题,小编用了 Lombok 的 @Data 注解,@Data 注解会自动生成 equals() 和 hashCode() 方法,所以不存在这个问题。
重写 User 类中的 equals 方法和 hashCode 方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
@Data
@Entity
@Table(name = "sys_user")
@org.hibernate.annotations.Table(appliesTo = "sys_user", comment = "用户")
public class User extends PersonnelBasicInfo implements UserDetails {
/**
* 用户名
*/
@Column(name = "username", nullable = false, unique = true, columnDefinition = "varchar(20) COMMENT '用户名'")
private String username;
/**
* 密码
*/
@Column(name = "password", nullable = false, columnDefinition = "varchar(255) COMMENT '密码'")
private String password;
/**
* 账户没有过期
*/
@Column(name = "account_non_expired", nullable = false, columnDefinition = "tinyint(1) DEFAULT 1 COMMENT '账户没有过期'", insertable = false)
private boolean accountNonExpired;
/**
* 账户没有锁定
*/
@Column(name = "account_non_locked", nullable = false, columnDefinition = "tinyint(1) DEFAULT 1 COMMENT '账户没有锁定'")
private boolean accountNonLocked = true;
/**
* 凭证没有过期
*/
@Column(name = "credentials_non_expired", nullable = false, columnDefinition = "tinyint(1) DEFAULT 1 COMMENT '凭证没有过期'", insertable = false)
private boolean credentialsNonExpired;
/**
* 启用
*/
@Column(name = "enabled", nullable = false, columnDefinition = "tinyint(1) DEFAULT 1 COMMENT '启用'")
private boolean enabled = true;
/**
* 角色列表
*/
// @ManyToMany(targetEntity = Role.class, fetch = FetchType.EAGER, cascade = CascadeType.PERSIST)
// @JoinTable(name = "sys_user_role", joinColumns = {@JoinColumn(name = "user_id", referencedColumnName = "id", nullable = false, columnDefinition = "varchar(32) COMMENT '用户ID'")}, inverseJoinColumns = {@JoinColumn(name = "role_id", referencedColumnName = "id", nullable = false, columnDefinition = "varchar(32) COMMENT '角色ID'")})
@Transient
private List<Role> roleList;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<Role> roleList = RoleService.findRoleListByUserId(getId());
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
for (Role role : roleList) {
authorities.add(new SimpleGrantedAuthority(role.getCode()));
}
return authorities;
}
@Override
public boolean equals(Object obj) {
if (obj instanceof User) {
return this.username.equals(((User) obj).username);
}
return false;
}
@Override
public int hashCode() {
return this.username.hashCode();
}
}
配置完成,重启项目,多端登录测试。
16.4.1 存在的问题
如果目前是采用了 JSON 格式登录,项目中控制 session 的并发数,就会有一些额外的问题需要处理。
最大的问题在于用自定义的过滤器代替了 UsernamePasswordAuthenticationFilter,进而导致前面所说的关于 session 的配置,统统失效。所有相关的配置都需要在新的过滤器 LoginFilter 中进行配置 ,包括 SessionAuthenticationStrategy 也需要自己手动配置。
16.4.2 具体应用
重写 User 类的 equals 和 hashCode 方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Data
@Entity
@Table(name = "sys_user")
@org.hibernate.annotations.Table(appliesTo = "sys_user", comment = "用户")
public class User extends PersonnelBasicInfo implements UserDetails {
...
@Override
public boolean equals(Object obj) {
if (obj instanceof User) {
return this.username.equals(((User) obj).username);
}
return false;
}
@Override
public int hashCode() {
return this.username.hashCode();
}
}
在 SecurityConfig 中进行配置
这里需要自己提供 SessionAuthenticationStrategy,而前面处理 session 并发的是 ConcurrentSessionControlAuthenticationStrategy,也就是说,需要自己提供一个 ConcurrentSessionControlAuthenticationStrategy 的实例,然后配置给 LoginFilter,但在创建 ConcurrentSessionControlAuthenticationStrategy 实例的过程中,还需要一个 SessionRegistryImpl 对象。
前面说过,SessionRegistryImpl 对象是用来维护会话信息的,现在这个东西也需要自己来提供
1
2
3
4
@Bean
SessionRegistryImpl sessionRegistry() {
return new SessionRegistryImpl();
}
在 LoginFilter 中配置 SessionAuthenticationStrategy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@Bean
LoginFilter loginFilter() throws Exception {
LoginFilter loginFilter = new LoginFilter();
loginFilter.setAuthenticationManager(authenticationManagerBean());
loginFilter.setFilterProcessesUrl(LoginConstant.LOGIN_URL);
loginFilter.setUsernameParameter("username");
loginFilter.setPasswordParameter("password");
loginFilter.setAuthenticationSuccessHandler((request, response, authentication) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpServletResponse.SC_OK);
PrintWriter writer = response.getWriter();
writer.write(new ObjectMapper().writeValueAsString(Response.success("登录成功", authentication.getPrincipal())));
writer.flush();
writer.close();
});
loginFilter.setAuthenticationFailureHandler((request, response, exception) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
PrintWriter writer = response.getWriter();
Response resp = Response.error(exception.getMessage());
if (exception instanceof LockedException) {
resp.setMessage("账户被锁定,请联系管理员");
} else if (exception instanceof CredentialsExpiredException) {
resp.setMessage("密码过期,请联系管理员");
} else if (exception instanceof AccountExpiredException) {
resp.setMessage("账户过期,请联系管理员");
} else if (exception instanceof DisabledException) {
resp.setMessage("账户被禁用,请联系管理员");
} else if (exception instanceof BadCredentialsException) {
resp.setMessage("用户名或者密码输入错误,请重新输入");
}
writer.write(new ObjectMapper().writeValueAsString(resp));
writer.flush();
writer.close();
});
ConcurrentSessionControlAuthenticationStrategy sessionAuthenticationStrategy = new ConcurrentSessionControlAuthenticationStrategy(sessionRegistry());
sessionAuthenticationStrategy.setMaximumSessions(1);
loginFilter.setSessionAuthenticationStrategy(sessionAuthenticationStrategy);
return loginFilter;
}
在这里自己手动构建 ConcurrentSessionControlAuthenticationStrategy 实例,构建时传递 SessionRegistryImpl 参数,然后设置 session 的并发数为 1,最后再将 sessionStrategy 配置给 LoginFilter。
之前的配置方案,最终也是像上面这样,只不过现在自己把这个东西写出来了而已
session 处理还有一个关键的过滤器叫做 ConcurrentSessionFilter,本来这个过滤器是不需要自己管的,但是这个过滤器中也用到了 SessionRegistryImpl,而 SessionRegistryImpl 现在是自定义的,所以,该过滤器也需要重新配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/admin/**").hasRole("admin")
.antMatchers("/user/**").hasRole("user")
.anyRequest().authenticated()
.and()
.logout()
.logoutRequestMatcher(new AntPathRequestMatcher(LoginConstant.LOGOUT_URL, ServletUtil.METHOD_GET))
.logoutSuccessHandler((request, response, authentication) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpServletResponse.SC_OK);
PrintWriter writer = response.getWriter();
writer.write(new ObjectMapper().writeValueAsString("注销成功"));
writer.flush();
writer.close();
})
.permitAll()
.and()
.csrf().disable()
.exceptionHandling()
.authenticationEntryPoint((request, response, authenticationEntryPoint) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpServletResponse.SC_GONE);
PrintWriter writer = response.getWriter();
writer.write(new ObjectMapper().writeValueAsString("尚未登录,请登录"));
writer.flush();
writer.close();
});
http.addFilterAt(new ConcurrentSessionFilter(sessionRegistry(), event -> {
HttpServletResponse response = event.getResponse();
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
PrintWriter writer = response.getWriter();
writer.write(new ObjectMapper().writeValueAsString(Response.error("您已在另一台设备登录,本次登录已下线")));
writer.flush();
writer.close();
}), ConcurrentSessionFilter.class);
http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class);
}
在这里,重新创建一个 ConcurrentSessionFilter 的实例,代替系统默认的即可。在创建新的 ConcurrentSessionFilter 实例时,需要两个参数:
- sessionRegistry 就是前面提供的 SessionRegistryImpl 实例。
- 处理 session 过期后的回调函数。
最后,还需要在处理完登录数据之后,手动向 SessionRegistryImpl 中添加一条记录:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
@Autowired
SessionRegistry sessionRegistry;
@Override
@SneakyThrows
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if (!ServletUtil.METHOD_POST.equals(request.getMethod())) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
if (MediaType.APPLICATION_JSON_VALUE.equals(request.getContentType()) || MediaType.APPLICATION_JSON_UTF8_VALUE.equals(request.getContentType())) {
Map<String, String> loginData = new ObjectMapper().readValue(request.getInputStream(), Map.class);
String username = Optional.ofNullable(loginData.get(getUsernameParameter())).orElse(StrUtil.EMPTY).trim();
String password = Optional.ofNullable(loginData.get(getPasswordParameter())).orElse(StrUtil.EMPTY);
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
setDetails(request, authRequest);
User principal = new User();
principal.setUsername(username);
sessionRegistry.registerNewSession(request.getSession(true).getId(), principal);
return this.getAuthenticationManager().authenticate(authRequest);
} else {
return super.attemptAuthentication(request, response);
}
}
}
在这里,调用 sessionRegistry.registerNewSession 方法,向 SessionRegistryImpl 中添加一条 session 记录。
配置完成,测试:
Chrome 用户一:登录。
Chrome 用户二:登录。
Chrome 用户一:访问 /hello 接口。
上面所说的是踢掉已经登录的用户,还有一种方式是禁止新的登录,如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@Bean
LoginFilter loginFilter() throws Exception {
LoginFilter loginFilter = new LoginFilter();
loginFilter.setAuthenticationManager(authenticationManagerBean());
loginFilter.setFilterProcessesUrl(LoginConstant.LOGIN_URL);
loginFilter.setUsernameParameter("username");
loginFilter.setPasswordParameter("password");
loginFilter.setAuthenticationSuccessHandler((request, response, authentication) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
response.setStatus(HttpServletResponse.SC_OK);
PrintWriter writer = response.getWriter();
writer.write(new ObjectMapper().writeValueAsString(Response.success("登录成功", authentication.getPrincipal())));
writer.flush();
writer.close();
});
loginFilter.setAuthenticationFailureHandler((request, response, exception) -> {
response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
PrintWriter writer = response.getWriter();
Response resp = Response.error(exception.getMessage());
if (exception instanceof LockedException) {
resp.setMessage("账户被锁定,请联系管理员");
} else if (exception instanceof CredentialsExpiredException) {
resp.setMessage("密码过期,请联系管理员");
} else if (exception instanceof AccountExpiredException) {
resp.setMessage("账户过期,请联系管理员");
} else if (exception instanceof DisabledException) {
resp.setMessage("账户被禁用,请联系管理员");
} else if (exception instanceof BadCredentialsException) {
resp.setMessage("用户名或者密码输入错误,请重新输入");
}
writer.write(new ObjectMapper().writeValueAsString(resp));
writer.flush();
writer.close();
});
ConcurrentSessionControlAuthenticationStrategy sessionAuthenticationStrategy = new ConcurrentSessionControlAuthenticationStrategy(sessionRegistry());
sessionAuthenticationStrategy.setMaximumSessions(1);
// 禁止新的登录
sessionAuthenticationStrategy.setExceptionIfMaximumExceeded(true);
loginFilter.setSessionAuthenticationStrategy(sessionAuthenticationStrategy);
return loginFilter;
}
17 Spring Security 自带防火墙
17.1 HttpFirewall
在 Spring Security 中提供了一个 HttpFirewall,这是一个请求防火墙,它可以自动处理掉一些非法请求。
HttpFirewall 目前一共有两个实现类:
一个是严格模式的防火墙设置,另一个是默认的防火墙设置。
DefaultHttpFirewall 的限制相对于 StrictHttpFirewall 要宽松一些,当然也意味着安全性不如 StrictHttpFirewall。
Spring Security 中默认使用的是 StrictHttpFirewall。
17.2 防护措施
列举一下 StrictHttpFirewall 是从哪些方面来保护程序的
17.2.1 只允许白名单中的方法
对于请求的方法,只允许白名单中的方法,也就是说,不是所有的 HTTP 请求方法都可以执行。
源码分析:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class StrictHttpFirewall implements HttpFirewall {
private Set<String> allowedHttpMethods = createDefaultAllowedHttpMethods();
private static Set<String> createDefaultAllowedHttpMethods() {
Set<String> result = new HashSet<>();
result.add(HttpMethod.DELETE.name());
result.add(HttpMethod.GET.name());
result.add(HttpMethod.HEAD.name());
result.add(HttpMethod.OPTIONS.name());
result.add(HttpMethod.PATCH.name());
result.add(HttpMethod.POST.name());
result.add(HttpMethod.PUT.name());
return result;
}
private void rejectForbiddenHttpMethod(HttpServletRequest request) {
if (this.allowedHttpMethods == ALLOW_ANY_HTTP_METHOD) {
return;
}
if (!this.allowedHttpMethods.contains(request.getMethod())) {
throw new RequestRejectedException("The request was rejected because the HTTP method \"" +
request.getMethod() +
"\" was not included within the whitelist " +
this.allowedHttpMethods);
}
}
}
从这段代码中可以看出,HTTP 请求方法必须是 GET、POST、PUT、DELETE、HEAD、OPTIONS、PATCH 中的一个,请求才能发送成功,否则,会抛出 RequestRejectedException 异常。
如果想发送其他的 HTTP 请求方法,例如 TRACE ,只需自己重新提供一个 StrictHttpFirewall 实例即可,如下:
1
2
3
4
5
6
@Bean
HttpFirewall httpFirewall() {
StrictHttpFirewall firewall = new StrictHttpFirewall();
firewall.setUnsafeAllowAnyHttpMethod(true);
return firewall;
}
其中,setUnsafeAllowAnyHttpMethod 方法表示不做 HTTP 请求方法校验,也就是说什么方法都可以通过。或者也可以通过 setAllowedHttpMethods 方法来重新定义可以通过的方法。
17.2.2 请求地址不能有分号
请求地址中有 ;
时,控制台报错:
1
2
The request was rejected because the URL contained a potentially malicious String ";"
请求被拒绝,因为 URL 包含潜在的恶意字符串“;”
❓什么时候请求地址中会包含 ;
❓
在使用 Shiro 时,如果你禁用了 Cookie,那么 jsessionid 就会出现在地址栏里,像下面这样:
1
http://localhost:8080/shiro/login;jsessionid=848E0319D579EB73D85AD959723AB3E0
这种传递 jsessionid 的方式是非常不安全的,所以在 Spring Security 中,这种传参方式默认就被禁用了。
当然,如果希望地址栏能够被允许出现 ;
,那么可以按照如下方式设置:
1
2
3
4
5
6
@Bean
HttpFirewall httpFirewall() {
StrictHttpFirewall firewall = new StrictHttpFirewall();
firewall.setAllowSemicolon(true);
return firewall;
}
在 URL 地址中,;
编码之后是 %3b
或者 %3B
,所以地址中同样不允许出现 %3b
或者 %3B
题外话
Spring3.2 开始,带来了一种全新的传参方式 @MatrixVariable。
@MatrixVariable 是 Spring3.2 中带来的功能,这种方式拓展了请求参数的传递格式,使得参数之间可以用 ;
隔开,这种传参方式真是哪壶不开提哪壶。因为 Spring Security 默认是禁止这种传参方式的,因此一般情况下,如果需要使用 @MatrixVariable 来标记参数,就得在 Spring Security 中额外放行。
接下来通过一个简单的例子来演示一下 @MatrixVariable 的用法。
新建一个 /hello
方法:
1
2
3
4
5
@RequestMapping(value = "/hello/{id}")
public void hello(@PathVariable Integer id, @MatrixVariable String name) {
System.out.println("id = " + id);
System.out.println("name = " + name);
}
另外还需要配置一下 SpringMVC,使 ;
不要被自动移除
1
2
3
4
5
6
7
8
9
10
@Configuration
public class WebMvcConfig extends WebMvcConfigurationSupport {
@Override
protected void configurePathMatch(PathMatchConfigurer configurer) {
UrlPathHelper urlPathHelper = new UrlPathHelper();
urlPathHelper.setRemoveSemicolonContent(false);
configurer.setUrlPathHelper(urlPathHelper);
}
}
启动项目(注意,Spring Security 中也要配置允许 URL 中存在 ;
),浏览器发送如下请求:
1
http://localhost:8080/hello/1001;name=yueyazhui
控制台打印信息如下:
1
2
id = 1001
name = yueyazhui
可以看到,@MatrixVariable 注解已经生效。
17.2.3 必须是标准化 URL
请求地址必须是标准化的 URL
源码分析:
StrictHttpFirewall#isNormalized:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private static boolean isNormalized(HttpServletRequest request) {
if (!isNormalized(request.getRequestURI())) {
return false;
}
if (!isNormalized(request.getContextPath())) {
return false;
}
if (!isNormalized(request.getServletPath())) {
return false;
}
if (!isNormalized(request.getPathInfo())) {
return false;
}
return true;
}
http://localhost:8080/hello
getRequestURI 就是获取请求协议之外的字符;/hello
getContextPath 是获取上下文路径,相当于是 project 的名字;null
getServletPath 是请求的 servlet 路径;/hello
getPathInfo 是除过 contextPath 和 servletPath 之后剩余的部分;null
这四种路径中,都不能包含如下字符串:
1
".", "/./" or "/."
17.2.4 必须是可打印的 ASCII 字符
如果请求地址中包含不可打印的 ASCII 字符,请求则会被拒绝:
StrictHttpFirewall#containsOnlyPrintableAsciiCharacters
1
2
3
4
5
6
7
8
9
10
11
12
13
private static boolean containsOnlyPrintableAsciiCharacters(String uri) {
if (uri == null) {
return true;
}
int length = uri.length();
for (int i = 0; i < length; i++) {
char ch = uri.charAt(i);
if (ch < '\u0020' || ch > '\u007e') {
return false;
}
}
return true;
}
17.2.5 双斜杠不被允许
如果请求地址中出现双斜杠,这个请求也将会被拒绝。双斜杠 //
使用 URL 地址编码之后,是 %2F%2F,其中 F 大小写无所谓。
如果希望请求地址中允许出现 //
,按照如下方式配置:
1
2
3
4
5
6
@Bean
HttpFirewall httpFirewall() {
StrictHttpFirewall firewall = new StrictHttpFirewall();
firewall.setAllowUrlEncodedDoubleSlash(true);
return firewall;
}
17.2.6 % 不被允许
如果请求地址中出现 %,这个请求也将会被拒绝。%
URL 地址编码后是 %25,所以 %25 也不能出现在 URL 地址中。
如果希望请求地址中允许出现 %,按照如下方式修改:
1
2
3
4
5
6
@Bean
HttpFirewall httpFirewall() {
StrictHttpFirewall firewall = new StrictHttpFirewall();
firewall.setAllowUrlEncodedPercent(true);
return firewall;
}
17.2.7 正反斜杠不被允许
如果请求地址中包含斜杠,这个请求也将会被拒绝。斜杠编码后的字符是 %2F 或者 %2f。
如果请求地址中包含反斜杠,这个请求也将会被拒绝。反斜杠编码后的字符 %5C 或者 %5c。
如果希望去掉这两条限制,按照如下方式来配置:
1
2
3
4
5
6
7
@Bean
HttpFirewall httpFirewall() {
StrictHttpFirewall firewall = new StrictHttpFirewall();
firewall.setAllowBackSlash(true);
firewall.setAllowUrlEncodedSlash(true);
return firewall;
}
17.2.8 .
不被允许
如果请求地址中存在 .
,这个请求也将会被拒绝。编码之后的字符 %2E
或者 %2e
。
如需支持,按照如下方式进行配置:
1
2
3
4
5
6
@Bean
HttpFirewall httpFirewall() {
StrictHttpFirewall firewall = new StrictHttpFirewall();
firewall.setAllowUrlEncodedPeriod(true);
return firewall;
}
17.2.9 小结
需要强调一点,上面所说的这些限制,都是针对请求的 requestURI 进行的限制,而不是针对请求参数。
18 会话固定攻击
18.1 HttpSession
HttpSession 是一个服务端的概念,服务端生成的 HttpSession 都会有一个对应的 sessionid,这个 sessionid 会通过 cookie 传递给前端,前端以后发送请求的时候,就带上这个 sessionid 参数,服务端看到这个 sessionid 就会把这个前端请求和服务端的某一个 HttpSession 对应起来,形成“会话”的感觉。
浏览器关闭并不会导致服务端的 HttpSession 失效,想让服务端的 HttpSession 失效,要么手动调用 HttpSession#invalidate 方法;要么等到 session 自动过期;要么重启服务端。
但是为什么有的时候会感觉浏览器关闭之后 session 就会失效?这是因为浏览器关闭之后,保存在浏览器里边的 sessionid 就会丢了(默认情况下),所以当浏览器再次访问服务端的时候,服务端会给浏览器重新分配一个 sessionid ,这个 sessionid 和之前的 HttpSession 对应不上,所以就会感觉 session 失效。
注意刚才用了一个默认情况下,也就是说,可以通过手动配置,让浏览器重启之后 sessionid 不丢失,但是这样会带来安全隐患,所以一般不建议。
以 Spring Boot 为例,服务端生成 sessionid 之后,返回给前端的响应头是这样的:
在服务端的响应头中有一个 Set-Cookie 字段,该字段表示浏览器更新 sessionid,同时需要注意还有一个 HttpOnly 属性,这个表示通过 JS 脚本是无法读取到 Cookie 信息的,这样可以有效防止 XSS(跨站脚本攻击) 攻击。
下一次浏览器再去发送请求时,就会自动携带上这个 jsessionid :
18.2 会话固定攻击
会话固定攻击(session fixation attack)
正常来说,只要不关闭浏览器,并且服务端的 HttpSession 也没有过期,那么维系服务端和浏览器的 sessionid 是不会发生变化的,而会话固定攻击,则是利用这一机制,借助与受害者相同的会话 ID 获取认证和授权,然后利用该会话 ID 劫持受害者的会话以成功冒充受害者,造成会话固定攻击。
一般情况下会话固定攻击的流程,以淘宝为例:
- 攻击者自己可以正常访问淘宝网站,在访问的过程中,淘宝网站给攻击者分配了一个 sessionid。
- 攻击者利用自己拿到的 sessionid 构造一个淘宝网站的链接,并把该链接发送给受害者。
- 受害者使用该链接登录淘宝网站(该链接中含有 sessionid),登录成功后,一个合法的会话就建立成功了。
- 攻击者利用手里的 sessionid 冒充受害者。
在这个过程中,如果淘宝网站支持 URL 重写,那么攻击还会变得更加容易。
URL 重写就是用户如果在浏览器中禁用了 cookie,那么 sessionid 自然也用不了了,所以有的服务端就支持把 sessionid 放在请求地址中:
1
http://www.taobao.com;jsessionid=xxxxxx
如果服务端支持这种 URL 重写,那么对于攻击者来说,按照上面的攻击流程,构造一个这样的地址简直不要太简单了。
不过这种请求地址在 Spring Security 中应该很少见到(原因后面会有说到),但是在 Shiro 中可能多多少少会有见过的。
18.3 如何防御
这个问题的根源在 sessionid 不变,如果用户在未登录时拿到的 sessionid 和登录之后拿到的 sessionid 不一样,就可以防止会话固定攻击了。
Spring Security 中的防御主要体现在三个方面:
首先就是之前说的 StrictHttpFirewall,如果请求地址中有 ;
请求会被直接拒绝。
然后就是响应头中的 Set-Cookie 字段有 HttpOnly 属性,这种方式避免了通过 XSS(跨站脚本攻击)来获取 Cookie 中的会话信息进而达成会话固定攻击。
最后则是让 sessionid 变一下。既然问题的根源是由于 sessionid 不变导致的,那就让 sessionid 变一下。
具体配置如下:
在这里,可以看到有四个选项:
-
migrateSession 表示在登录成功之后,创建一个新的会话,然后将旧 session 中的信息复制到新的 session 中,默认。
-
none 表示不做任何事情,继续使用旧的 session。
-
changeSessionId 表示 session 不变,但会修改 sessionid,这实际上用到了 Servlet 容器提供的防御会话固定攻击。
-
newSession 表示登录后创建一个新的 session。
默认的 migrateSession ,在用户匿名访问的时候是一个 sessionid,当用户成功登录之后,又是另外一个 sessionid,这样就可以有效避免会话固定攻击。
19 集群化部署,Session 共享
之前的文章已经讲过 Spring Security 如何像 QQ 一样,自动踢掉已登录的用户,但那是基于单体应用,如果项目是集群化部署,这个问题又该如何解决,Spring Security 要如何处理 Session 并发?
19.1 集群会话方案
在传统的单服务架构中,一般来说,只有一个服务器,那么不存在 Session 共享问题,但是在分布式/集群项目中,Session 共享则是一个必须面对的问题,先看一个简单的架构图:
在这样的架构中,会出现一些单服务中不存在的问题,例如客户端发起一个请求,这个请求到达 Nginx 上之后,被 Nginx 转发到 Tomcat A 上,然后在 Tomcat A 上往 Session 中保存了一份数据,下次又来一个请求,这个请求被转发到 Tomcat B 上,此时再去 Session 中获取数据,发现没有之前的数据。
19.1.1 Session 共享
对于这一类问题的解决,目前比较主流的方案就是将各个服务之间需要共享的数据,保存到一个公共的地方(主流方案就是 Redis):
当所有的 Tomcat 需要往 Session 中写数据时,都往 Redis 中写,当所有的 Tomcat 需要读数据时,都从 Redis 中读。这样,不同的服务就可以使用相同的 Session 数据了。
这样的方案,可以由开发者手动实现,即手动往 Redis 中存储数据,手动从 Redis 中读取数据,相当于使用一些 Redis 客户端工具来实现这样的功能,毫无疑问,手动实现工作量还是蛮大的。
一个简化的方案就是使用 Spring Session 来实现这一功能,Spring Session 就是使用 Spring 中的代理过滤器,将所有的 Session 操作拦截下来,自动的将数据同步到 Redis 中,或者自动的从 Redis 中读取数据。
对于开发者来说,所有关于 Session 同步的操作都是透明的,开发者使用 Spring Session,一旦配置完成后,具体的用法就像使用一个普通的 Session 一样。
19.1.2 Session 拷贝
Session 拷贝就是不利用 Redis,直接在各个 Tomcat 之间进行 Session 数据拷贝,但是这种方式效率有点低,Tomcat A、B、C 中任意一个的 Session 发生了变化,都需要拷贝到其他 Tomcat 上,如果集群中的服务器数量特别多的话,这种方式不仅效率低,还会有很严重的延迟。
所以这种方案一般作为了解即可。
19.1.3 粘滞会话
所谓的粘滞会话就是将相同 IP 发送来的请求,通过 Nginx 路由到同一个 Tomcat 上去,这样就不用进行 Session 共享与同步了。这是一个办法,但是在一些极端情况下,可能会导致负载失衡(因为大部分情况下,都是很多人用同一个公网 IP)。
所以,Session 共享就成为了这个问题目前主流的解决方案了。
19.2 Session 共享
19.2.1 添加依赖
在 pom.xml 文件中引入 Spring Session 以及 Redis:
1
2
3
4
5
6
7
8
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
19.2.2 配置
配置 Redis 的基本信息
1
2
3
4
spring.redis.host=127.0.0.1
spring.redis.port=6379
spring.redis.password=
spring.redis.database=0
19.2.3 使用
配置完成后 ,就可以使用 Spring Session 了,其实就是使用普通的 HttpSession ,其他的 Session 同步到 Redis 等操作,框架已经自动帮你完成了:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@RestController
public class HelloController {
@Value("${server.port}")
Integer port;
@GetMapping("/set")
public String set(HttpSession session) {
session.setAttribute("user", "yueyazhui");
return String.valueOf(port);
}
@GetMapping("/get")
public String get(HttpSession session) {
return session.getAttribute("user") + ":" + port;
}
}
这个 Spring Boot 项目将会以集群的方式启动,为了获取每一个请求到底是哪一个 Spring Boot 项目提供的服务,需要在每次请求时返回当前服务的端口号,因此这里注入了 server.port 。
项目打包并启动两个实例:
1
2
java -jar learn_spring_security-0.0.1-SNAPSHOT.war --server.port=8080
java -jar learn_spring_security-0.0.1-SNAPSHOT.war --server.port=8081
然后先访问 http://localhost:8080/set
向 8080
这个服务的 Session
中保存一个变量(先登录),访问成功后,这个变量就已经自动同步到 Redis
中 了 :
然后,再调用 http://localhost:8081/get
接口,就可以获取到 8080
服务中 Session
中的数据:
此时关于 Session 共享的配置已全部完成,这就是 Session 共享的效果。
19.3 引入 Nginx
为了让这个案例看起来更完美一些,接下来引入 Nginx ,实现服务实例自动切换。
编辑 nginx.conf 文件:
Linux:/usr/local/nginx/conf/nginx.conf
1
2
3
4
5
6
7
8
9
10
11
12
13
upstream yueyazhui.top {
server 127.0.0.1:8081 weight=1;
server 127.0.0.1:8082 weight=2;
}
server {
listen 8080;
server_name localhost;
location / {
proxy_pass http://yueyazhui.top;
proxy_redirect default;
}
}
配置解释:
- upstream 表示上游服务器
- yueyazhui.top 表示服务器集群的名字,随便取
- upstream 里边配置的是一个个的单独服务
- weight 表示服务的权重,意味着将会有多少比例的请求从 Nginx 上转发到该服务上
- location 中的 proxy_pass 表示请求转发的地址;
/
表示拦截所有的请求,转发到配置好的服务集群中 - proxy_redirect 表示当发生重定向请求时,Nginx 自动修正响应头数据(默认是 Tomcat 返回重定向,此时重定向的地址是 Tomcat 的地址,这里需要修改使之成为 Nginx 的地址)。
配置完成后,启动两个 Spring Boot 实例:
1
2
java -jar learn_spring_security-0.0.1-SNAPSHOT.war --server.port=8081
java -jar learn_spring_security-0.0.1-SNAPSHOT.war --server.port=8082
其中
- nohup 表示当终端关闭时,Spring Boot 不要停止运行
- & 表示让 Spring Boot 在后台启动
重启 Nginx:
1
/usr/local/nginx/sbin/nginx -s reload
Nginx 启动成功后,首先清空 Redis,然后访问 10.211.55.15:8080/set
表示向 session
中保存数据,这个请求首先会到达 Nginx
上,再由 Nginx
转发给某一个 Spring Boot
实例:
如上,表示端口为 8081
的 Spring Boot
处理了这个 /set
请求,再访问 /get
请求:
可以看到,/get
请求是被端口为 8082 的服务所处理的。