SpringSecurity+JWT 认证流程解析
本文适合: 对 Spring Security 有一点了解或者跑过简单 demo 但是对整体运行流程不明白的同学,对 SpringSecurity 有兴趣的也可以当作你们的入门教程,示例代码中也有很多注释。
大家在做系统的时候,一般做的第一个模块就是认证与授权模块,因为这是一个系统的入口,也是一个系统最重要最基础的一环,在认证与授权服务设计搭建好了之后,剩下的模块才得以安全访问。
市面上一般做认证授权的框架就是shiro
和Spring Security
,也有大部分公司选择自己研制。出于之前看过很多Spring Security
的入门教程,但都觉得讲的不是太好,所以我这两天在自己鼓捣Spring Security
的时候萌生了分享一下的想法,希望可以帮助到有兴趣的人。
Spring Security
框架我们主要用它就是解决一个认证授权功能,所以我的文章主要会分为两部分:
第一部分认证(本篇)
第二部分授权(放在下一篇)
我会为大家用一个 Spring Security + JWT + 缓存的一个 demo 来展现我要讲的东西,毕竟脑子的东西要体现在具体事物上才可以更直观的让大家去了解去认识。
学习一件新事物的时候,我推荐使用自顶向下的学习方法,这样可以更好的认识新事物,而不是盲人摸象。
注:只涉及到用户认证授权不涉及 oauth2 之类的第三方授权。
想上手 Spring Security 一定要先了解它的工作流程,因为它不像工具包一样,拿来即用,必须要对它有一定的了解,再根据它的用法进行自定义操作。
我们可以先来看看它的工作流程:
在Spring Security的
官方文档上有这么一句话:
Spring Security’s web infrastructure is based entirely on standard servlet filters.
Spring Security 的 web 基础是 Filters。
这句话展示了Spring Security
的设计思想:即通过一层层的 Filters 来对 web 请求做处理。
放到真实的Spring Security
中,用文字表述的话可以这样说:
一个 web 请求会经过一条过滤器链,在经过过滤器链的过程中会完成认证与授权,如果中间发现这条请求未认证或者未授权,会根据被保护 API 的权限去抛出异常,然后由异常处理器去处理这些异常。
用图片表述的话可以这样画,这是我在百度找到的一张图片:
如上图,一个请求想要访问到 API 就会以从左到右的形式经过蓝线框框里面的过滤器,其中绿色部分是我们本篇主要讲的负责认证的过滤器,蓝色部分负责异常处理,橙色部分则是负责授权。
图中的这两个绿色过滤器我们今天不会去说,因为这是 Spring Security 对 form 表单认证和 Basic 认证内置的两个 Filter,而我们的 demo 是 JWT 认证方式所以用不上。
如果你用过Spring Security
就应该知道配置中有两个叫formLogin
和httpBasic
的配置项,在配置中打开了它俩就对应着打开了上面的过滤器。
formLogin
对应着你 form 表单认证方式,即 UsernamePasswordAuthenticationFilter。httpBasic
对应着 Basic 认证方式,即 BasicAuthenticationFilter。
换言之,你配置了这两种认证方式,过滤器链中才会加入它们,否则它们是不会被加到过滤器链中去的。
因为Spring Security
自带的过滤器中是没有针对 JWT 这种认证方式的,所以我们的 demo 中会写一个 JWT 的认证过滤器,然后放在绿色的位置进行认证工作。
知道了 Spring Security 的大致工作流程之后,我们还需要知道一些非常重要的概念也可以说是组件:
SecurityContext:上下文对象,
Authentication
对象会放在里面。SecurityContextHolder:用于拿到上下文对象的静态工具类。
Authentication:认证接口,定义了认证对象的数据形式。
AuthenticationManager:用于校验
Authentication
,返回一个认证完成后的Authentication
对象。
1.SecurityContext
上下文对象,认证后的数据就放在这里面,接口定义如下:
`public interface SecurityContext extends Serializable {
// 获取 Authentication 对象
Authentication getAuthentication();
// 放入 Authentication 对象
void setAuthentication(Authentication authentication);
} 复制代码`
这个接口里面只有两个方法,其主要作用就是 get or set Authentication
。
2. SecurityContextHolder
`public class SecurityContextHolder {
public static void clearContext() {
strategy.clearContext();
}
public static SecurityContext getContext() {
return strategy.getContext();
}
public static void setContext(SecurityContext context) {
strategy.setContext(context);
}
} 复制代码`
可以说是SecurityContext
的工具类,用于 get or set or clear SecurityContext
,默认会把数据都存储到当前线程中。
3. Authentication
`public interface Authentication extends Principal, Serializable {
Collection<? extends GrantedAuthority> getAuthor
ities();
Object getCredentials();
Object getDetails();
Object getPrincipal();
boolean isAuthenticated();
void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
} 复制代码`
这几个方法效果如下:
getAuthorities
: 获取用户权限,一般情况下获取到的是用户的角色信息。getCredentials
: 获取证明用户认证的信息,通常情况下获取到的是密码等信息。getDetails
: 获取用户的额外信息,(这部分信息可以是我们的用户表中的信息)。getPrincipal
: 获取用户身份信息,在未认证的情况下获取到的是用户名,在已认证的情况下获取到的是 UserDetails。isAuthenticated
: 获取当前Authentication
是否已认证。setAuthenticated
: 设置当前Authentication
是否已认证(true or false)。
Authentication
只是定义了一种在 SpringSecurity 进行认证过的数据的数据形式应该是怎么样的,要有权限,要有密码,要有身份信息,要有额外信息。
4. AuthenticationManager
`public interface AuthenticationManager {
// 认证方法
Authentication authenticate(Authentication authentication)
throws AuthenticationException;
} 复制代码`
AuthenticationManager
定义了一个认证方法,它将一个未认证的Authentication
传入,返回一个已认证的Authentication
,默认使用的实现类为:ProviderManager。
接下来大家可以构思一下如何将这四个部分,串联起来,构成 Spring Security 进行认证的流程:
1. ??先是一个请求带着身份信息进来
2. ??经过AuthenticationManager
的认证,
3. ??再通过SecurityContextHolder
获取SecurityContext
,
4. ??最后将认证后的信息放入到SecurityContext
。
真正开始讲诉我们的认证代码之前,我们首先需要导入必要的依赖,数据库相关的依赖可以自行选择什么 JDBC 框架,我这里用的是国人二次开发的myabtis-plus。
`
org.springframework.boot
spring-boot-starter-security
org.springframework.boot
spring-boot-starter-web
org.springframework.boot
spring-boot-starter-validation
io.jsonwebtoken
jjwt
0.9.0
com.baomidou
mybatis-plus-boot-starter
3.3.0
mysql
mysql-connector-java
5.1.47
复制代码`
接着,我们需要定义几个必须的组件。
由于我用的 Spring-Boot 是 2.X 所以必须要我们自己定义一个加密器:
1. 定义加密器 Bean
`@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
} 复制代码`
这个 Bean 是不必可少的,Spring Security
在认证操作时会使用我们定义的这个加密器,如果没有则会出现异常。
2. 定义 AuthenticationManager
`@Bean
public AuthenticationManager authenticationManager() throws Exception {
return super.authenticationManager();
} 复制代码`
这里将Spring Security
自带的authenticationManager
声明成 Bean,声明它的作用是用它帮我们进行认证操作,调用这个 Bean 的authenticate
方法会由Spring Security
自动帮我们做认证。
3. 实现 UserDetailsService
`public class CustomUserDetailsService implements UserDetailsService {
@Autowired
private UserService userService;
@Autowired
private RoleInfoService roleInfoService;
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
log.debug("开始登陆验证,用户名为: {}",s);
// 根据用户名验证用户
QueryWrapper queryWrapper = new QueryWrapper<>();
queryWrapper.lambda().eq(UserInfo::getLoginAccount,s);
UserInfo userInfo = userService.getOne(queryWrapper);
if (userInfo == null) {
throw new UsernameNotFoundException("用户名不存在,登陆失败。");
}
// 构建 UserDetail 对象
UserDetail userDetail = new UserDetail();
userDetail.setUserInfo(userInfo);
List roleInfoList = roleInfoService.listRoleByUserId(userInfo.getUserId());
userDetail.setRoleInfoList(roleInfoList);
return userDetail;
}
} 复制代码`
实现UserDetailsService
的抽象方法并返回一个 UserDetails 对象,认证过程中 SpringSecurity 会调用这个方法访问数据库进行对用户的搜索,逻辑什么都可以自定义,无论是从数据库中还是从缓存中,但是我们需要将我们查询出来的用户信息和权限信息组装成一个 UserDetails 返回。
UserDetails 也是一个定义了数据形式的接口,用于保存我们从数据库中查出来的数据,其功能主要是验证账号状态和获取权限,具体实现可以查阅我仓库的代码。
4. TokenUtil
由于我们是 JWT 的认证模式,所以我们也需要一个帮我们操作 Token 的工具类,一般来说它具有以下三个方法就够了:
创建 token
验证 token
反解析 token 中的信息
在下文我的代码里面,JwtProvider 充当了 Token 工具类的角色,具体实现可以查阅我仓库的代码。
有了前面的讲解之后,大家应该都知道用SpringSecurity
做 JWT 认证需要我们自己写一个过滤器来做 JWT 的校验,然后将这个过滤器放到绿色部分。
在我们编写这个过滤器之前,我们还需要进行一个认证操作,因为我们要先访问认证接口拿到 token,才能把 token 放到请求头上,进行接下来请求。
如果你不太明白,不要紧,先接着往下看我会在这节结束再次梳理一下。
1. 认证方法
访问一个系统,一般最先访问的是认证方法,这里我写了最简略的认证需要的几个步骤,因为实际系统中我们还要写登录记录啊,前台密码解密啊这些操作。
`@Override
public ApiResult login(String loginAccount, String password) {
// 1 创建 UsernamePasswordAuthenticationToken
UsernamePasswordAuthenticationToken usernameAuthentication = new UsernamePasswordAuthenticationToken(loginAccount, password);
// 2 认证
Authentication authentication = this.authenticationManager.authenticate(usernameAuthentication);
// 3 保存认证信息
SecurityContextHolder.getContext().setAuthentication(authentication);
// 4 生成自定义 token
UserDetail userDetail = (UserDetail) authentication.getPrincipal();
AccessToken accessToken = jwtProvider.createToken((UserDetails) authentication.getPrincipal());
// 5 放入缓存
caffeineCache.put(CacheName.USER, userDetail.getUsername(), userDetail);
return ApiResult.ok(accessToken);
} 复制代码`
这里一共五个步骤,大概只有前四步是比较陌生的:
传入用户名和密码创建了一个
UsernamePasswordAuthenticationToken
对象,这是我们前面说过的Authentication
的实现类,传入用户名和密码做构造参数,这个对象就是我们创建出来的未认证的Authentication
对象。使用我们先前已经声明过的 Bean-
authenticationManager
调用它的authenticate
方法进行认证,返回一个认证完成的Authentication
对象。认证完成没有出现异常,就会走到第三步,使用
SecurityContextHolder
获取SecurityContext
之后,将认证完成之后的Authentication
对象,放入上下文对象。从
Authentication
对象中拿到我们的UserDetails
对象,之前我们说过,认证后的Authentication
对象调用它的getPrincipal()
方法就可以拿到我们先前数据库查询后组装出来的UserDetails
对象,然后创建 token。把
UserDetails
对象放入缓存中,方便后面过滤器使用。
这样的话就算完成了,感觉上很简单,因为主要认证操作都会由authenticationManager.authenticate()
帮我们完成。
接下来我们可以看看源码,从中窥得 Spring Security 是如何帮我们做这个认证的(省略了一部分):
`// AbstractUserDetailsAuthenticationProvider
public Authentication authenticate(Authentication authentication){
// 校验未认证的 Authentication 对象里面有没有用户名
String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED"
: authentication.getName();
boolean cacheWasUsed = true;
// 从缓存中去查用户名为 XXX 的对象
UserDetails user = this.userCache.getUserFromCache(username);
// 如果没有就进入到这个方法
if (user == null) {
cacheWasUsed = false;
try {
// 调用我们重写 UserDetailsService 的 loadUserByUsername 方法
// 拿到我们自己组装好的 UserDetails 对象
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;
}
}
评论