纸上得来终觉浅,觉知此事要躬行。java
本文适合: 对Spring Security有一点了解或者跑过简单demo可是对总体运行流程不明白的同窗,对SpringSecurity有兴趣的也能够看成大家的入门教程,示例代码中也有不少注释。
本文代码: 码云地址 GitHub地址
你们在作系统的时候,通常作的第一个模块就是认证与受权模块,由于这是一个系统的入口,也是一个系统最重要最基础的一环,在认证与受权服务设计搭建好了以后,剩下的模块才得以安全访问。
市面上通常作认证受权的框架就是shiro
和Spring Security
,也有大部分公司选择本身研制。出于以前看过不少Spring Security
的入门教程,但都以为讲的不是太好,因此我这两天在本身鼓捣Spring Security
的时候萌生了分享一下的想法,但愿能够帮助到有兴趣的人。mysql
Spring Security
框架咱们主要用它就是解决一个认证受权功能,因此个人文章主要会分为两部分:git
我会为你们用一个Spring Security + JWT + 缓存的一个demo来展示我要讲的东西,毕竟脑子的东西要体如今具体事物上才能够更直观的让你们去了解去认识。
学习一件新事物的时候,我推荐使用自顶向下的学习方法,这样能够更好的认识新事物,而不是盲人摸象。github
注:只涉及到用户认证受权不涉及oauth2之类的第三方受权。web
想上手 Spring Security 必定要先了解它的工做流程,由于它不像工具包同样,拿来即用,必需要对它有必定的了解,再根据它的用法进行自定义操做。spring
咱们能够先来看看它的工做流程:
在Spring Security的
官方文档上有这么一句话:sql
Spring Security’s web infrastructure is based entirely on standard servlet filters.数据库
Spring Security 的web基础是Filters。json
这句话展现了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的大体工做流程以后,咱们还须要知道一些很是重要的概念也能够说是组件:
Authentication
对象会放在里面。Authentication
,返回一个认证完成后的Authentication
对象。上下文对象,认证后的数据就放在这里面,接口定义以下:
public interface SecurityContext extends Serializable { // 获取Authentication对象 Authentication getAuthentication(); // 放入Authentication对象 void setAuthentication(Authentication authentication); } 复制代码
这个接口里面只有两个方法,其主要做用就是get or set Authentication
。
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
,默认会把数据都存储到当前线程中。
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
: 获取用户身份信息,在未认证的状况下获取到的是用户名,在已认证的状况下获取到的是 UserDetails。isAuthenticated
: 获取当前 Authentication
是否已认证。setAuthenticated
: 设置当前 Authentication
是否已认证(true or false)。Authentication
只是定义了一种在SpringSecurity进行认证过的数据的数据形式应该是怎么样的,要有权限,要有密码,要有身份信息,要有额外信息。
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。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.0</version> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.3.0</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.47</version> </dependency> 复制代码
接着,咱们须要定义几个必须的组件。
因为我用的Spring-Boot是2.X因此必需要咱们本身定义一个加密器:
@Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } 复制代码
这个Bean是没必要可少的,Spring Security
在认证操做时会使用咱们定义的这个加密器,若是没有则会出现异常。
@Bean public AuthenticationManager authenticationManager() throws Exception { return super.authenticationManager(); } 复制代码
这里将Spring Security
自带的authenticationManager
声明成Bean,声明它的做用是用它帮咱们进行认证操做,调用这个Bean的authenticate
方法会由Spring Security
自动帮咱们作认证。
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<UserInfo> 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<RoleInfo> roleInfoList = roleInfoService.listRoleByUserId(userInfo.getUserId()); userDetail.setRoleInfoList(roleInfoList); return userDetail; } } 复制代码
实现UserDetailsService
的抽象方法并返回一个UserDetails对象,认证过程当中SpringSecurity会调用这个方法访问数据库进行对用户的搜索,逻辑什么均可以自定义,不管是从数据库中仍是从缓存中,可是咱们须要将咱们查询出来的用户信息和权限信息组装成一个UserDetails返回。
UserDetails 也是一个定义了数据形式的接口,用于保存咱们从数据库中查出来的数据,其功能主要是验证帐号状态和获取权限,具体实现能够查阅我仓库的代码。
因为咱们是JWT的认证模式,因此咱们也须要一个帮咱们操做Token的工具类,通常来讲它具备如下三个方法就够了:
在下文个人代码里面,JwtProvider充当了Token工具类的角色,具体实现能够查阅我仓库的代码。
有了前面的讲解以后,你们应该都知道用SpringSecurity
作JWT认证须要咱们本身写一个过滤器来作JWT的校验,而后将这个过滤器放到绿色部分。
在咱们编写这个过滤器以前,咱们还须要进行一个认证操做,由于咱们要先访问认证接口拿到token,才能把token放到请求头上,进行接下来请求。
若是你不太明白,没关系,先接着往下看我会在这节结束再次梳理一下。
访问一个系统,通常最早访问的是认证方法,这里我写了最简略的认证须要的几个步骤,由于实际系统中咱们还要写登陆记录啊,前台密码解密啊这些操做。
@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
对象。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; } } Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract"); } try { // 校验帐号是否禁用 preAuthenticationChecks.check(user); // 校验数据库查出来的密码,和咱们传入的密码是否一致 additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication); } } 复制代码
看了源码以后你会发现和咱们日常写的同样,其主要逻辑也是查数据库而后对比密码。
登陆以后效果以下:
咱们返回token以后,下次请求其余API的时候就要在请求头中带上这个token,都按照JWT的标准来作就能够。
有了token以后,咱们要把过滤器放在过滤器链中,用于解析token,由于咱们没有session,因此咱们每次去辨别这是哪一个用户的请求的时候,都是根据请求中的token来解析出来当前是哪一个用户。
因此咱们须要一个过滤器去拦截全部请求,前文咱们也说过,这个过滤器咱们会放在绿色部分用来替代UsernamePasswordAuthenticationFilter
,因此咱们新建一个JwtAuthenticationTokenFilter
,而后将它注册为Bean,并在编写配置文件的时候须要加上这个:
@Bean public JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter() { return new JwtAuthenticationTokenFilter(); } @Override protected void configure(HttpSecurity http) throws Exception { http.addFilterBefore(jwtAuthenticationTokenFilter(), UsernamePasswordAuthenticationFilter.class); } 复制代码
addFilterBefore
的语义是添加一个Filter到XXXFilter以前,放在这里就是把JwtAuthenticationTokenFilter
放在UsernamePasswordAuthenticationFilter
以前,由于filter的执行也是有顺序的,咱们必需要把咱们的filter放在过滤器链中绿色的部分才会起到自动认证的效果。
接下来咱们能够看看JwtAuthenticationTokenFilter
的具体实现了:
@Override protected void doFilterInternal(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull FilterChain chain) throws ServletException, IOException { log.info("JWT过滤器经过校验请求头token进行自动登陆..."); // 拿到Authorization请求头内的信息 String authToken = jwtProvider.getToken(request); // 判断一下内容是否为空且是否为(Bearer )开头 if (StrUtil.isNotEmpty(authToken) && authToken.startsWith(jwtProperties.getTokenPrefix())) { // 去掉token前缀(Bearer ),拿到真实token authToken = authToken.substring(jwtProperties.getTokenPrefix().length()); // 拿到token里面的登陆帐号 String loginAccount = jwtProvider.getSubjectFromToken(authToken); if (StrUtil.isNotEmpty(loginAccount) && SecurityContextHolder.getContext().getAuthentication() == null) { // 缓存里查询用户,不存在须要从新登录。 UserDetail userDetails = caffeineCache.get(CacheName.USER, loginAccount, UserDetail.class); // 拿到用户信息后验证用户信息与token if (userDetails != null && jwtProvider.validateToken(authToken, userDetails)) { // 组装authentication对象,构造参数是Principal Credentials 与 Authorities // 后面的拦截器里面会用到 grantedAuthorities 方法 UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, userDetails.getPassword(), userDetails.getAuthorities()); // 将authentication信息放入到上下文对象中 SecurityContextHolder.getContext().setAuthentication(authentication); log.info("JWT过滤器经过校验请求头token自动登陆成功, user : {}", userDetails.getUsername()); } } } chain.doFilter(request, response); } 复制代码
代码里步骤虽说的很详细了,可是可能由于代码过长不利于阅读,我仍是简单说说,也能够直接去仓库查看源码:
Authorization
请求头对应的token信息UserDetail
信息便可UserDetail
用户名与token中的是否一直。authentication
对象,把它放在上下文对象中,这样后面的过滤器看到咱们上下文对象中有authentication
对象,就至关于咱们已经认证过了。这样的话,每个带有正确token的请求进来以后,都会找到它的帐号信息,并放在上下文对象中,咱们可使用SecurityContextHolder
很方便的拿到上下文对象中的Authentication
对象。
完成以后,启动咱们的demo,能够看到过滤器链中有如下过滤器,其中咱们自定义的是第5个:
🐱🏍就酱,咱们登陆完了以后获取到的帐号信息与角色信息咱们都会放到缓存中,当带着token的请求来到时,咱们就把它从缓存中拿出来,再次放到上下文对象中去。
结合认证方法,咱们的逻辑链就变成了:
登陆👉拿到token👉请求带上token👉JWT过滤器拦截👉校验token👉将从缓存中查出来的对象放到上下文中
这样以后,咱们认证的逻辑就算完成了。
认证和JWT过滤器完成后,这个JWT的项目其实就能够跑起来了,能够实现咱们想要的效果,若是想让程序更健壮,咱们还须要再加一些辅助功能,让代码更友好。
当用户未登陆或者token解析失败时会触发这个处理器,返回一个非法访问的结果。
当用户自己权限不知足所访问API须要的权限时,触发这个处理器,返回一个权限不足的结果。
用户退出通常就是清除掉上下文对象和缓存就好了,你也能够作一下附加操做,这两步是必须的。
JWT的项目token刷新也是必不可少的,这里刷新token的主要方法放在了token工具类里面,刷新完了把缓存重载一遍就好了,由于缓存是有有效期的,从新put能够重置失效时间。
Spring Security
的上手的确有点难度,在我第一次去了解它的时候看的是尚硅谷的教程,那个视频的讲师拿它和Thymeleaf结合,这就致使网上也有不少博客去讲Spring Security
的时候也是这种方式,而没有去关注先后端分离。
也有教程作过滤器的时候是直接继承UsernamePasswordAuthenticationFilter
,这样的方法也是可行的,不过咱们了解了总体的运行流程以后你就知道不必这样作,不须要去继承XXX,只要写个过滤器而后放在那个位置就能够了。