# spring-security-sample
**Repository Path**: mqb98/spring-security-sample
## Basic Information
- **Project Name**: spring-security-sample
- **Description**: No description available
- **Primary Language**: Java
- **License**: Not specified
- **Default Branch**: master
- **Homepage**: None
- **GVP Project**: No
## Statistics
- **Stars**: 3
- **Forks**: 0
- **Created**: 2021-03-19
- **Last Updated**: 2024-08-04
## Categories & Tags
**Categories**: Uncategorized
**Tags**: None
## README
跟着 https://github.com/lenve/spring-security-samples 学习记录
|Demo|内容|
| ------ | ------ |
|form-login|SpringSecurity初体验|
|form-login-2|表单登录-前后端不分离|
|form-login-3|前后端分离配置|
|form-login-4|基于内存的用户配置|
|form-login-5|将用户存到数据库|
|withjpa|使用jpa操作数据库|
|rememberme|自动登录|
|rememberme-persistent|自动登录-降低安全风险|
|verifycode-0|自定义过滤器-添加验证码功能|
|verifycode-1|自定义AuthenticationProvider-添加验证码功能|
|authentication-details|获取登录用户ip,自定义details|
|session-1|用户并发登录限制--基于内存用户|
|session-2|用户并发登录限制--基于数据库用户|
|stricthttpfirewall|SpringSecurity自带安全防护|
|session-3|防御固定会话攻击|
|session-4|集群化部署-SpringSession进行Session共享|
|csrf-1||
|csrf-2|csrf防御|
|csrf-3||
|encrypt|密码加密|
## 初步使用SpringSecurity
### 新建项目
首先创建一个SpringBoot项目,引入Security和Web的依赖
```xml
org.springframework.boot
spring-boot-starter-security
org.springframework.boot
spring-boot-starter-web
```
创建一个接口
```java
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "hello";
}
}
```
接下来直接启动项目,访问 http://localhost:8080/hello
发现并没有直接访问到接口,而是自动重定向到了一个登录页面

默认的登录用户名是user,登录密码在启动时打印在了控制台,当然这个密码每次重新启动都会变化。 具体这个密码是如何生成的,可以查看`UserDetailsServiceAutoConfiguration#getOrDeducePassword`
```text
Using generated security password: 40fb25e8-e147-40aa-8f77-f6eea3723e11
```
当登录成功之后,就可以成功的访问到/hello接口了。在SpringSecurity中,默认的登录页面和登录请求都是/login,只不过一个是登录页面(get),一个是登录接口(post)。
### 非 主流方式配置用户名/密码
#### 1.配置文件
查看配置类,使用自定义的配置去覆盖默认配置
```java
@ConfigurationProperties(prefix = "spring.security")
public class SecurityProperties {
}
```
```yaml
spring:
security:
user:
name: maqb
password: 123
```
#### 2.配置类
配置类的配置会覆盖yml中的配置
```java
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
PasswordEncoder passwordEncoder(){
return NoOpPasswordEncoder.getInstance();
}
@Override
protected void configure(AuthenticationManagerBuilder auth)throws Exception{
auth.inMemoryAuthentication()
.withUser("maqb")
.password("456").roles("admin");
}
}
```
1. 首先自定义一个配置类SecurityConfig继承自WebSecurityConfigurerAdapter
2. 定义一个PasswordEncoder,目前由于不对密码加密,使用NoOpPasswordEncoder
3. configure方法中,通过inMemoryAuthentication()在内存中配置用户。如果要配置多个用户,使用and()连接
## 自定义登录页
首先的话在资源目录static下新建login.html,外加一些html、css、images。 然后在SecurityConfig配置类中自定义登录页
```java
@Override
public void configure(WebSecurity web)throws Exception{
web.ignoring().antMatchers("/js/**","/css/**","/images/**");
}
@Override
protected void configure(HttpSecurity http)throws Exception{
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin().loginPage("/login.html").permitAll(); // 注意此处要放行login.html请求,否则会一直重定向
}
```
## 前后端不分离配置登录页
form-login2
### 自定义登录页和登录接口
在SpringSecurity中,我们不做任何配置时,默认的登录页面和登录接口都是/login,也就是说默认会有两个请求,而且是放行的
+ GET localhost:8080/login 登录页
+ POST localhost:8080/login 登录请求
在上一节中简单的自定义了登录页,配置如下:
```java
.and().formLogin().loginPage("/login.html").permitAll();
```
我们自定义了loginPage为/login.html, 这个配置相当于重写了默认的/login, 实际上有两个效果,即配置了登录页,又配置了登录请求
+ GET localhost:8080/login.html 登录页
+ POST localhost:8080/login.html 登录请求
这样的话登录请求是 POST /login.html,看起来很奇怪 我们也可以将登录请求再重新设置,通过loginProcessingUrl()来自定义登录请求
```java
.and()
.formLogin()
.loginPage("/login.html")
.loginProcessingUrl("/doLogin")
.permitAll(); // 注意此处要放行login.html请求,否则会一直重定向
```
这样配置之后,登录页和登录请求就分开了
+ GET localhost:8080/login.html 登录页
+ POST localhost:8080/doLogin 登录请求
有一点要注意,重写了登录请求地址之后,要将请求页面的表单也进行修改 其中的action属性要修改为我们自定义的登录请求
```html
```
### 自定义登录参数
登录参数在默认情况下是username和password
```html
```
当然我们也可以自定义这两个参数
```java
.and()
.formLogin()
.loginPage("/login.html")
.loginProcessingUrl("/doLogin")
.usernameParameter("name")
.passwordParameter("passwd")
.permitAll(); // 注意此处要放行login.html请求,否则会一直重定向
```
在自定义username和password参数之后,记得好修改html页面表单的对应参数
```html
```
### 登录接口/页面/参数 源码查看
#### 登录接口/页面
登录接口和页面的配置都是在 formLogin()之后的
```java
.and()
.formLogin()
.loginPage("/login.html")
.loginProcessingUrl("/doLog
```
关于formLogin()之后的配置, 都在类FormLoginConfigurer中, FormLoginConfigure继承抽象类AbstractAuthenticationFilterConfigurer,
在AbstractAuthenticationFilterConfigurer的构造方法中, 默认定以了loginPage
```java
protected AbstractAuthenticationFilterConfigurer(){
this.defaultSuccessHandler=new SavedRequestAwareAuthenticationSuccessHandler();
this.successHandler=this.defaultSuccessHandler;
this.setLoginPage("/login");
}
```
另一方面, 在FormLoginConfigure的init方法中, 调用了父类AbstractAuthenticationFilterConfigurer的init方法
```java
public void init(H http)throws Exception{
super.init(http);
this.initDefaultLoginFilter(http);
}
```
```java
// 父类init
public void init(B http)throws Exception{
this.updateAuthenticationDefaults();
this.updateAccessDefaults(http);
this.registerDefaultAuthenticationEntryPoint(http);
}
```
在 updateAuthenticationDefaults() 方法中, 定义了默认的 loginProcessingUrl 为 loginPage
```java
protected final void updateAuthenticationDefaults(){
if(this.loginProcessingUrl==null){
this.loginProcessingUrl(this.loginPage);
}
// 略..
}
```
#### 登录参数
登录参数的配置也是在formLogin()之后, 所以也是去FormConfigure这个类中查看
```java
.and()
.formLogin()
.loginPage("/login.html")
.loginProcessingUrl("/doLogin")
.usernameParameter("name")
.passwordParameter("passwd")
```
查看FormConfigure的构造方法, 可见默认设置的username和password两个参数
```java
public FormLoginConfigurer(){
super(new UsernamePasswordAuthenticationFilter(),(String)null);
this.usernameParameter("username");
this.passwordParameter("password");
}
```
紧接着查看FormConfigure的usernameParameter()方法,
其作用就是将usernameParameter的值赋值给UsernamePasswordAuthenticationFilter对象的usernameParameter
```java
public FormLoginConfigurer usernameParameter(String usernameParameter){
((UsernamePasswordAuthenticationFilter)this.getAuthenticationFilter()).setUsernameParameter(usernameParameter);
return this;
}
```
然后进入UsernamePasswordAuthenticationFilter类查看, 在上一步调用完它的setUsernameParameter()之后
```java
public void setUsernameParameter(String usernameParameter){
Assert.hasText(usernameParameter,"Username parameter must not be empty or null");
this.usernameParameter=usernameParameter;
}
```
已经将UsernamePasswordAuthenticationFilter对象的属性usernameParameter重新赋值了。然后就是查看此类的obtainUsername方法,
从request中,以usernameParameter为key,取出对应的值, 作为username. password也是同理.
```java
@Nullable
protected String obtainUsername(HttpServletRequest request){
return request.getParameter(this.usernameParameter);
}
```
### 登录回调
之前的这些配置, 前后端是否分离都没差别. 当在登录成功以后, 就要分情况讨论了
+ 前后端分离登录
+ 前后端不分离登录
此处先描述前后端不分离的情况
#### 登录成功回调
在SpringSecurity中, 和登录成功重定向URL相关的方法有两个
+ defaultSuccessUrl
+ successForwardUrl
具体的方法源码可以查看FormLoginConfigure的父类AbstractAuthenticationFilterConfigurer
这个两个方法, 我们在配置的时候, 只需要配置其中一个就好了, 具体要配置哪个, 就看需求了
1. defaultSuccessUrl 如果我们在defaultSuccessUrl中指定登录成功后的跳转页面为 /index, 此时分为两种情况
+ 如果是直接在浏览器输入登录地址, 然后登录成功, 就直接跳转到 /index
+ 如果是在浏览器输入其他地址, 如 http://localhost:8080/hello, 然后由于没有登录而重定向到登录页面, 然后登录成功 就不会来到 /index, 而是去到 /hello
2. defaultSuccessUrl 还有一个重载方法, 加了一个boolean类型的参数.
+ 这个参数不设置, 默认为false, 此时就是和1中的情况一样
+ 如果设置为true, 则defaultSuccessUrl的效果和successForwardUrl一直.
3. successForwardUrl 表示不管你从哪里来, 登录后一律跳转到 successForwardUrl 指定的地址 例如 successForwardUrl 指定的地址为 /index
,你在浏览器地址栏输入 http://localhost:8080/hello ,结果因为没有登录,重定向到登录页面, 当你登录成功之后,就会服务端跳转到 /index 页面;或者你直接就在浏览器输入了登录页面地址,登录成功后也是来到
/index。
注意:successForwardUrl是在登录成功后转发到 url,因为登录时的请求是 POST 的类型的,所以转发到 url时也是以POST方式去请求url的。
而 defaultSuccessUrl("/url",true) 这种方式就是以 GET 的方式重定向到 url
#### 登录失败的回调
登录失败的回调也有两个
+ failureForwardUrl
+ failureUrl
这个两个方法, 也是只要配置一个就好了, 我个人觉得使用failureUrl较好
+ failureForwardUrl: 失败后转发到url, 如failureForwardUrl("/ffu")就是登录验证失败后转发到 /ffu 但是由于表单提交用的是POST请求, 所以使用failureForwardUrl("
/ffu") 转发到 /ffu, 这个 /ffu也需要是一个POST类型的接口
+ failureUrl: 失败后重定向到url, 如.failureUrl("/fu")就是登录验证失败后, 重定向到 /fu 这个 /fu 接口要求是GET类型的
简单粗暴的理解, 使用failureForwardUrl 登录校验失败后转发的接口地址, 要求是POST类型 failureUrl 重定向的接口地址, 要求是GET类型
### 注销登录
```java
http.logout()
.logoutUrl("/logout")
.logoutRequestMatcher(new AntPathRequestMatcher("/logout","GET"))
.logoutSuccessUrl("/out")
.deleteCookies()
.clearAuthentication(true)
.invalidateHttpSession(true)
.permitAll();
```
+ logoutUrl: 默认的注销url就是 /logout, 可以通过配置logoutUrl("/xxx")来修改默认的
+ logoutRequestMatcher: 不仅可以修改默认的注销url, 还可以修改请求方式. 实际中这个方法和logoutUrl只要设置一个就好了
+ deleteCookies: 用来清除cookies
+ clearAuthentication 和 invalidateHttpSession 分别表示清除认证信息和使HttpSession失效, 默认可以不用配置, 默认就会被清除
## 前后端分离, 使用JSON交互
form-login3
在前后端分离的情况下, 认证这一块一般有两种处理方式: Session 和 Token Session可以理解为有状态的登录, 而Token则是无状态的登录
### 无状态登录
#### 有状态登录-session
有状态登录, 即服务端需要记录每次会话的客户端信息, 从而识别客户端身份, 根据用户身份进行请求的处理, 典型的设计如Tomcat中的session. 例如用户登录后, 可以把用户信息保存在服务端的session中,
并且给用户一个cookie的值, 记录对应的session, 然后下次请求, 用户携带cookie值来(这一步由浏览器完成)发送请求, 这样我们就能识别到对应的session, 从而找到用户的信息.
session的一大优点就是方便, 基本不用做处理, 一切都是默认的就好了 但是它有一个致命的问题就是如果前端Android, IOS, 小程序这些, 它们天然就没有cookie, 如果非要用session, 就需要前端工程师在各自的
设备上做适配, 一般是模拟cookie 还有一些其他缺陷, 如
+ 服务端保存大量数据, 增加服务端压力
+ 服务端保存用户状态, 不方便集群化部署(一般是通过增加一台session服务器来解决)
#### 无状态登录-token
无状态的大致意思是:
+ 服务端不保存任何客户端请求者信息
+ 客户端的每次请求必须具备自描述信息, 通过这些信息识别客户端
好处:
+ 客户端请求不依赖服务端的信息, 多次请求不需要必须访问到同一台服务器
+ 服务端可以任意的迁移和伸缩
+ 减小服务端压力
#### 无状态登录流畅
1. 首先客户端发生账户名/密码到服务端进行认证
2. 认证通过后, 服务端将用户信息加密并且编码成一个 token, 返回给客户端
3. 以后客户端每次发生请求, 都需要携带认证的token,
4. 服务端对客户端请求中携带的token进行解密, 判断是否有效, 并且获取用户登录信息
### 登录交互
#### 前后端分离的数据交互
在前后端分离这样的开发架构下, 前后端的交互都是通过JSON来进行的, 无论是登录成功还是失败, 都不会有服务端跳转或者客户端跳转之类
登录成功了, 服务端就返回一段登录成功的提示JSON给前端, 前端收到后, 后续操作由前端决定, 失败也是同理
#### 登录成功
之前我们配置登录成功的处理是通过如下两个方法来配置的
+ defaultSuccessUrl
+ successForwardUrl 这两个都是配置跳转地址的, 适用于前后端不分离的开发. 除了这两个方法外, 还有一个就是 successHandler
successHandler的功能十分强大, 甚至已经囊括了 defaultSuccessUrl 和 successForwardUrl 的功能
```java
.successHandler((request,response,authentication)->{
Object principal=authentication.getPrincipal();
response.setContentType("application/json;charset=utf-8");
PrintWriter out=response.getWriter();
out.write(new ObjectMapper().writeValueAsString(principal));
out.flush();
out.close();
})
```
successHandler 方法的参数是一个 AuthenticationSuccessHandler 对象,这个对象中我们要实现的方法是 onAuthenticationSuccess。
onAuthenticationSuccess方法一共有3个参数
+ HttpServletRequest
+ HttpServletResponse
+ Authentication
有了前两个参数request和response,我们可以选择服务端跳转,客户端跳转,或者直接返回JSON数据 第三个参数 Authentication则保存了刚刚登录的用户的信息

可见返回给前端的当前登录用户信息中,密码已经被擦除了... 具体在哪里擦除等后面分析源码的时候再找
#### 登录失败
登录失败也有一个类似的回调,如下
```java
.failureHandler((request,response,exception)->{
response.setContentType("application/json;charset=utf-8");
PrintWriter out=response.getWriter();
out.write(exception.getMessage());
out.flush();
out.close();
})
```
一二俩个参数就是HttpServletRequest和HttpResponse, 第三个参数就是登录失败的原因了,我们也可以通过挨个去对比exception,来返回更详细的失败原因
```java
if (e instanceof LockedException) {
respBean.setMsg("账户被锁定,请联系管理员!");
}
elseif(e instanceof CredentialsExpiredException) {
respBean.setMsg("密码过期,请联系管理员!");
}
elseif(e instanceof AccountExpiredException) {
respBean.setMsg("账户过期,请联系管理员!");
}
elseif(e instanceof DisabledException) {
respBean.setMsg("账户被禁用,请联系管理员!");
}
elseif(e instanceof BadCredentialsException) {
respBean.setMsg("用户名或者密码输入错误,请重新输入!");
}
```
这里有一个需要注意的点。 我们知道当用户登录时,用户名或密码输入错误,我们一般只给一个模糊的提示,即【用户名或密码错误,请重新输入】, 而不会给一个明确的诸如【用户名不存在】或者【密码输入错误】这样精确的提示。
在SpringSecurity中,用户名查找失败对应的异常是: UsernameNotFoundException。密码匹配失败对应的异常是:BadCredentialsException。但是我们在登陆失败的回调中,却总是看不到 UsernameNotFountException,无论是是用户名还是密码错误,抛出的异常都是BadCredentialsException。
原因是在登录校验用户名密码时,会进入 UsernamePasswordAuthenticationFilter#attemptAuthentication方法中,
在此方法的最后一句 `return this.getAuthenticationManager().authenticate(authRequest);` 调用了 authenticate方法,
重点就在此方法中。我们直接来到ProviderManager#authenticate 方法中,几乎所有认证的重要逻辑都在此方法中。 摘取此方法中关键的部分
```java
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
Class extends Authentication> toTest = authentication.getClass();
Authentication result = null;
for (AuthenticationProvider provider : getProviders()) {
if (!provider.supports(toTest)) {
continue;
}
result = provider.authenticate(authentication);
if (result != null) {
copyDetails(authentication, result);
break;
}
}
if (result == null && parent != null) {
result = parentResult = parent.authenticate(authentication);
}
if (result != null) {
if (eraseCredentialsAfterAuthentication
&& (result instanceof CredentialsContainer)) {
((CredentialsContainer) result).eraseCredentials();
}
if (parentResult == null) {
eventPublisher.publishAuthenticationSuccess(result);
}
return result;
}
throw lastException;
}
```
1. 首先获取authentication的class,判断当前的provider是否支持该authentication
2. 如果支持,则调用provider的authenticate方法开始校验,检验完成后,会返回一个新的authentication
3. 这里的provider可能有多个,如果provider的authenticate方法没能正常的返回一个Authentication,则调用provider的parent的authenticate方法继续校验
4. copyDetails方法则用来把旧的Token的details属性拷贝到新的Token中
5. 调用eraseCredentials方法擦除凭证信息,也就是密码。这个擦除方法比较简单,就是将Token中的credentials属性置空
实际进行调试可以发现
1. getProviders() 默认只有一个Provider - AnonymousAuthenticationProvider,这个provider并不支持当前的authentication
2. 然后会调用parent的authenticate方法,也就是DaoAuthenticationProvider的authenticate方法,不过DaoAuthenticationProvider没有重写authenticate方法,
所以具体方法实现还要看DaoAuthenticationProvider的父类AbstractUserDetailsAuthenticationProvider的authenticate方法。
截取一部分方法,可见将UsernameNotFoundException异常封装,然后抛出BadCredentialsException
```java
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
() -> messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.onlySupports",
"Only UsernamePasswordAuthenticationToken is supported"));
// Determine username
String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED"
: authentication.getName();
boolean cacheWasUsed = true;
UserDetails user = this.userCache.getUserFromCache(username);
if (user == null) {
cacheWasUsed = false;
try {
user = retrieveUser(username,
(UsernamePasswordAuthenticationToken) authentication);
} catch (UsernameNotFoundException notFound) {
logger.debug("User '" + username + "' not found");
if (hideUserNotFoundExceptions) {
throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
} else {
throw notFound;
}
}
Assert.notNull(user,
"retrieveUser returned null - a violation of the interface contract");
}
// ....
return createSuccessAuthentication(principalToReturn, authentication, user);
}
```
#### 未认证处理方案
在没有认证就访问接口页面的,在前后端不分离的情况下,直接重定向到登录页就好了。 但是在前后端分离的情况下,就不能这样处理了。此时后端不能直接进行重定向,而是返回一段JSON提示用户未登录给前端,让前端决定页面跳转。
在默认情况下,未登录就访问接口,会调用 `public class LoginUrlAuthenticationEntryPoint implements AuthenticationEntryPoint, InitializingBean`
类的commence方法来处理,默认重定向的登录页。
可以通过实现 AuthenticationEntryPoint接口 重写 commence方法来替换策略
```java
.and()
.csrf().disable()
.exceptionHandling()
.authenticationEntryPoint((request,response,authException)->{
response.setContentType("application/json;charset=utf-8");
PrintWriter out=response.getWriter();
out.write("尚未登录,请先登录");
out.flush();
out.close();
});
```
#### 注销登录
原先也是跳转页面,现在直接返回一个json,具体跳转逻辑由前端决定
```java
http.logout()
.logoutUrl("/logout")
.logoutSuccessHandler((request,response,authentication)->{
response.setContentType("application/json;charset=utf-8");
PrintWriter out=response.getWriter();
out.write("注销成功");
out.flush();
out.close();
})
.deleteCookies();
```
## 基于内存用户的授权
### 1.授权
所谓授权,就是用户如果要访问某一个资源,我们需要去检查用户是否具备这样的权限,如果具备就允许访问,如果不具备,则不允许访问。
### 2.准备测试用户
由于当前还没有连接数据库,所以测试用户还是基于内存来配置。 基于内存配置用户有两种方式,第一种就是和之前一样,在SecurityConfig类中重写 configure(AuthenticationManagerBuilder auth)
方法
```java
@Override
protected void configure(AuthenticationManagerBuilder auth)throws Exception{
auth.inMemoryAuthentication()
.withUser("spencer")
.password("123")
.roles("admin")
.and()
.withUser("maqb")
.password("123")
.roles("user");
}
```
还有一种就是重写 userDetailsService()方法,注意要加一个@Bean注解
```java
@Bean
@Override
protected UserDetailsService userDetailsService(){
InMemoryUserDetailsManager manager=new InMemoryUserDetailsManager();
manager.createUser(User.withUsername("spencer").password("123").roles("admin").build());
manager.createUser(User.withUsername("maqb").password("123").roles("user").build());
return manager;
}
```
### 3.准备测试接口
```java
@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";
}
}
```
如上准备了3个测试接口,规划是这样的
1. /hello 是任何身份都可以访问的
2. /admin/hello 是具有admin身份的人才可以访问的
3. /user/hello 是具有user身份的人才能访问的
4. 所有user可以访问的资源,admin都可以访问
**「注意第四条规范意味着所有具备 admin 身份的人自动具备 user 身份。」**
### 4.配置
接下来我们来配置权限的拦截规则,在 Spring Security 的 configure(HttpSecurity http) 方法中,代码如下: 注意 anyRequest() 一定要放在最后, 查看配置源码
AbstractRequestMatcherRegistry类 可知: 在任何拦截规则之前(包括 anyRequest 自身),都会先判断 anyRequest 是否已经配置,如果已经配置,则会抛出异常,系统启动失败。
```java
@Override
protected void configure(HttpSecurity http)throws Exception{
http.authorizeRequests()
.antMatchers("/admin/**").hasRole("admin")
.antMatchers("/user/**").hasRole("user")
.and()
.authorizeRequests().anyRequest().authenticated();
// ...
}
```
这里的匹配规则我们采用了 Ant 风格的路径匹配符,Ant 风格的路径匹配符在 Spring 家族中使用非常广泛,它的匹配规则也非常简单:
| 通配符 | 含义 |
| ------ | ------ |
| ** | 匹配多层路径 |
| * | 匹配一层路径 |
| ? | 匹配任意单个字符 |
### 5.启动测试
在使用maqb用户登录后(角色为user),可以访问
+ /hello
+ /user/hello
不可以访问
+ /admin/hello 403
在使用spencer用户登录后(角色为admin),可以访问
+ /hello
+ /admin/hello
不可以访问
+ /user/hello 403
这与我们最初设想的admin用户可以访问/user/hello 不符合 关于这一个问题,可以通过修改授权设置来解决,直接设置 /user/** 可以让user和admin都可以访问
```java
.antMatchers("/user/**").hasRole("user") // 修改成如下
.antMatchers("/user/**").hasAnyRole("user","admin")
```
还有一种方法就是角色继承
### 6.角色继承
所有user角色能访问的资源,admin都可以访问,可以通过角色继承来实现 在SpringSecurity中,我们只需要在SecurityConfig中添加一个配置就好了
```java
@Bean
RoleHierarchy roleHierarchy(){
RoleHierarchyImpl roleHierarchy=new RoleHierarchyImpl();
roleHierarchy.setHierarchy("ROLE_admin > ROLE_user");
return roleHierarchy;
}
```
注意要加上前缀 ROLE_
此时再重启项目,admin角色也可以访问 /user/hello
## 将用户存入数据库
### UserDetailsService
SpringSecurity支持多种不同的数据源,这些不同的数据源最终都被封装成 UserDetailsService 的实例。
我们可以自己实现一个UserDetailsService,也可以使用SpringSecurity自带的,如上一节用到的InMemoryUserDetailsManager。 如下图展示了UserDetailsService的继承结构

除了InMemoryUserDetailsManager之外,还可以使用 JdbcUserDetailsManager,JdbcUserDetailsManager可以通过JDBC的方式将数据库和SpringSecurity连接起来
### JdbcUserDetailsManager
JdbcUserDetailsManager 自己提供了一个数据库模型,这个模型的位置保存在 `org/springframework/security/core/userdetails/jdbc/users.ddl`
具体的脚本内容如下
```sql
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 即可。 修改完之后,执行脚本,生成两个表
+ users表中保存用户的基本信息,包括用户名、密码、账户是否可用。
+ authorities表中保存了用户的角色
+ 两个表通过username连接
### 连接数据库
在完成数据库表的创建之后,使用JdbcUserDetailsManager替换InMemoryUserDetailsManager。
1. 添加依赖
```xml
org.springframework.boot
spring-boot-starter-jdbc
mysql
mysql-connector-java
```
2. 添加数据连接配置
```yaml
spring:
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://localhost:3306/spring_security_sample?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
username: root
password: admin
```
3. 修改SecurityConfig配置类 启动的时候,如果spencer和maqb两个用户不存在,则创建他们
```java
@Autowired
DataSource dataSource;
@Bean
@Override
protected UserDetailsService userDetailsService() {
JdbcUserDetailsManager manager = new JdbcUserDetailsManager();
manager.setDataSource(dataSource);
if (!manager.userExists("spencer")) {
manager.createUser(User.withUsername("spencer").password("123").roles("admin").build());
}
if (!manager.userExists("maqb")) {
manager.createUser(User.withUsername("maqb").password("123").roles("user").build());
}
return manager;
}
```
启动项目,可见数据库表中自动添加了数据

### 测试
项目启动后,用户可以登录

spencer用户三个接口都可以访问, maqb用户只能访问 /hello 和 /user/hello
## SpringSecurity+JPA
上一节已经将用户保存到了数据库中,但是使用的是JDBC,还是不太方便,下面就引入JPA来完成数据库操作。(Mybatis也是同理)
### 1.创建工程
创建一个Maven或者Springboot项目,添加需要的依赖。 再在数据库中创建一个新的库 withjpa
### 2.准备模型
准备两个实体类,分别代表用户和角色。
用户角色:
```java
@Entity(name = "t_role")
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String nameZh;
// getter/setter
}
```
用户实体:
```java
@Entity(name = "t_user")
public class User implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String password;
private boolean accountNonExpired;
private boolean accountNonLocked;
private boolean credentialsNonExpired;
private boolean enabled;
@ManyToMany(fetch = FetchType.EAGER, cascade = CascadeType.PERSIST)
private List roles;
@Override
public Collection extends GrantedAuthority> getAuthorities() {
List authorities = new ArrayList<>();
for (Role role : getRoles()) {
authorities.add(new SimpleGrantedAuthority(role.getName()));
}
return authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@Override
public boolean isAccountNonExpired() {
return accountNonExpired;
}
@Override
public boolean isAccountNonLocked() {
return accountNonLocked;
}
@Override
public boolean isCredentialsNonExpired() {
return credentialsNonExpired;
}
@Override
public boolean isEnabled() {
return enabled;
}
// getter/setter
}
```
用户实体类实现了 UserDetails 接口,此接口用于提供用户的核心信息。 稍微解释下此类的字段:
1. accountNonExpired、accountNonLocked、credentialsNonExpired、enabled这四个属性分别用来表示账户是否没有过期、 账户是否没有被锁定、密码是否没过期、账户是否可用。
2. roles 属性表示用于的角色,User和Role是多对多
3. getAuthorities 方法返回用户的权限信息,此处就直接将角色稍微处理下返回了
### 3.配置
1. 数据模型准备好之后,再来自定义一个 UserDao
```java
public interface UserDao extends JpaRepository {
User findUserByUsername(String username);
}
```
2. 定义UserService UserService实现了UserDetailsService接口,要实现loadUserByUsername方法。
这个方法的参数就是登录时传入的用户名,根据此用户名去数据库查出用户,然后进行密码对比(自动完成)
```java
@Service
public class UserService implements UserDetailsService {
@Resource
private UserDao userDao;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userDao.findUserByUsername(username);
if (user == null) {
throw new UsernameNotFoundException("用户名不存在");
}
return user;
}
}
```
3.修改SecurityConfig配置类 去掉之前的基于内存的配置和JdbcUserDetailsManager相关的配置,加上如下配置。
```java
@Autowired
UserService userService;
@Override
protected void configure(AuthenticationManagerBuilder auth)throws Exception{
auth.userDetailsService(userService).passwordEncoder(passwordEncoder());
}
```
4. 添加yml配置
```yaml
spring:
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://localhost:3306/withjpa?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
username: root
password: admin
jpa:
database: mysql
#database-platform: mysql
hibernate:
ddl-auto: update
show-sql: true
```
### 4.测试
先使用测试单元,给数据库添加一些数据
```java
@SpringBootTest
public class WithJpaApplicationTests {
@Resource
UserDao userDao;
@Test
void contextLoads() {
User u1 = new User();
u1.setUsername("spencer");
u1.setPassword("123");
u1.setAccountNonExpired(true);
u1.setAccountNonLocked(true);
u1.setCredentialsNonExpired(true);
u1.setEnabled(true);
List rs1 = new ArrayList<>();
Role r1 = new Role();
r1.setName("ROLE_admin");
r1.setNameZh("管理员");
rs1.add(r1);
u1.setRoles(rs1);
userDao.save(u1);
User u2 = new User();
u2.setUsername("maqb");
u2.setPassword("123");
u2.setAccountNonExpired(true);
u2.setAccountNonLocked(true);
u2.setCredentialsNonExpired(true);
u2.setEnabled(true);
List rs2 = new ArrayList<>();
Role r2 = new Role();
r2.setName("ROLE_user");
r2.setNameZh("普通用户");
rs2.add(r2);
u2.setRoles(rs2);
userDao.save(u2);
}
}
```
执行完成之后可以看见数据库withjpa库表中已经有了数据

再有了数据之后,就可以启动项目测试了。启动之后,可以成功登录。 并且登录成功后, admin角色的用户 /hello 、/user/hello 、/admin/hello三个接口都可以访问

## 自动登录
自动登录是一个非常常见的功能,毕竟每次都要输入用户名和密码很麻烦。 自动登录的功能就是,用户在登录成功之后,在某一段时间内,如果用户关闭了浏览器重新打开,或者服务器重启了,都不需要用户重新登录, 用户依然可以直接访问资源。
作为一个常见的功能,SpringSecurity也已经提供了相应的支持,先看下SpringSecurity如何实现这个功能。
### 实战代码
其实只要在SpringSecurity配置类中加一句 .rememberMe() 就好了
```java
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().anyRequest().authenticated();
http.formLogin()
.and()
.rememberMe()
.and()
.csrf().disable();
}
```
然后随意来一个测试接口
```java
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "hello";
}
}
```
启动项目输入localhost:8080/hello ,会自动跳转到登录页,在默认的登录页,可以看到有个rememberMe的选项

输入用户名、密码,选中remember Me,然后登录,查看登录请求所携带的参数。如果要自定义页面,注意rememberMe的参数名字

在登录成功之后,就会自动跳转到 hello 接口了。注意,系统访问 hello 时自动携带的 cookie

可以看到这里多了一个remember-me,这就是实现的核心,关于这个remember-me稍后再看原理,先来测试效果。 我们关闭浏览器后,再重新打开,直接访问 hello
接口,发现直接就访问到了,不需要重新登录。说明RememberMe生效了。 (不过此时重启服务,浏览器访问hello接口还是要重新登录的)
### 原理分析
将访问hello时cookie中的 remember-me 的值复制出来,这是一个Base64编码后的字符串,对其进行解码。
```java
public class DecodeTest {
@Test
void t1() {
Base64.Decoder decoder = Base64.getDecoder();
String str = new String(decoder.decode("c3BlbmNlcjoxNjE4MjMyMzU0MTYzOmZjMmEwODllY2UzMWU1ZWE0NTM5NDExOGI0NTU3NGI1"));
System.out.println(str); // spencer:1618232354163:fc2a089ece31e5ea45394118b45574b5
}
}
```
可见解码后的字符串,是由三部分组成的,中间由:隔开。
`spencer:1618232354163:fc2a089ece31e5ea45394118b45574b5`
1. 第一段比较明显,是登录的用户名
2. 第二段是一个时间戳,通过在线时间戳转换后发现,是一个两周后的时间
3. 第三段是使用MD5散列函数算出来的值,它的明文格式是
`username + ":" + tokenExpiryTime + ":" + password + ":" + key`
最后的key是一个散列盐值,可以防止令牌被修改
在了解完cookie中 remember-me 的含义之后,对于记住的登录流程也大致可以猜到了。
在浏览器关闭重新打开后,用户再去访问 hello 接口,此时会携带 cookie 中的 remember-me 到服务端。 服务端拿到值后,可以方便的计算出用户名和过期时间,若是还在有效期内,则使用用户名查询到用户密码,
然后通过MD5散列函数计算出散列值,再将计算出的散列值和浏览器传递来的散列值进行对比,就能确认这个令牌是否有效。
### 源码分析
这里主要从两个方面来介绍,一个是 remember-me 这个令牌的生成过程,另一个则是它的解析过程
#### 1. 生成
生成的核心处理方法在:`TokenBasedRememberMeServices#onLoginSuccess`
```java
@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)) {
logger.debug("Unable to retrieve username");
return;
}
if (!StringUtils.hasLength(password)) {
UserDetails user = getUserDetailsService().loadUserByUsername(username);
password = user.getPassword();
if (!StringUtils.hasLength(password)) {
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 (logger.isDebugEnabled()) {
logger.debug("Added remember-me cookie for user '" + username
+ "', expiry: '" + new Date(expiryTime) + "'");
}
}
protected String makeTokenSignature(long tokenExpiryTime, String username,
String password) {
String data = username + ":" + tokenExpiryTime + ":" + password + ":" + getKey();
MessageDigest digest;
try {
digest = MessageDigest.getInstance("MD5");
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("No MD5 algorithm available!");
}
return new String(Hex.encode(digest.digest(data.getBytes())));
}
```
这个方法比较好理解:
1. 登录成功后从Authentication中取出用户名和密码
2. 由于登录成功之后,密码可能被擦除了。所以,如果一开始没有拿到密码,就从UserDetailsService中重新加载用户重新获取密码
3. 接下来就是获取令牌的有效期,默认是两周时间。
4. 在接下来调用 makeTokenSignature 方法计算散列值。实际上就是根据username、令牌有效期、password、key 一起去计算一个散列值。 如果没有自定义key的值,默认是在
RememberMeConfigurer#getKey 方法中进行设置的,它的值是一个 UUID 字符串。
5. 最后将用户名、令牌有效期以及计算得到的散列值放入 Cookie 中
对第四点补充下: 由于我们没有自己设置 key,key的默认值是一个UUID字符串,这样会带来一个问题,就是如果服务端重启,这个key就会变, 这个样就会导致之前派发出去的 remember-me 自动登录令牌失效,所以,我们可以指定这个
key。指定方式如下:
```java
@Override
protected void configure(HttpSecurity http)throws Exception{
http.authorizeRequests().anyRequest().authenticated();
http.formLogin()
.and()
.rememberMe()
.key("maqb")
.and()
.csrf().disable();
}
```
具体从登录如何走到这里的,大致流程如下
```text
AbstractAuthenticationProcessingFilter#doFilter ->
AbstractAuthenticationProcessingFilter#successfulAuthentication ->
AbstractRememberMeServices#loginSuccess ->
TokenBasedRememberMeServices#onLoginSuccess。
```
#### 2. 解析
解析方法在 RememberMeAuthenticationFilter#doFilter 中
```java
public void doFilter(ServletRequest req,ServletResponse res,FilterChain chain)
throws IOException,ServletException{
HttpServletRequest request=(HttpServletRequest)req;
HttpServletResponse response=(HttpServletResponse)res;
if(SecurityContextHolder.getContext().getAuthentication()==null){
Authentication rememberMeAuth=rememberMeServices.autoLogin(request,
response);
// ...略
}
}
```
在 `SecurityContextHolder.getContext().getAuthentication()` 无法获取登录用户的时候,调用autoLogin方法 再来查看autoLogin方法
```java
@Override
public final Authentication autoLogin(HttpServletRequest request,
HttpServletResponse response) {
String rememberMeCookie = extractRememberMeCookie(request);
if (rememberMeCookie == null) {
return null;
}
logger.debug("Remember-me cookie detected");
if (rememberMeCookie.length() == 0) {
logger.debug("Cookie was empty");
cancelCookie(request, response);
return null;
}
UserDetails user = null;
try {
String[] cookieTokens = decodeCookie(rememberMeCookie);
user = processAutoLoginCookie(cookieTokens, request, response);
userDetailsChecker.check(user);
// ...略
}
}
```
这个方法会从requestHeader中获取remember-me这个cookie的值,然后使用decodeCookie方法进行解码。 解码之后,再调用 processAutoLoginCookie
方法去做校验,processAutoLoginCookie 方法的代码我就不贴了,核心流程就是首先获取用户名和过期时间,再根据用户名查询到用户密码,然后通过 MD5
散列函数计算出散列值,再将拿到的散列值和浏览器传递来的散列值进行对比,就能确认这个令牌是否有效,进而确认登录是否有效。
## 自动登录-降低安全风险
在上一节的自动登陆中,我们自定义了key,使得就算服务器重启,也可以使用旧的令牌登录(remember-me 的值)。 如果这个令牌泄露,被别人拿到,那么也就可以随意访问了。
下面列举两种降低风险的方法
1. 持久化令牌方案
2. 二次校验
### 1 持久化令牌
#### 1.1 基本原理
持久化令牌在基本的自动登录功能基础上,又增加了新的校验参数,一个是 series,另一个是token。 其中,series 只有当用户在使用用户名/密码登录时,才会生成或者更新。而token只要有新的会话,就会重新生成。
这样就可以避免一个用户同时多端在线,就像手机QQ,一个手机上登录了,就会踢掉另一个手机的登录,这样用户很容易就可以发现账户是否泄露。
持久化令牌的具体处理类在 PersistentTokenBasedRememberMeServices。而上一节的自动化登录使用的是TokenBasedRmemeberMeServices。
它们都是AbstractRememberMeServices的子类。

用来保存令牌的处理类是 `PersistentRmemeberMeToken`,字段date表示上一次自动登录的时间。
```java
public class PersistentRememberMeToken {
private final String username;
private final String series;
private final String tokenValue;
private final Date date;
// ...
}
```
#### 1.2 代码演示
首先我们需要一张表来记录令牌信息,这张表可以自定义,也可以使用系统默认提供的JDBC来操作,如果使用默认的JDBC, 即 JdbcTokenRepositoryImpl。来看下此类的默认定义的SQL语句
```java
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
```sql
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;
```
先直接在Mysql中执行语句创建好表。
然后给项目添加依赖
```xml
org.springframework.boot
spring-boot-starter-jdbc
mysql
mysql-connector-java
```
再修改yaml,添加数据库连接的配置
```yaml
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
username: root
password: admin
url: jdbc:mysql://localhost:3306/withjpa?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
```
最后在SecurityConfig类中配置 JdbcTokenRepositoryImpl
```java
@Autowired
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();
http.formLogin()
.and()
.rememberMe()
.key("maqb")
.tokenRepository(jdbcTokenRepository())
.and()
.csrf().disable();
}
```
提供一个 JdbcTokenRepositoryImpl 实例,并给其配置 DataSource 数据源,最后通过 tokenRepository 将 JdbcTokenRepositoryImpl 实例纳入配置中。
#### 1.3 测试
还是去访问 /hello 接口,此时会自动跳转到登录页面,输入用户名、密码,勾选记住我这个选项,登录成功后成功访问到 /hello。

将remember-me的字符串取出来,再次使用Base64解码,得到如下字符串:
`wkuRheVGuu8wNfC%2FKiXflg%3D%3D:bQBHqMWp9YJGlehJco4I1A%3D%3D`
其中 %2f 表示 `/` %3D 表示 `=`,所以上面的字符串实际上是
`wkuRheVGuu8wNfC/KiXflg==:bQBHqMWp9YJGlehJco4I1A==`
此时再去数据库查看,可以看到有一条持久化的登录令牌数据,其中的series就是上面数字:的前半段, 而token就是:的后半段

此时我们再重启服务器,直接访问 /hello 接口,没有问题,可以直接访问。
然后再来查看数据库的数据,发现token和last_used变化了

#### 1.4 源码分析
此处的流程和自动登录那里的流程是一样的,只是具体的实现类不同了。
首先是 生成过程:
也是先从 AbstractAuthenticationProcessingFilter#doFilter
开始,进入此方法的最后一句 `successfulAuthentication(request, response, chain, authResult);`
会进入此类的successfulAuthentication方法中,当执行到此方法的 `rememberMeServices.loginSuccess(request, response, authResult);`
就会进入 PersistentTokenBasedRememberMeServices 的 loginSuccess 方法中
来仔细看下此方法
```java
protected void onLoginSuccess(HttpServletRequest request,
HttpServletResponse response, Authentication successfulAuthentication) {
String username = successfulAuthentication.getName();
logger.debug("Creating new persistent login for user " + username);
PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(
username, generateSeriesData(), generateTokenData(), new Date());
try {
tokenRepository.createNewToken(persistentToken);
addCookie(persistentToken, request, response);
} catch (Exception e) {
logger.error("Failed to save persistent token ", e);
}
}
```
此方法可以看到
1. 在登录成功后,先获取到用户名
2. 接下来构造一个 PersistentRememberMeToken 实例,使用generateSeriesData()和generateTokenData()来生成series和token, 具体的生成过程实际上就是调用
SecureRandom 生成随机数再进行 Base64 编码,不同于我们以前用的 Math.random 或者 java.util.Random 这种伪随机数, SecureRandom
则采用的是类似于密码学的随机数生成规则,其输出结果较难预测,适合在登录这样的场景下使用。
3. 调用 tokenRepository.createNewToken方法,这个tokenRepository就是一开始配置的JdbcTokenRepositoryImpl,这个createNewToken方法其实就是将数据存入数据库中
4. addCookie 就是将series和token加到cookie中。
---
然后就是 校验 的过程了,也在 PersistentTokenBasedRememberMeService 这个类中
```java
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)+"'");
}
final String presentedSeries=cookieTokens[0];
final String presentedToken=cookieTokens[1];
PersistentRememberMeToken token=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.
tokenRepository.removeUserTokens(token.getUsername());
throw new CookieTheftException(
messages.getMessage(
"PersistentTokenBasedRememberMeServices.cookieStolen",
"Invalid remember-me token (Series/token) mismatch. Implies previous cookie theft attack."));
}
if(token.getDate().getTime()+getTokenValiditySeconds()*1000L