本文共 13030 字,大约阅读时间需要 43 分钟。
在我们传统的B\S应用开发方式中,都是使用session进行状态管理的,比如说:保存登录、用户、权限等状态信息。这种方式的原理大致如下:
当然,这整个过程中,cookies和sessionid都是服务端和浏览器端自动维护的。所以从编码层面是感知不到的,程序员只能感知到session数据的存取。但是,这种方式在有些情况下,是不适用的。
当然以上的这些情况我们都有方案(如redis共享session等),可以继续使用session来保存状态。但是还有另外一种做法就是不用session了,即开发一个无状态的应用,JWT就是这样的一种方案。
缺点总结:
笔者不想用比较高大上的名词解释JWT(JSON web tokens),你可以认为JWT是一个加密后的接口访问密码,并且该密码里面包含用户名信息。这样既可以知道你是谁?又可以知道你是否可以访问应用?
这就是JWT,以及JWT在应用服务开发中的使用方法。
下图是我用在线的JWT解码工具,解码时候的截图。注意我这里用的是解码,不是解密。
从图中,我们可以看到JWT分为三个部分:
很多的朋友看到上面的这个解码文件,就会生出一个疑问?你都把JWT给解析了,而且JWT又这么的被大家广泛熟知,它还安全么?我用一个简单的道理说明一下:
如何加强JWT的安全性?
话说回来,如果你的服务器、或者你团队的内部人员出现漏洞,同样没有一种协议和算法是安全的。
我相信大家都能理解上面的认证与鉴权的整体流程,但是具体到使用Spring Security 如何实现认证,其中细节及原理还是需要单独提出来说明一下。
当客户端获取到JWT之后,他就可以使用JWT请求接口资源服务了。大家可以看到在“授权流程细节”的时序图中,有一个Filter过滤器我们没有讲到,其实它和授权认证的流程关系不大,它是用来进行接口鉴权的。因为授权认证就只有一个接口即可,但是服务资源接口却有很多,所以我们不可能在每一个Controller方法中都进行鉴权,所以在到达Controller之前通过Filter过滤器进行JWT解签和权限校验。
假如我们有一个接口资源“/hello”定义在HelloWorldcontroller中,鉴权流程是如何进行的?请结合上图进行理解:
以上的内容,我们在之前的文章中都已经讲过。如果仍然不熟悉,可以翻看本号之前的文章。我是参考第一章、第二章实现的basicserver基础上进行删减。因为JWT用于开发前后端分离的无状态应用,所以项目中去掉与session相关的内容,去掉页面视图相关的内容。环境准备完成后,核心的内容及配置如下。
通过maven坐标引入JWT工具包jjwt
io.jsonwebtoken jjwt 0.9.0
在application.yml中加入如下自定义一些关于JWT的配置
jwt: header: JWTHeaderName secret: aabbccdd expiration: 3600000
写一个Spring Boot配置自动加载的工具类。
@Data@ConfigurationProperties(prefix = "jwt")@Componentpublic class JwtTokenUtil { private String secret; private Long expiration; private String header; /** * 生成token令牌 * * @param userDetails 用户 * @return 令token牌 */ public String generateToken(UserDetails userDetails) { Mapclaims = new HashMap<>(2); claims.put("sub", userDetails.getUsername()); claims.put("created", new Date()); return generateToken(claims); } /** * 从令牌中获取用户名 * * @param token 令牌 * @return 用户名 */ public String getUsernameFromToken(String token) { String username; try { Claims claims = getClaimsFromToken(token); username = claims.getSubject(); } catch (Exception e) { username = null; } return username; } /** * 判断令牌是否过期 * * @param token 令牌 * @return 是否过期 */ public Boolean isTokenExpired(String token) { try { Claims claims = getClaimsFromToken(token); Date expiration = claims.getExpiration(); return expiration.before(new Date()); } catch (Exception e) { return false; } } /** * 刷新令牌 * * @param token 原令牌 * @return 新令牌 */ public String refreshToken(String token) { String refreshedToken; try { Claims claims = getClaimsFromToken(token); claims.put("created", new Date()); refreshedToken = generateToken(claims); } catch (Exception e) { refreshedToken = null; } return refreshedToken; } /** * 验证令牌 * * @param token 令牌 * @param userDetails 用户 * @return 是否有效 */ public Boolean validateToken(String token, UserDetails userDetails) { String username = getUsernameFromToken(token); return (username.equals(userDetails.getUsername()) && !isTokenExpired(token)); } /** * 从claims生成令牌,如果看不懂就看谁调用它 * * @param claims 数据声明 * @return 令牌 */ private String generateToken(Map claims) { Date expirationDate = new Date(System.currentTimeMillis() + expiration); return Jwts.builder().setClaims(claims) .setExpiration(expirationDate) .signWith(SignatureAlgorithm.HS512, secret) .compact(); } /** * 从令牌中获取数据声明,如果看不懂就看谁调用它 * * @param token 令牌 * @return 数据声明 */ private Claims getClaimsFromToken(String token) { Claims claims; try { claims = Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody(); } catch (Exception e) { claims = null; } return claims; }}
上面的代码就是使用io.jsonwebtoken.jjwt提供的方法开发JWT令牌生成、刷新的工具类。
@RestControllerpublic class JwtAuthController { @Resource private JwtAuthService jwtAuthService; @PostMapping(value = "/authentication") public AjaxResponse login(@RequestBody Mapmap) { String username = map.get("username"); String password = map.get("password"); if (StringUtils.isEmpty(username) || StringUtils.isEmpty(password)) { return AjaxResponse.error( new CustomException( CustomExceptionType.USER_INPUT_ERROR,"用户名密码不能为空")); } try{ return AjaxResponse.success(jwtAuthService.login(username, password)); }catch(CustomException e){ return AjaxResponse.error(e); } } @PostMapping(value = "/refreshtoken") public AjaxResponse refresh(@RequestHeader("${jwt.header}") String token) { return AjaxResponse.success(jwtAuthService.refreshToken(token)); }}
核心的token业务逻辑写在JwtAuthService 中
@Servicepublic class JwtAuthService { @Resource private AuthenticationManager authenticationManager; @Resource private UserDetailsService userDetailsService; @Resource private JwtTokenUtil jwtTokenUtil; public String login(String username, String password) throws CustomException { try{ //使用用户名密码进行登录验证 UsernamePasswordAuthenticationToken upToken = new UsernamePasswordAuthenticationToken( username, password ); Authentication authentication = authenticationManager.authenticate(upToken); SecurityContextHolder.getContext().setAuthentication(authentication); }catch(AuthenticationException e){ throw new CustomException(CustomExceptionType.USER_INPUT_ERROR, "用户名或密码不正确"); } //生成JWT UserDetails userDetails = userDetailsService.loadUserByUsername( username ); return jwtTokenUtil.generateToken(userDetails); } public String refreshToken(String oldToken) { if (!jwtTokenUtil.isTokenExpired(oldToken)) { return jwtTokenUtil.refreshToken(oldToken); } return null; }}
因为使用到了AuthenticationManager ,所以在继承WebSecurityConfigurerAdapter的SpringSecurity配置实现类中,将AuthenticationManager 声明为一个Bean。并将"/authentication"和 "/refreshtoken"开放访问权限,如何开放访问权限,我们之前的文章已经讲过了。
@Bean(name = BeanIds.AUTHENTICATION_MANAGER)@Overridepublic AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean();}
当用户第一次登陆之后,我们将JWT令牌返回给了客户端,客户端应该将该令牌保存起来。在进行接口请求的时候,将令牌带上,放到HTTP的header里面,header的名字要和jwt.header的配置一致,这样服务端才能解析到。下面我们定义一个拦截器:
@Componentpublic class JwtAuthenticationTokenFilter extends OncePerRequestFilter { @Resource MyUserDetailsService myUserDetailsService; @Resource JwtTokenUtil jwtTokenUtil; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String jwtToken = request.getHeader(jwtTokenUtil.getHeader()); if(jwtToken != null && StringUtils.isNoneEmpty(jwtToken)){ String username = jwtTokenUtil.getUsernameFromToken(jwtToken); //如果可以正确的从JWT中提取用户信息,并且该用户未被授权 if(username != null && SecurityContextHolder.getContext().getAuthentication() == null){ UserDetails userDetails = myUserDetailsService.loadUserByUsername(username); if(jwtTokenUtil.validateToken(jwtToken,userDetails)){ //给使用该JWT令牌的用户进行授权 UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDetails,null, userDetails.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authenticationToken); } } } filterChain.doFilter(request,response); }}
在spring Security的配置类(即WebSecurityConfigurerAdapter实现类的configure(HttpSecurity http)配置方法中,加入如下配置:
.sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and().addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
测试登录接口,即:获取token的接口。输入正确的用户名、密码即可获取token。
下面我们访问一个我们定义的简单的接口“/hello”,但是不传递JWT令牌,结果是禁止访问。当我们将上一步返回的token,传递到header中,就能正常响应hello的接口结果。
有的同学按照本文实现了登录认证功能,但是仍然没有办法访问"/hello"这个API,回头看一下2.3章节的内容。后面的学完了,前面的不能忘了啊。
所以你需要为你当前登录用户分配角色、该角色具有访问“/hello”的接口访问权限,你才能正确的获取数据。
转发大神的链接,里面有jwt 的配置
转载地址:http://bpoq.baihongyu.com/