Spring Security默认是基于session进行用户认证的,用户通过登录请求完成认证之后,认证信息在服务器端保存在session中,之后的请求发送上来后SecurityContextPersistenceFilter过滤器从session中获取认证信息、以便通过后续安全过滤器的安全检查。
今天的目标是替换Spring Security默认的session保存认证信息的机制为***通过JWT的方式进行认证。***
JWT(JSON WEB TOKEN)的相关内容就不做详细分析了,我们只需要知道以下几点:
1. 用户登录认证(用户名、密码验证)通过之后,系统生成token并送给前端。
2. token中包含用户id(或用户名)以及过期时间,包含通过加密机制生成的摘要,具有防篡改的能力。
3. token信息不需要在服务器端保存,前端获取到token之后,每次请求都必须携带该token。
4. 后台接收到请求之后,检查没有token、或者token验证不通过则不生成认证信息,否则,token验证通过则表示该用户通过认证。
5. 后台接收到的token如果已过期,则根据应用的需求自动更新token或者要求前端重新登录。
与session方案对比一下,我们需要解决的问题如下:
1. 需要停用掉Spring Security默认的session管理用户认证信息的方案。
2. 用户登录后需要生成并返回给前端token。
3. 前端请求上来之后,需要获取并验证token,验证通过后生成用户认证信息。
下面我们逐一解决上述三个问题。我们仍然使用上一篇文章中用过的demo,已经贴出过的代码就不再贴出了。
#### 准备工作
我们需要准备一些与JWT相关的东西,比如引入JWT的生成token、token验证的模块。
我们引入java-jwt,在pom文件加入依赖即可:
```
```
然后需要编写一个工具类,以便能够生成、验证token,我们暂时不考虑token过期等等细节问题的处理,只要能正确生成、验证token就可以:
```
public class JwtUtil {
public final static String SECRET_KEY="This is secret key for JWT";
public final static String JWTHeader_Leading_Str="Bearer ";
public final static String JWTHeader_Name="Authorization";
public static String generateToken(String userName){
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.SECOND,120);
HashMap header = new HashMap<>();
header.put("alg","HS256");
header.put("Type","JWT");
return JWT.create().withHeader(header)
.withClaim("userName",userName)
.withExpiresAt(calendar.getTime())
.sign(Algorithm.HMAC256(SECRET_KEY));
}
public static String verify(String token){
// 创建解析对象,使用的算法和secret要与创建token时保持一致
JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(SECRET_KEY)).build();
// 解析指定的token
DecodedJWT decodedJWT = jwtVerifier.verify(token);
return decodedJWT.getClaims().get("userName").asString();
}
public static String parseToken(HttpServletRequest request){
String rawJwt = request.getHeader(JWTHeader_Name);
if(rawJwt==null){
return null;
}
if(!rawJwt.startsWith(JWTHeader_Leading_Str)){
return null;
}
return rawJwt.substring(JWTHeader_Leading_Str.length()+1);
}
private void showToken(DecodedJWT decodedJWT){
// 获取解析后的token中的信息
String header = decodedJWT.getHeader();
System.out.println("type:" + decodedJWT.getType());
System.out.println("header:" + header);
Map
System.out.println("Payload:" + payloadMap);
Date expires = decodedJWT.getExpiresAt();
System.out.println("过期时间:" + expires);
String signature = decodedJWT.getSignature();
System.out.println("signature:" + signature);
}
public static void main(String[] args) {
String token=JwtUtil.generateToken("Zhang Fu");
System.out.println(token);
String userName = JwtUtil.verify(token);
System.out.println("userName:" +userName);
}
}
```
OK,准备工作完成。
#### 停用Spring Security的默认session方案
为了停用session,我们需要增加一项配置,所以我们要新建一个配置文件:
```
@Configuration
public class WebSecurityConfig {
@Autowired
MyRememberMeService myRememberMeService;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception
{
httpSecurity.authorizeRequests()
//.antMatchers("/hello").permitAll()
.anyRequest().authenticated().and()
.httpBasic().and()
.rememberMe().rememberMeServices(myRememberMeService)
.and().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.formLogin();
//httpSecurity.addFilterBefore(new JwtSecurityFilter(), UsernamePasswordAuthenticationFilter.class);
//httpSecurity.addFilterAfter(new JwtAfterUsernamePasswordFilter(),UsernamePasswordAuthenticationFilter.class);
return httpSecurity.build();
}
}
```
***设置SessionCreationPolicy.STATELESS就可以达到目的。***
原因可以在sessionManagementConfigure.java这个session配置器中找到,在他的init方法中:
```
@Override
public void init(H http) {
SecurityContextRepository securityContextRepository = http.getSharedObject(SecurityContextRepository.class);
boolean stateless = isStateless();
if (securityContextRepository == null) {
if (stateless) {
http.setSharedObject(SecurityContextRepository.class, new NullSecurityContextRepository());
}
```
如果SessionCreationPolicy设置为stateless的话,那么他会创建NullSecurityContextRepository作为他的SecurityContextRepository。
这个NullSecurityContextRepository实际就是个假把式,啥也不干,我们知道用户认证通过后会调用他的saveContext方法存储认证信息,他是这么干的:
```
@Override
public void saveContext(SecurityContext context, HttpServletRequest request, HttpServletResponse response) {
}
```
所以,他就是个偷工减料的货,啥也没干。
所以第一个问题解决了。
#### 用户登录后生成token并返回给前端
这个问题我尝试了好几个方案之后才成功。
我们知道用户登录是在安全过滤器UsernamePasswordAuthenticationFilter中完成的,登录成功后如果想要生成JWT的token,方案无非就是:
1. UsernamePasswordAuthenticationFilter之后加一个我们自己的过滤器,与UsernamePasswordAuthenticationFilter一样只匹配登录请求,生成token。
2. UsernamePasswordAuthenticationFilter过滤器认证通过后有没有调用过其他可以被我们客户化的东东,我们客户化这个东东完成我们的目标。
3. 客户化UsernamePasswordAuthenticationFilter,登录成功后生成token。
这里必须交代一下,第3个方案只是从逻辑上来说应该能解决我们的问题,但是压根就没有考虑过这个方案,因为我觉得太麻烦。
先试了第一个方案,没成功,因为我们知道Spring Security还有一个RequestCacheAwareFilter过滤器,会导致如果你是在尚未获取授权之前访问了非登录页面,那么Spring Security会导航到登录页面、登录成功后在UsernamePasswordAuthenticationFilter中就会发生跳转,这样的话就跳过了我们后面加的这个过滤器,目标就无法实现或者说即使弯弯绕绕能实现,但是方案也不会太好。
所以,就努力研究第2个方案。
所以大概看了一下UsernamePasswordAuthenticationFilter在登录认证成功后的处理,发现了这个:
![image.png](/img/bVc472f)
所以就大概去研究了一下RememberMeServices,读了一下他的doc,发现他是一个基于cokie的、确保前台请求即使在session过期之后发送上来都可以继续通过安全认证的“记住我”机制。
除了cokie之外,其他的与JWT的要求完全吻合。如果我们能自己实现一个基于JWT的RememberMeService,是不是就解决问题了?
所以就用他来尝试一下。
#### MyRememberMeService#loginSuccess
创建MyRememberMeService并通过配置加入到应用中来,前面停用session的时候的配置文件中已经加入了,返回去看一眼就行。
我们要实现他的loginSuccess方法,创建并返回token:
```
@Override
public void loginSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication successfulAuthentication) {
String username = successfulAuthentication.getName();
String token= JwtUtil.generateToken(username);
token=JwtUtil.JWTHeader_Leading_Str+token;
log.info("After login success:"+token);
response.setHeader(JwtUtil.JWTHeader_Name,token);
}
```
#### 验证一下创建并返回token
启动项目,成功登录系统后,惊喜的发现他已经开始干活了:
![image.png](/img/bVc479a)
好了,给了我们信心,撸起袖子加油干!
#### RememberMeAuthenticationFilter
RememberMeServices机制依赖RememberMeAuthenticationFilter实现,我们在上面的配置文件中已经启用了。
![image.png](/img/bVc472O)
然后简单看一眼RememberMeAuthenticationFilter过滤器的doFilter方法,他首先去SecurityContextHolder获取认证信息,如果没有获取到的话,就调用RememberMeService的autoLogin方法,只是从doFilter的源码来看(代码就不贴出了),autoLogin方法返回的Authentication并未完成认证,因为返回之后还要调用authenticationManager进行认证。
![image.png](/img/bVc478Q)
这是与我们预期不符的地方,我们希望autoLogin之后就可以完成认证、并且可以将认证信息放置到SecurityContextHolder中(因为我们是通过JWT做验证的,token验证通过的话就相当于完成了认证)。
***那我们是不是可以在autoLogin中完成这些操作,并且返回null骗一下RememberMeAuthenticationFilter的doFilter方法不再要求authenticationManager去再次认证呢?***
我们试一下!
#### MyRememberMeService#autoLogin
创建RememberMeService并实现autoLogin方法,为了简化他的初始化过程,我们直接把他注入到Spring Ioc容器中。
***如前所述,方法一定要返回null。***
```
@Slf4j
@Component
public class MyRememberMeService implements RememberMeServices {
@Autowired
MyUserDetailsService myUserDetailsService;
@Override
public Authentication autoLogin(HttpServletRequest request, HttpServletResponse response) {
log.info("autoLogin in MyRememberMeService: ");
String username;
String token = JwtUtil.parseToken(request);
if(token==null){
log.info("I dont get token from header");
token=request.getParameter("token");
}
log.info("finally the token is :" + token);
UsernamePasswordAuthenticationToken authenticationToken=null;
if(token!=null) {
String userName=JwtUtil.verify(token);
UserDetails user = myUserDetailsService.loadUserByUsername(userName);
authenticationToken = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
return null;
}
@Override
public void loginFail(HttpServletRequest request, HttpServletResponse response) {
}
}
```
#### 测试一下autoLogin
上面的代码中已经看到了,我们只是为了测试、如果从请求头信息中拿不到token的话就从请求参数中获取。只是为了学习、测试偷个懒,正式项目实现的时候这个地方还是需要比较多的完善的。
启动项目,开始测试,第一步先通过login获取token,上面已经展示过了,然后用获取到的token发一个需要认证的请求,token加在请求参数后面:
![image.png](/img/bVc479l)
如图,请求成功了!
上一篇 [Spring Security自定义用户认证过程(2)](https://segmentfault.com/a/1190000043101057)
下一篇 [Mybatis拦截器](https://segmentfault.com/a/1190000043186656)