jwt.md 16.3 KB
Newer Older
沉默王二's avatar
jwt  
沉默王二 已提交
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386
## 干掉Session?这个跨域认证解决方案真的优雅!

用户登录认证是 Web 应用中非常常见的一个业务,一般的流程是这样的:

- 客户端向服务器端发送用户名和密码
- 服务器端验证通过后,在当前会话(session)中保存相关数据,比如说登录时间、登录 IP 等。
- 服务器端向客户端返回一个 session_id,客户端将其保存在 Cookie 中。
- 客户端再向服务器端发起请求时,将 session_id 传回给服务器端。
- 服务器端拿到 session_id 后,对用户的身份进行鉴定。

单机情况下,这种模式是没有任何问题的,但对于前后端分离的 Web 应用来说,就非常痛苦了。于是就有了另外一种解决方案,服务器端不再保存 session 数据,而是将其保存在客户端,客户端每次发起请求时再把这个数据发送给服务器端进行验证。**JWT**(JSON Web Token)就是这种方案的典型代表。

![](https://cdn.jsdelivr.net/gh/itwanger/toBeBetterJavaer/images/springboot/jwt-1.png)


### 一、关于 JWT 

JWT,是目前最流行的一个[跨域](https://mp.weixin.qq.com/s/HTMDZaukCb7pyfHefVcfyg)认证解决方案:客户端发起用户登录请求,服务器端接收并认证成功后,生成一个 JSON 对象(如下所示),然后将其返回给客户端。

```
{
  "sub": "wanger",
  "created": 1645700436900,
  "exp": 1646305236
}
```

客户端再次与服务器端通信的时候,把这个 JSON 对象捎带上,作为前后端互相信任的一个凭证。服务器端接收到请求后,通过 JSON 对象对用户身份进行鉴定,这样就不再需要保存任何 session 数据了。

假如我现在使用用户名 wanger 和密码 123456 进行访问编程喵(Codingmore)的 login 接口,那么实际的 JWT 是一串看起来像是加过密的字符串。

![](https://cdn.jsdelivr.net/gh/itwanger/toBeBetterJavaer/images/springboot/jwt-2.png)

为了让大家看的更清楚一点,我将其复制到了 [jwt 的官网](https://jwt.io/)

![](https://cdn.jsdelivr.net/gh/itwanger/toBeBetterJavaer/images/springboot/jwt-3.png)



左侧 Encoded 部分就是 JWT 密文,中间用「`.`」分割成了三部分(右侧 Decoded 部分):

- Header(头部),描述 JWT 的元数据,其中 `alg` 属性表示签名的算法(当前为 HS512);
- Payload(负载),用来存放实际需要传递的数据,其中 `sub` 属性表示主题(实际值为用户名),`created` 属性表示 JWT 产生的时间,`exp` 属性表示过期时间
- Signature(签名),对前两部分的签名,防止数据篡改;这里需要服务器端指定一个密钥(只有服务器端才知道),不能泄露给客户端,然后使用 Header 中指定的签名算法,按照下面的公式产生签名:

```
HMACSHA512(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  your-256-bit-secret
)
```

算出签名后,再把 Header、Payload、Signature 拼接成一个字符串,中间用「`.`」分割,就可以返回给客户端了。

客户端拿到 JWT 后,可以放在 localStorage,也可以放在 Cookie 里面。

```
const TokenKey = '1D596CD8-8A20-4CEC-98DD-CDC12282D65C' // createUuid()

export function getToken () {
  return Cookies.get(TokenKey)
}

export function setToken (token) {
  return Cookies.set(TokenKey, token)
}
```

以后客户端再与服务器端通信的时候,就带上这个 JWT,一般放在 HTTP 的请求的头信息 Authorization 字段里。

```
Authorization: Bearer <token>
```

![](https://cdn.jsdelivr.net/gh/itwanger/toBeBetterJavaer/images/springboot/jwt-4.png)

服务器端接收到请求后,再对 JWT 进行验证,如果验证通过就返回相应的资源。

### 二、实战 JWT

第一步,在 pom.xml 文件中添加 JWT 的依赖。

```
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.0</version>
</dependency>
```

第二步,在 application.yml 中添加 JWT 的配置项。

```
jwt:
  tokenHeader: Authorization #JWT存储的请求头
  secret: codingmore-admin-secret #JWT加解密使用的密钥
  expiration: 604800 #JWT的超期限时间(60*60*24*7)
  tokenHead: 'Bearer '  #JWT负载中拿到开头
```

第三步,新建 JwtTokenUtil.java 工具类,主要有三个方法:

- `generateToken(UserDetails userDetails)`:根据登录用户生成 token
- `getUserNameFromToken(String token)`:从 token 中获取登录用户
- `validateToken(String token, UserDetails userDetails)`:判断 token 是否仍然有效

```java
public class JwtTokenUtil {

    @Value("${jwt.secret}")
    private String secret;
    @Value("${jwt.expiration}")
    private Long expiration;
    @Value("${jwt.tokenHead}")
    private String tokenHead;

    /**
     * 根据用户信息生成token
     */
    public String generateToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        claims.put(CLAIM_KEY_USERNAME, userDetails.getUsername());
        claims.put(CLAIM_KEY_CREATED, new Date());
        return generateToken(claims);
    }

    /**
     * 根据用户名、创建时间生成JWT的token
     */
    private String generateToken(Map<String, Object> claims) {
        return Jwts.builder()
                .setClaims(claims)
                .setExpiration(generateExpirationDate())
                .signWith(SignatureAlgorithm.HS512, secret)
                .compact();
    }

    /**
     * 从token中获取登录用户名
     */
    public String getUserNameFromToken(String token) {
        String username = null;
        Claims claims = getClaimsFromToken(token);
        if (claims != null) {
            username = claims.getSubject();
        }

        return username;
    }

    /**
     * 从token中获取JWT中的负载
     */
    private Claims getClaimsFromToken(String token) {
        Claims claims = null;
        try {
            claims = Jwts.parser()
                    .setSigningKey(secret)
                    .parseClaimsJws(token)
                    .getBody();
        } catch (Exception e) {
            LOGGER.info("JWT格式验证失败:{}", token);
        }
        return claims;
    }

    /**
     * 验证token是否还有效
     *
     * @param token       客户端传入的token
     * @param userDetails 从数据库中查询出来的用户信息
     */
    public boolean validateToken(String token, UserDetails userDetails) {
        String username = getUserNameFromToken(token);
        return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
    }

    /**
     * 判断token是否已经失效
     */
    private boolean isTokenExpired(String token) {
        Date expiredDate = getExpiredDateFromToken(token);
        return expiredDate.before(new Date());
    }

    /**
     * 从token中获取过期时间
     */
    private Date getExpiredDateFromToken(String token) {
        Claims claims = getClaimsFromToken(token);
        return claims.getExpiration();
    }
}
```

第四步, 在 UsersController.java 中新增 login 登录接口,接收用户名和密码,并将 JWT 返回给客户端。

```java
@Controller
@Api(tags="用户")
@RequestMapping("/users")
public class UsersController {
    @Autowired
    private IUsersService usersService;
    @Value("${jwt.tokenHeader}")
    private String tokenHeader;
    @Value("${jwt.tokenHead}")
    private String tokenHead;

    @ApiOperation(value = "登录以后返回token")
    @RequestMapping(value = "/login", method = RequestMethod.POST)
    @ResponseBody
    public ResultObject login(@Validated UsersLoginParam users, BindingResult result) {
        String token = usersService.login(users.getUserLogin(), users.getUserPass());

        if (token == null) {
            return ResultObject.validateFailed("用户名或密码错误");
        }

        // 将 JWT 传递回客户端
        Map<String, String> tokenMap = new HashMap<>();
        tokenMap.put("token", token);
        tokenMap.put("tokenHead", tokenHead);
        return ResultObject.success(tokenMap);
    }

}
```

第五步,在 UsersServiceImpl.java 中新增 login 方法,根据用户名从数据库中查询用户,密码验证通过后生成 JWT。

```java
@Service
public class UsersServiceImpl extends ServiceImpl<UsersMapper, Users> implements IUsersService {

    @Autowired
    private PasswordEncoder passwordEncoder;
    @Autowired
    private JwtTokenUtil jwtTokenUtil;

    public String login(String username, String password) {
        String token = null;
        //密码需要客户端加密后传递
        try {
            // 查询用户+用户资源
            UserDetails userDetails = loadUserByUsername(username);

            // 验证密码
            if (!passwordEncoder.matches(password, userDetails.getPassword())) {
                Asserts.fail("密码不正确");
            }

            // 返回 JWT
            token = jwtTokenUtil.generateToken(userDetails);
        } catch (AuthenticationException e) {
            LOGGER.warn("登录异常:{}", e.getMessage());
        }
        return token;
    }
}
```

第六步,新增 JwtAuthenticationTokenFilter.java,每次客户端发起请求时对 JWT 进行验证。

```java
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
    private static final Logger LOGGER = LoggerFactory.getLogger(JwtAuthenticationTokenFilter.class);
    @Autowired
    private UserDetailsService userDetailsService;
    @Autowired
    private JwtTokenUtil jwtTokenUtil;
    @Value("${jwt.tokenHeader}")
    private String tokenHeader;
    @Value("${jwt.tokenHead}")
    private String tokenHead;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        // 从客户端请求中获取 JWT
        String authHeader = request.getHeader(this.tokenHeader);
        // 该 JWT 是我们规定的格式,以 tokenHead 开头
        if (authHeader != null && authHeader.startsWith(this.tokenHead)) {
            // The part after "Bearer "
            String authToken = authHeader.substring(this.tokenHead.length());
            // 从 JWT 中获取用户名
            String username = jwtTokenUtil.getUserNameFromToken(authToken);
            LOGGER.info("checking username:{}", username);

            // SecurityContextHolder 是 SpringSecurity 的一个工具类
            // 保存应用程序中当前使用人的安全上下文
            if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                // 根据用户名获取登录用户信息
                UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
                // 验证 token 是否过期
                if (jwtTokenUtil.validateToken(authToken, userDetails)) {
                    // 将登录用户保存到安全上下文中
                    UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails,
                            null, userDetails.getAuthorities());
                    authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                    SecurityContextHolder.getContext().setAuthentication(authentication);

                    LOGGER.info("authenticated user:{}", username);
                }
            }
        }
        chain.doFilter(request, response);
    }
}
```

JwtAuthenticationTokenFilter  继承了 OncePerRequestFilter,该过滤器能确保一次请求只通过一次 filter,而不需要重复执行。也就是说,客户端每发起一次请求,该过滤器就会执行一次。

这个过滤器非常关键啊,基本上每行代码我都添加了注释,当然了,为了确保大家都能搞清楚这个类到底做了什么,我再来画一幅流程图,这样就一清二楚了。

![](https://cdn.jsdelivr.net/gh/itwanger/toBeBetterJavaer/images/springboot/jwt-5.png)

SpringSecurity 是一个安全管理框架,可以和 Spring Boot 应用无缝衔接,SecurityContextHolder 是其中非常关键的一个工具类,持有安全上下文信息,里面保存有当前操作的用户是谁,用户是否已经被认证,用户拥有的权限等关键信息。

SecurityContextHolder 默认使用了 ThreadLocal 策略来存储认证信息,ThreadLocal 的特点是存在它里边的数据,哪个线程存的,哪个线程才能访问到。这就意味着不同的请求进入到服务器端后,会由不同的 Thread 去处理,例如线程 A 将请求 1 的用户信息存入了 ThreadLocal,线程 B 在处理请求 2 的时候是无法获取到用户信息的。

所以说 JwtAuthenticationTokenFilter 过滤器会在每次请求过来的时候进行一遍 JWT 的验证,确保客户端过来的请求是安全的。然后 SpringSecurity 才会对接下来的请求接口放行。这也是 JWT 和 Session 的根本区别:

- JWT 需要每次请求的时候验证一次,并且只要 JWT 没有过期,哪怕服务器端重启了,认证仍然有效。
- Session 在没有过期的情况下是不需要重新对用户信息进行验证的,当服务器端重启后,用户需要重新登录获取新的 Session。

也就是说,**在 JWT 的方案下,服务器端保存的密钥(secret)一定不能泄露,否则客户端就可以根据签名算法伪造用户的认证信息了**

### 三、Swagger 中添加 JWT 验证

对于后端开发人员来说,如何在 [Swagger(整合了 Knife4j 进行美化)](https://mp.weixin.qq.com/s/gWPCg6TP3G_-I-eqA6EJmA) 中添加 JWT 验证呢?

第一步,访问 login 接口,输入用户名和密码进行登录,获取服务器端返回的 JWT。

![](https://cdn.jsdelivr.net/gh/itwanger/toBeBetterJavaer/images/springboot/jwt-6.png)


第二步,收集服务器端返回的 tokenHead 和 token,将其填入 Authorize(注意 tokenHead 和 token 之间有一个空格)完成登录认证。

![](https://cdn.jsdelivr.net/gh/itwanger/toBeBetterJavaer/images/springboot/jwt-7.png)

第三步,再次请求其他接口时,Swagger 会自动将 Authorization 作为请求头信息发送到服务器端。

![](https://cdn.jsdelivr.net/gh/itwanger/toBeBetterJavaer/images/springboot/jwt-8.png)

第四步,服务器端接收到该请求后,会通过 JwtAuthenticationTokenFilter 过滤器对 JWT 进行校验。

![](https://cdn.jsdelivr.net/gh/itwanger/toBeBetterJavaer/images/springboot/jwt-9.png)

到此为止,整个流程全部打通了,完美!

### 四、总结

综上来看,用 JWT 来解决前后端分离项目中的跨域认证还是非常丝滑的,这主要得益于 JSON 的通用性,可以跨语言,JavaScript 和 Java 都支持;另外,JWT 的组成非常简单,非常便于传输;还有 JWT 不需要在服务器端保存会话信息(Session),非常易于扩展。

当然了,为了保证 JWT 的安全性,不要在 JWT 中保存敏感信息,因为一旦私钥泄露,JWT 是很容易在客户端被解密的;如果可以,请使用 HTTPS 协议。

参考链接:

>阮一峰:https://www.ruanyifeng.com/blog/2018/07/json_web_token-tutorial.html<br>
>春夏秋冬过:https://segmentfault.com/a/1190000012557493<br>
>江南一点雨:https://cloud.tencent.com/developer/article/1612175<br>
>Dearmadman:https://www.jianshu.com/p/576dbf44b2ae<br>
>mcarozheng:http://www.macrozheng.com/

源码路径:

>https://github.com/itwanger/coding-more


----

**本篇已收录至 GitHub 上星标 1.6k+ star 的开源专栏《Java 程序员进阶之路》,据说每一个优秀的 Java 程序员都喜欢她,风趣幽默、通俗易懂。内容包括 Java 基础、Java 并发编程、Java 虚拟机、Java 企业级开发、Java 面试等核心知识点。学 Java,就认准 Java 程序员进阶之路**😄。

[https://github.com/itwanger/toBeBetterJavaer](https://github.com/itwanger/toBeBetterJavaer)

star 了这个仓库就等于你拥有了成为了一名优秀 Java 工程师的潜力。也可以戳下面的链接跳转到《Java 程序员进阶之路》的官网网址,开始愉快的学习之旅吧。

[https://tobebetterjavaer.com/](https://tobebetterjavaer.com/)

![](https://cdn.jsdelivr.net/gh/itwanger/toBeBetterJavaer/images/springboot/jwt-10.png)


*没有什么使我停留——除了目的,纵然岸旁有玫瑰、有绿荫、有宁静的港湾,我是不系之舟*