本文章转载于公众号:王清江唷,仅用于学习和讨论,如有侵权请联系
QQ交流群:298405437
本人QQ:4206359 具体视频地址:8 跑后端_哔哩哔哩_bilibili
阮老师讲得很好了,网址如下:
http://www.ruanyifeng.com/blog/2018/07/json_web_token-tutorial.html
问题一:不登录的时候有token吗?
答:没有,所以只能在login页面,凡是想跳转其他界面,都被重定向到登录,硬生生让你登录。前端阻拦的代码如下:
问题二:token什么时候生成的?
答:登录的时候生成的,具体代码讲述:
在login控制器的service层代码中,有login方法:
public String login(String username, String password, String code, String uuid){boolean captchaOnOff = configService.selectCaptchaOnOff();// 验证码开关if (captchaOnOff){validateCaptcha(username, code, uuid);}// 用户验证Authentication authentication = null;try{// 该方法会去调用UserDetailsServiceImpl.loadUserByUsernameauthentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));}catch (Exception e){if (e instanceof BadCredentialsException){AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));throw new UserPasswordNotMatchException();}else{AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, e.getMessage()));throw new ServiceException(e.getMessage());}}AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success")));LoginUser loginUser = (LoginUser) authentication.getPrincipal();recordLoginInfo(loginUser.getUserId());// 生成tokenreturn tokenService.createToken(loginUser);
}
最后一句话,他说生成token,于是他执行:
public String createToken(LoginUser loginUser)
{
String token = IdUtils.fastUUID();
loginUser.setToken(token);
setUserAgent(loginUser);
refreshToken(loginUser);
Map
claims = new HashMap<>(); claims.put(Constants.LOGIN_USER_KEY, token);
return createToken(claims);
}
代码片段:可切换语言,无法单独设置文字格式
然后它又是最后一个话在生成token,好家伙,玩我是吧?点进去我们才看到真的生成方法:
private String createToken(Map
claims) {
String token = Jwts.builder()
.setClaims(claims)
.signWith(SignatureAlgorithm.HS512, secret).compact();
return token;
}
代码片段:可切换语言,无法单独设置文字格式
现在token生成好了,大家要注意,refreshToken方法已经把当前成功登录的人的信息存到了redis中,前缀是login_tokens: + 当前的tokenId,tokenId是一个uuid。
所以再看login方法,不仅仅生成了token,还把登录人的信息存到了Redis中。方便下次用户带着token来的时候,后端可以拿到tokenId来redis中找用户的tokenId是否存在。
问题三,用户登录后,发请求是怎么自动带上token的?
登录成功的时候,前端存了一份token在cookie中,登录代码如下:
// 登录
Login({ commit }, userInfo) {
const username = userInfo.username.trim()
const password = userInfo.password
const code = userInfo.code
const uuid = userInfo.uuid
return new Promise((resolve, reject) => {
login(username, password, code, uuid).then(res => {
setToken(res.token)
commit('SET_TOKEN', res.token)
resolve()
}).catch(error => {
reject(error)
})
})
},
代码片段:可切换语言,无法单独设置文字格式
引入眼帘有一个setToken,里面就将token放cookie,代码如下:
export function setToken(token) {
return Cookies.set(TokenKey, token)
}
代码片段:可切换语言,无法单独设置文字格式
接下来如果要发请求,request.js会自动带上,如下代码:
可以看到,它会去config.headers看看到底要不要,如果我们在请求头指定了不要,那么发请求就不会带上token,否则就会被带上token。
问题四:我再次请求的时候带上了token,后端在哪问我带没带token呢?
其实后端有一个拦截器,总是在悄悄检查。
如下代码:
package com.ruoyi.framework.security.filter;import java.io.IOException;import javax.servlet.FilterChain;import javax.servlet.ServletException;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;import org.springframework.security.core.context.SecurityContextHolder;import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;import org.springframework.stereotype.Component;import org.springframework.web.filter.OncePerRequestFilter;import com.ruoyi.common.core.domain.model.LoginUser;import com.ruoyi.common.utils.SecurityUtils;import com.ruoyi.common.utils.StringUtils;
/*** token过滤器 验证token有效性** @author ruoyi*/@Componentpublic class JwtAuthenticationTokenFilter extends OncePerRequestFilter{@Autowiredprivate TokenService tokenService;@Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)throws ServletException, IOException{LoginUser loginUser = tokenService.getLoginUser(request);if (StringUtils.isNotNull(loginUser) && StringUtils.isNull(SecurityUtils.getAuthentication())){tokenService.verifyToken(loginUser);UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));SecurityContextHolder.getContext().setAuthentication(authenticationToken);}chain.doFilter(request, response);} }
import com.ruoyi.framework.web.service.TokenService;
代码片段:可切换语言,无法单独设置文字格式
getLoginUser方法总是在试图拿到token。然后验证token是否正确,token有没有过期,然后把用户该有的权限重新设置在上下文中。
该拦截器是在哪里设置的呢?
@Overrideprotected void configure(HttpSecurity httpSecurity) throws Exception{// 注解标记允许匿名访问的urlExpressionUrlAuthorizationConfigurer.ExpressionInterceptUrlRegistry registry = httpSecurity.authorizeRequests(); permitAllUrl.getUrls().forEach(url -> registry.antMatchers(url).permitAll());httpSecurity// CSRF禁用,因为不使用session.csrf().disable()// 认证失败处理类.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()// 基于token,所以不需要session.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()// 过滤请求.authorizeRequests()// 对于登录login 注册register 验证码captchaImage 允许匿名访问.antMatchers("/login", "/register", "/captchaImage").anonymous()// 静态资源,可匿名访问.antMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html", "/**/*.css", "/**/*.js", "/profile/**").permitAll().antMatchers("/swagger-ui.html", "/swagger-resources/**", "/webjars/**", "/*/api-docs", "/druid/**").permitAll()// 除上面外的所有请求全部需要鉴权认证.anyRequest().authenticated().and().headers().frameOptions().disable();// 添加Logout filterhttpSecurity.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);// 添加JWT filterhttpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);// 添加CORS filterhttpSecurity.addFilterBefore(corsFilter, JwtAuthenticationTokenFilter.class);httpSecurity.addFilterBefore(corsFilter, LogoutFilter.class);
}
代码片段:可切换语言,无法单独设置文字格式
认证配置类中最重要的HttpSecurity配置如下:
@Overrideprotected void configure(HttpSecurity httpSecurity) throws Exception{// 注解标记允许匿名访问的urlExpressionUrlAuthorizationConfigurer.ExpressionInterceptUrlRegistry registry = httpSecurity.authorizeRequests(); permitAllUrl.getUrls().forEach(url -> registry.antMatchers(url).permitAll());httpSecurity// CSRF禁用,因为不使用session.csrf().disable()// 认证失败处理类.exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()// 基于token,所以不需要session.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()// 过滤请求.authorizeRequests()// 对于登录login 注册register 验证码captchaImage 允许匿名访问.antMatchers("/login", "/register", "/captchaImage").anonymous()// 静态资源,可匿名访问.antMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html", "/**/*.css", "/**/*.js", "/profile/**").permitAll().antMatchers("/swagger-ui.html", "/swagger-resources/**", "/webjars/**", "/*/api-docs", "/druid/**").permitAll()// 除上面外的所有请求全部需要鉴权认证.anyRequest().authenticated().and().headers().frameOptions().disable();// 添加Logout filterhttpSecurity.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);// 添加JWT filterhttpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);// 添加CORS filterhttpSecurity.addFilterBefore(corsFilter, JwtAuthenticationTokenFilter.class);httpSecurity.addFilterBefore(corsFilter, LogoutFilter.class);
}
在SecurityConfig类中明确指定了认证失败后handle:
.exceptionHandling().authenticationEntryPoint(unauthorizedHandler)
也就是如下的方式来处理:
package com.ruoyi.framework.security.handle;import java.io.IOException;import java.io.Serializable;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;import org.springframework.security.core.AuthenticationException;import org.springframework.security.web.AuthenticationEntryPoint;import org.springframework.stereotype.Component;import com.alibaba.fastjson2.JSON;import com.ruoyi.common.constant.HttpStatus;import com.ruoyi.common.core.domain.AjaxResult;import com.ruoyi.common.utils.ServletUtils;
/*** 认证失败处理类 返回未授权** @author ruoyi*/@Componentpublic class AuthenticationEntryPointImpl implements AuthenticationEntryPoint, Serializable{private static final long serialVersionUID = -8970718410437077606L;@Overridepublic void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e)throws IOException{int code = HttpStatus.UNAUTHORIZED;String msg = StringUtils.format("请求访问:{},认证失败,无法访问系统资源", request.getRequestURI());ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(code, msg)));} }import com.ruoyi.common.utils.StringUtils;
可以看到如果认证失败就简单返回一句话【请求访问:{},认证失败,无法访问系统资源】。
通过postman或apifox等发请求的软件,随便给后端发一个请求,很容易就能得到一个这样的返回:
现在有一个这样的问题,遇到认证失败,但是自己想用postman、apifox、apipost等软件就想要调通API,怎么办?
现在介绍一种方法,因为超级管理员是无视一切权限的,只要用上超级管理员的token,一切权限都能轻轻松松越过去。下面演示apipost如何使用超级管理员的token。
首先拿到token【视频说】,然后放到如下位置:
然后就能调通了,如下图:
大家可能会问,为啥404,因为abc这个接口本身就没有写,所以404,但凡调用一个写好的接口,就能调通,如下:
然而,如果用上了超级管理员token,一切查询方面的结果都是和超级管理员有关的,有时候可能并不是自己想要的,这就需要给授权,后面再说。
swagger也不通,如下:
也是因为没有权限,如下方式加上token就能调通:
我们知道,SpringSecurity的机制无非就是过滤器链,一个一个执行过滤器,当前ry的过滤器链如下:
这里我先看cors(处理跨域)的过滤器有两个,但是处理跨域的过滤器只会执行一次(因为继承了OncePerRequestFilter),所以没有加入两个解决跨域过滤器的必要。所以我删除了一个,我怎么删除的呢?我直接在ry添加两个过滤器的地方注释了一个:
从上面的红色框可以看出,我就是删除了在JwtAuthenticationTokenFilter过滤器前面的一个cors过滤器。
addFilterBefore(参数一,参数二)方法的功能是添加一个过滤器,并且添加到参数二过滤器之前。
当我删除了一个跨域过滤器之后,新的过滤器链如下:
// httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
httpSecurity.addFilterAfter(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
刚刚说到addFilterBefore是在添加到指定过滤器之前,而addFilterAfter相反,添加到指定过滤器之后。
通过addFilterAfter和addFilterBefore,我们可以控制additionalFilters过滤器链每一个过滤器的顺序。
看下面的图:
眼尖的同学已经发现,其实上面几行,我已经用过了addFilterAfter方法,然而,这里虽然把addFilterBefore改成了addFilterAfter,但是其实结果还是一样的。
因为没有启用表单认证所以UsernamePasswordAuthenticationFilter被移除了。UsernamePasswordAuthenticationFilter自然也不起作用了,相当于我们刚刚的addFilterAfter其实就是取代了UsernamePasswordAuthenticationFilter的位置,由authenticationTokenFilter(JwtAuthenticationTokenFilter)实例来完成认证。
Tips:SpringSecurity成功认证的标志是上下文存储着经过认证的用户信息,像下面这样:
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());SecurityContextHolder.getContext().setAuthentication(authenticationToken);
当前过滤器链:
共12个过滤器。
分别是:
1、WebAsyncManagerIntegrationFilter,用于将SecurityContext传递到异步线程中(异步线程就可以获取安全上下文)。
2、SecurityContextPersistenceFilter,它主要是使用SecurityContextRepository在session中保存或更新一个SecurityContext,并将SecurityContext给以后的过滤器使用,来为后续filter建立所需的上下文。
3、HeaderWriterFilter,它用于向请求的Header中添加相应的信息。
文件ResourcesConfig.java
/*** 跨域配置*/@Beanpublic CorsFilter corsFilter(){CorsConfiguration config = new CorsConfiguration();config.setAllowCredentials(true);// 设置访问源地址config.addAllowedOriginPattern("*");// 设置访问源请求头config.addAllowedHeader("*");// 设置访问源请求方法config.addAllowedMethod("*");// 有效期 1800秒config.setMaxAge(1800L);// 添加映射路径,拦截一切请求UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();source.registerCorsConfiguration("/**", config);// 返回新的CorsFilterreturn new CorsFilter(source);
上面的配置可以让前端请求基本上是随便跨域了。ry把此过滤器设置了两次,一次在LogoutFilter.class之前,一次在JwtAuthenticationTokenFilter.class之前,但是被我删除了JwtAuthenticationTokenFilter.class前面的,但是执行的效果还是一样的。
5、LogoutFilter,用于处理注销主体。
LogoutFilter会去执行两个handler,分别是SecurityContextLogoutHandler和LogoutSuccessEventPublishingLogoutHandler。SecurityContextLogoutHandler用于清除上下文认证信息。
做的一些关键代码如下:
if (this.clearAuthentication) {
SecurityContext context = SecurityContextHolder.getContext();
context.setAuthentication(null);
}
SecurityContextHolder.clearContext();
LogoutSuccessEventPublishingLogoutHandler啥都没干,如果authentication已经是Null的情况下,正好上一个handler已经将authentication清理了,所以可以理解LogoutSuccessEventPublishingLogoutHandler啥都没做。如果前一个handler执行出现问题,本handler也可以理解为一种补偿机制,发布一个事件说authentication已经登出了,相关代码如下:
@Overridepublic void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {if (this.eventPublisher == null) {return;}if (authentication == null) {return;}this.eventPublisher.publishEvent(new LogoutSuccessEvent(authentication));
}
ry对成功注销做了自定
实现:
/*** 自定义退出处理类 返回成功** @author ruoyi*/@Configurationpublic class LogoutSuccessHandlerImpl implements LogoutSuccessHandler{@Autowiredprivate TokenService tokenService;/*** 退出处理** @return*/@Overridepublic void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)throws IOException, ServletException{LoginUser loginUser = tokenService.getLoginUser(request);if (StringUtils.isNotNull(loginUser)){String userName = loginUser.getUsername();// 删除用户缓存记录tokenService.delLoginUser(loginUser.getToken());// 记录用户退出日志AsyncManager.me().execute(AsyncFactory.recordLogininfor(userName, Constants.LOGOUT, "退出成功"));}ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(HttpStatus.SUCCESS, "退出成功")));}
}
无非就是在退出的时候做一下收尾工作,清缓存,写登出日志,最后再返回一个退出成功的信息。
当用户登陆成功之后,发请求会带上token,JwtAuthenticationTokenFilter是专门用于解析token并把认证成功的用户信息设置到上下文中,用于后续的鉴权(认证成功的LoginUser里面有字段里面带着用户的权限的)。相关代码如下:
/**
* token过滤器 验证token有效性
*
* @author ruoyi
*/
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter
{
@Autowired
private TokenService tokenService;
@Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)throws ServletException, IOException{LoginUser loginUser = tokenService.getLoginUser(request);if (StringUtils.isNotNull(loginUser) && StringUtils.isNull(SecurityUtils.getAuthentication())){tokenService.verifyToken(loginUser);UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));SecurityContextHolder.getContext().setAuthentication(authenticationToken);}chain.doFilter(request, response);} }
7、RequestCacheAwareFilter,通过HttpSessionRequestCache内部维护了一个RequestCache,用于缓存HttpServletRequest。
8、SecurityContextHolderAwareRequestFilter,针对ServletRequest进行了一次包装,使得request具有更加丰富的API。
通过SecurityContextHolderAwareRequestWrapper把ServletRequest包装起来,多了一些方法如getUserPrincipal,getAuthentication方便后续的确认是否目前已经认证。
因为在过滤器链条上面request和response一直在链条上传输,这也是包装request的原因。
9、AnonymousAuthenticationFilter,当SecurityContextHolder中认证信息为空,则会创建一个匿名用户存入到SecurityContextHolder中。SpringSecurity为了兼容未登录的访问,也走了一套认证流程,只不过是一个匿名的身份。
10、SessionManagementFilter,SecurityContextRepository限制同一用户开启多个会话的数量,但是前后端分离往往会禁用session,改用Redis存储用户会话状态。
11、ExceptionTranslationFilter,用于处理异常,它的过滤器方法直接放行,但是catch了许多异常例如认证过程的异常,如下:
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)throws IOException, ServletException {try {chain.doFilter(request, response);}catch (IOException ex) {throw ex;}catch (Exception ex) {// Try to extract a SpringSecurityException from the stacktraceThrowable[] causeChain = this.throwableAnalyzer.determineCauseChain(ex);RuntimeException securityException = (AuthenticationException) this.throwableAnalyzer.getFirstThrowableOfType(AuthenticationException.class, causeChain);if (securityException == null) {securityException = (AccessDeniedException) this.throwableAnalyzer.getFirstThrowableOfType(AccessDeniedException.class, causeChain);}if (securityException == null) {rethrow(ex);}if (response.isCommitted()) {throw new ServletException("Unable to handle the Spring Security Exception "+ "because the response is already committed.", ex);}handleSpringSecurityException(request, response, chain, securityException);}
}
12、FilterSecurityInterceptor,获取所配置资源访问的授权信息,根据SecurityContextHolder中存储的用户信息来决定其是否有权限,然而ry自创了一套鉴权方式,所以原生FilterSecurityInterceptor这里不做展开。
最后:
这些过滤器由一个代理管理,从上依次往下执行。下面我来来看一下代理(FilterChainProxy.VirtualFilterChain)的关键性代码:
@Overridepublic void doFilter(ServletRequest request, ServletResponse response) throws IOException, ServletException {if (this.currentPosition == this.size) {...省略非关键代码// Deactivate path stripping as we exit the security filter chainthis.firewalledRequest.reset();this.originalChain.doFilter(request, response);return;}this.currentPosition++;Filter nextFilter = this.additionalFilters.get(this.currentPosition - 1);...省略非关键代码nextFilter.doFilter(request, response, this);
}
从上面的代码可以看得出,有一个名为additionalFilters的ArrayList存储了12个过滤器,一个一个依次执行,执行完毕之后继续执行originalChain里面的过滤器。