Spring Boot(十四):Spring Security JWT -实现分布式架构的认证!
大家好,我是 杰哥
前几次文章,我们分别介绍了 Spring Security
和 JWT
,并通过入门案例,分别了解到 Spring Security
和 JWT
实现认证的方式。
Spring Security
是一个声明式的安全访问控制的框架,而 JWT
则是信息传输的一种开放标准,是一种跨域解决通用方案
业内也常常将两者结合,也就是说使用 Spring Security
框架时,采用 JWT
替代 Session
来实现分布式环境下的认证功能
Spring Security:Spring Boot(十一):Spring Security 实现权限控制
JWT : 告别 session,还是这个认证方案优秀!
具体该如何结合呢?我们今天就一起来探索一番
一 理论优先
(一) 重要角色认识
首先要知道这么几个事情,实战中会使用到哦~
-
SecurityContext:
Spring Security
的上下文对象,Authentication
认证对象会放在里面,若用户未认证,则Authentication
。对象的内容为null
-
SecurityContextHolder:用于拿到上下文对象的静态工具类,使用方法
SecurityContextHolder.getContext()
就可以获取到SecurityContext
对象, -
Authentication:认证接口,定义了认证对象的数据形式,比如用户名密码
-
AuthenticationManager:认证管理器。这个接口规范了
Spring Security
的过滤器要如何执行身份认证,并在身份认证成功后返回一个经过认证的Authentication
对象
(二)Spring Security
的过滤器链
之前我们介绍 Spring Security
的时候,曾经提到过 Spring Security
采用的是责任链的设计模式,它有一条很长的过滤器链来实现各个环节的控制逻辑
preview
如上图,每个 WEB 请求,会经过这么一条过滤器链,在经过过滤器的过程中会完成认证与授权。如果中间发现这个请求未认证或者未授权,就会抛出异常
Spring Security
的过滤器链有十几个,我们本篇主要讲的是 Spring Security
采用JWT
实现认证方案,所以只关注以下几个:
-
SecurityContextPersistenceFilter
,在新版本中已经被废弃,推荐使用SecurityContextHolderFilter
。在运行应用程序的其余部分之前,SecurityContextHolderFilter
从SecurityContextRepository
加载SecurityContext
并将其设置在SecurityContextHolder
上 -
UsernamePasswordAuthenticationFilter
是针对使用用户名和密码进行身份认证而定制化的一个过滤器,如果将原本默认的认证方案变更为JWT
,那么就需要定义一个过滤器,并配置在其前面将认证信息存储至上下文之后,再进行认证 -
FilterSecurityInterceptor
会根据SecurityContextHolder
中存储的用户信息来决定其是否有权限,从而决定是否允许访问
二 实战
既然 JWT
只是不同于 Session
的另一种认证方案,那么,我们的实战,也将在 Spring Security demo
的基础上,将原本的 Session
机制,更换为 JWT
而已
(一)Spring Security
配置类
Spring Security
配置类 - SecurityConfig
如下
在 Spring Security demo
的基础上,为了将认证方案更改为JWT
,做了两个地方的改动
1 认证过滤器配置
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry
authorizeRequests = http.csrf().disable().authorizeRequests();
// 1.查询到所有的权限
List<Permission> allPermission = permissionMapper.findAllPermission();
// 2.分别添加权限规则
allPermission.forEach((p -> {
authorizeRequests.antMatchers(p.getUrl()).hasAnyAuthority(p.getName()) ;
}));
authorizeRequests.and()
// 配置为 Spring Security 不创建使用 session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/**").fullyAuthenticated()
//不用验证登录接口
.antMatchers("/authenticate").permitAll()
.anyRequest().authenticated();
//配置认证过滤器 jwtAuthenticationFilter
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
这里其实在之前 Spring Security
项目的基础上,增加了以下两项的配置
1)配置为 Spring Security
不创建使用 session
2)配置认证过滤器 jwtAuthenticationFilter
可以顺便复习一下:
SecurityFilterChain Bean
实际上是在新版本(2.7.0
版本) Spring Security
中,用来定义各个资源能够被拥有哪些权限的用户所访问的规则
如资源 /user/common
可以分别被具有 admin
、common
权限的用户所访问,而 /user/admin
则只可以被具有 admin
权限的用户所访问
2 配置认证接口的放行规则
/**
* 资源放行配置
* @return
*/
@Bean
WebSecurityCustomizer webSecurityCustomizer() {
return web -> {
web.ignoring().antMatchers("/hello");
web.ignoring().antMatchers("/login");
//登录接口放行
web.ignoring().antMatchers("/authenticate");
web.ignoring().antMatchers("/css/**", "/js/**");
};
}
登录接口 /authenticate
是用来获取 token
的,它本身当然无法携带认证信息,所以这里配置我们后续所定义的登录接口 /authenticate
不用被校验
(二)JWT 认证过滤器
用于每次请求的拦截处理。验证 token
,并将验证之后的token
对应的用户信息存储至上下文中
/**
* JWT 认证过滤器
*/
@Slf4j
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Resource
private JwtService jwtService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
try {
//1-获取 token
String token = request.getHeader("Authorization");
if (StrUtil.isBlank(token)) {
//放行,会自动执行后面的过滤器
logger.info("请求头不含 JWT token 或者 token 的值为空,调用下个过滤器");
filterChain.doFilter(request,response);
return;
}
//2-获取认证信息
Authentication authentication = jwtService.getAuthentication(token);
//3-设置用户验证对象至上下文
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (MalformedJwtException | ExpiredJwtException | UnsupportedJwtException e) {
SecurityContextHolder.getContext().setAuthentication(null);
} finally{
filterChain.doFilter(request, response);
}
}
}
这里通过继承 OncePerRequestFilter
类,对于客户端的请求进行拦截处理,并确保在一次请求中只通过一次过滤,避免重复执行
过滤器中的逻辑为:
1 获取 token。首先从客户端的请求头中获取 token
的值,并做非空校验
2 获取认证对象。调用 JwtService
的 getAuthentication()
方法得到 Authentication
对象
进入 JwtService
的 getAuthentication()
方法
/**
* 获取认证过的token对应的信息,包括用户以及用户对应的权限
* @param token
* @return
*/
public Authentication getAuthentication(String token) {
// 1-根据 token 和秘钥,解析出 JWT 的 claims 对象
Claims claims =
Jwts.parser()
.setSigningKey(KEY)
.parseClaimsJws(token)
.getBody();
// 2-获取权限信息,并转换为集合类型
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
//3-得到用户对象 principal
User principal = new User(claims.getSubject(), "", authorities);
//4-得到认证 token 对象(UsernamePasswordAuthenticationToken 实现了 Authentication 接口,表示是通过用户名密码认证过的 token 认证信息)
return new UsernamePasswordAuthenticationToken(principal, token, authorities);
}
1)得到 Claims 对象
根据 token
和秘钥,解析出 Claims
对象,这个里面就存储了我们的 JWT
的 payload
中各个参数值。debug
可以看到,这个里面实际上是我们在创建 token
时,存进去的数据:用户的用户名 sub
、权限列表 auth
以及过期时间 exp
2)得到权限列表 authorities
。 获取权限信息,并转换为集合类型(生成 token 时,是以字符串的形式存储的)
3)将认证过后的这个用户对象配置到 Security 上下文:SecurityContextHolder
后续的 FilterSecurityInterceptor
会根据 SecurityContextHolder
中存储的用户信息来决定其是否有权限,从而决定是否允许访问,请求结束之后会清除掉该用户信息
一般利用 ThreadLocal
机制来保存每个使用者的 SecurityContext
,就避免了多线程并发导致信息错乱等问题了
(三)登录接口-生成 token
@RestController
@CrossOrigin
public class JwtAuthenticationController {
@Resource
private UserService<User> userService;
@Resource
private JwtService jwtService;
/**
* 登录接口 - 用于生成 token
* @param username 用户名
* @param password 密码
* @return
* @throws Exception
*/
@PostMapping("/authenticate")
public ResponseResult login(String username, String password) throws Exception {
//1-校验用户名密码是否为空
if(StrUtil.isBlank(username) || StrUtil.isBlank(password)){
throw new Exception("用户名或密码不能为空!");
}
// 2-根据用户查询用户是否存在
User user = userService.findByUsername(username);
if (user == null){
throw new Exception("用户名或密码有误!");
}
//3-验证用户名密码
password = MD5Util.md5slat(password);
if (!password.equalsIgnoreCase(user.getPassword())){
throw new Exception("用户名或密码有误!");
}
UserVo userVo = UserVo.builder().build();
userVo.setId(user.getId());
userVo.setUsername(username);
userVo.setPassword(password);
//4- 生成 token
String token = jwtService.createToken(userVo);
userVo.setToken(token);
userVo.setRefreshToken(UUID.randomUUID().toString());
return new ResponseResult(userVo);
}
}
这里,就与上一篇中的登录接口的逻辑一样了,也是以下四个步骤:
1 校验用户名密码是否为空
2 根据用户名查询用户是否存在
3 验证用户名密码是否正确。(需要提前通过 MD5Util.md5slat(password
) 方法得到加密之后的密码,写入表 User
中的密码)
比如,我的密码是 123456
,首先采用 MD5Util.md5slat(123456)
生成一个秘钥
运行 main
方法,输出的秘钥为:53b432e61314e00bcc99287af9537dc4
填写在 user
表中
**4 生成 token
。**调用 jwtService.createToken()
方法生成 token
,并返回 UserVo
对象
那么,token
是怎样生成的呢?
进入 jwtService.createToken()
方法
/**
* 创建 token
* @param userVo 用户对象
* @return token
*/
public String createToken(UserVo userVo) {
String authorities = userVo.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
return Jwts.builder()
.setSubject(userVo.getUsername())
.claim(AUTHORITIES_KEY, authorities)
.setExpiration(new Date(now + TOKEN_EXSPIRE_TIME))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
采用 Jwts.builder()
创建一个JwtBuilder
构建器,然后分别配置用户的用户名 sub
、权限列表 auth
以及过期时间 exp
,指定加密算法,调用compact()
方法得到一个字符串,即生成了一个token
字符串
(四)资源接口
@RestController
@RequestMapping("/user")
public class UserController {
@GetMapping("/common")
public String common() {
return "hello~ common";
}
@GetMapping("/admin")
public String admin() {
return "hello~ admin";
}
}
分别定义 /user/common
和 /user/admin
两个 API
,作为用户的权限,用于测试不同用户的权限
这里只列出了该项目的主要类,此外,还分别需要创建用于密码加密的 MD5Util
工具类,User
、UserVo
、ResponseResult
等实体类, 用于项目本身的功能完善
三 测试
需要说明一下,本次的项目是在 Spring Security
文章里面的 demo(**github.com/helemile/Sp… Session
变更为 JWT
认证方案而已,原先的实现逻辑完全没有变化
所以,RBAC 权限控制表:user
, role
, user_role
,permission
,role_permission
表中的信息完全一样(除了密码采用了 MD5
加盐的方式加密以外)
即:共存在两个用户:admin
和 user
,他们所对应的权限列表分别为:
user
用户: common
权限,可以访问的资源列表:/user/common
admin
用户: admin
权限,可以访问的资源列表: /user/admin
,/user/common
(一)user 用户
1 调用登录接口,获取 token
(user
用户)
使用 user
账号进行登录,该账号只有 common
权限
登录之后,成功返回当前用户的信息,包括用户名,密码,token
以及权限列表 authorities
2 不携带 token
,直接访问资源 /user/common
接口返回 403
-禁止访问
3 携带 token
,访问资源 /user/common
访问成功
4 携带 token
,访问资源 /user/admin
当然,由于 user
用户只有 common
权限,所以如果访问 /user/admin
资源,也会返回 403
若换成 admin
用户登录呢?
(二)admin 用户
1 调用登录接口,获取 token
(admin
用户)
2 访问 /user/admin
资源
成功访问了
也就是说,我们的目的就达到了:成功将 Spring Security
的认证方案替换成了 JWT
,实现了Sprinfg Security
与 JWT
的结合
四 总结
今天我们讲了:
1 Spring Security
的几个重要类或接口:SecurityContext
、SecurityContextHolder``Authentication
以及 AuthenticationManager
2 Spring Security
的过滤器链说明
3 实战实现 Spring Security
与 JWT
认证方案的集成,共同实现了分布式系统的认证功能
关于用户的权限列表 authorities
如何获取这个问题,网上的很多实战例子是在每次过滤器解析时,根据用户名去数据库查询一次,得到权限列表。这样的效果其实跟 Session
差不多了,因为同样每次请求都要请求一次持久层呢~
我们这里实战 demo
的思路是将用户以及用户的权限列表 authorities
作为生成 token
的其中一个数据项,从而在过滤器中解析完 token
即可获得,从而存储至认证对象:Authentication
中,而不用专门从数据库中获取一次,这种设计还是相对比较巧妙的
如果你还有更好的方案,欢迎留言交流哦~
文章演示代码地址:github.com/helemile/Sp…
嗯,就这样。每天学习一点,时间会见证你的强大~
欢迎大家关注我们的公众号【青梅主码】,一起持续性学习吧~
往期精彩回顾
总结复盘
网络篇
事务篇章
Docker篇(六):Docker Compose如何管理多个容器?
SpringCloud篇章
Spring Cloud(十):消息中心篇-Kafka经典面试题,你都会吗?
Spring Cloud(九):注册中心选型篇-四种注册中心特点超全总结
Spring Cloud(四):公司内部,关于Eureka和zookeeper的一场辩论赛
Spring Boot篇章
Spring Boot(十二):陌生又熟悉的 OAuth2.0 协议,实际上每个人都在用
Spring Boot(七):你不能不知道的Mybatis缓存机制!
翻译
WebTransport 会在不久的将来取代 WebRTC 吗?
.........
职业、生活感悟
..........
转载自:https://juejin.cn/post/7130041010016485407