小紅書二面:什么是JWT?它是如何工作的?
一 無狀態(tài)登錄
1.1 什么是有狀態(tài)
有狀態(tài)服務(wù),即服務(wù)端需要記錄每次會(huì)話的客戶端信息,從而識(shí)別客戶端身份,根據(jù)用戶身份進(jìn)行請(qǐng)求的處理,典型的設(shè)計(jì)如 Tomcat 中的 Session。例如登錄:用戶登錄后,我們把用戶的信息保存在服務(wù)端 session 中,并且給用戶一個(gè) cookie 值,記錄對(duì)應(yīng)的 session,然后下次請(qǐng)求,用戶攜帶 cookie 值來(這一步有瀏覽器自動(dòng)完成),我們就能識(shí)別到對(duì)應(yīng) session,從而找到用戶的信息。這種方式目前來看最方便,但是也有一些缺陷,如下:
- 服務(wù)端保存大量數(shù)據(jù),增加服務(wù)端壓力
- 服務(wù)端保存用戶狀態(tài),不支持集群化部署
1.2 什么是無狀態(tài)
微服務(wù)集群中的每個(gè)服務(wù),對(duì)外提供的都使用 RESTful 風(fēng)格的接口。而 RESTful 風(fēng)格的一個(gè)最重要的規(guī)范就是:服務(wù)的無狀態(tài)性,即:
- 服務(wù)端不保存任何客戶端請(qǐng)求者信息
- 客戶端的每次請(qǐng)求必須具備自描述信息,通過這些信息識(shí)別客戶端身份
那么這種無狀態(tài)性有哪些好處呢?
- 客戶端請(qǐng)求不依賴服務(wù)端的信息,多次請(qǐng)求不需要必須訪問到同一臺(tái)服務(wù)器
- 服務(wù)端的集群和狀態(tài)對(duì)客戶端透明
- 服務(wù)端可以任意的遷移和伸縮(可以方便的進(jìn)行集群化部署)
- 減小服務(wù)端存儲(chǔ)壓力
1.3 如何實(shí)現(xiàn)無狀態(tài)
無狀態(tài)登錄的流程:
- 首先客戶端發(fā)送賬戶名/密碼到服務(wù)端進(jìn)行認(rèn)證
- 認(rèn)證通過后,服務(wù)端將用戶信息加密并且編碼成一個(gè) token(一般是 JWT),返回給客戶端
- 以后客戶端每次發(fā)送請(qǐng)求,都需要攜帶認(rèn)證的 token
- 服務(wù)端對(duì)客戶端發(fā)送來的 token 進(jìn)行解密,判斷是否有效,并且獲取用戶登錄信息
二 JWT
2.1 JWT 簡介
JWT,全稱是 Json Web Token, 是一種 JSON 風(fēng)格的輕量級(jí)的授權(quán)和身份認(rèn)證規(guī)范,可實(shí)現(xiàn)無狀態(tài)、分布式的 Web 應(yīng)用授權(quán):
圖片
JWT 作為一種規(guī)范,并沒有和某一種語言綁定在一起,常用的 Java 實(shí)現(xiàn)是 GitHub 上的開源項(xiàng)目 jjwt,地址如下:https://github.com/jwtk/jjwt
2.2 JWT 數(shù)據(jù)格式
JWT 包含三部分?jǐn)?shù)據(jù):
- Header:頭部,通常頭部有兩部分信息:
聲明類型,這里是JWT
加密算法,自定義
我們會(huì)對(duì)頭部進(jìn)行 Base64Url 編碼(可解碼),得到第一部分?jǐn)?shù)據(jù)。
- Payload:載荷,就是有效數(shù)據(jù),在官方文檔中(RFC7519),這里給了7個(gè)示例信息:
iss (issuer):表示簽發(fā)人
exp (expiration time):表示token過期時(shí)間
sub (subject):主題
aud (audience):受眾
nbf (Not Before):生效時(shí)間
iat (Issued At):簽發(fā)時(shí)間
jti (JWT ID):編號(hào)
這部分也會(huì)采用 Base64Url 編碼,得到第二部分?jǐn)?shù)據(jù)。
- Signature:簽名,是整個(gè)數(shù)據(jù)的認(rèn)證信息。一般根據(jù)前兩步的數(shù)據(jù),再加上服務(wù)的的密鑰secret(密鑰保存在服務(wù)端,不能泄露給客戶端),通過 Header 中配置的加密算法生成。用于驗(yàn)證整個(gè)數(shù)據(jù)完整和可靠性。
生成的數(shù)據(jù)格式如下圖:
圖片
注意,這里的數(shù)據(jù)通過 . 隔開成了三部分,分別對(duì)應(yīng)前面提到的三部分,另外,這里數(shù)據(jù)是不換行的,圖片換行只是為了展示方便而已。
2.3 JWT 交互流程
流程圖:
圖片
步驟翻譯:
- 應(yīng)用程序或客戶端向授權(quán)服務(wù)器請(qǐng)求授權(quán)
- 獲取到授權(quán)后,授權(quán)服務(wù)器會(huì)向應(yīng)用程序返回訪問令牌
- 應(yīng)用程序使用訪問令牌來訪問受保護(hù)資源(如 API)
因?yàn)?JWT 簽發(fā)的 token 中已經(jīng)包含了用戶的身份信息,并且每次請(qǐng)求都會(huì)攜帶,這樣服務(wù)的就無需保存用戶信息,甚至無需去數(shù)據(jù)庫查詢,這樣就完全符合了 RESTful 的無狀態(tài)規(guī)范。
2.4 JWT 存在的問題
說了這么多,JWT 也不是天衣無縫,由客戶端維護(hù)登錄狀態(tài)帶來的一些問題在這里依然存在,舉例如下:
- 續(xù)簽問題,這是被很多人詬病的問題之一,傳統(tǒng)的 cookie+session 的方案天然的支持續(xù)簽,但是 jwt 由于服務(wù)端不保存用戶狀態(tài),因此很難完美解決續(xù)簽問題,如果引入 redis,雖然可以解決問題,但是 jwt 也變得不倫不類了。
- 注銷問題,由于服務(wù)端不再保存用戶信息,所以一般可以通過修改 secret 來實(shí)現(xiàn)注銷,服務(wù)端 secret 修改后,已經(jīng)頒發(fā)的未過期的 token 就會(huì)認(rèn)證失敗,進(jìn)而實(shí)現(xiàn)注銷,不過畢竟沒有傳統(tǒng)的注銷方便。
- 密碼重置,密碼重置后,原本的 token 依然可以訪問系統(tǒng),這時(shí)候也需要強(qiáng)制修改 secret。
- 基于第 2 點(diǎn)和第 3 點(diǎn),一般建議不同用戶取不同 secret。
對(duì)于上面提到的這些問題,在目前具體的項(xiàng)目實(shí)踐中,更多的是通過引入 Redis 來解決這些問題。
三 實(shí)戰(zhàn)
說了這么久,接下來我們就來看看這個(gè)東西到底要怎么用?
3.1 環(huán)境搭建
首先我們來創(chuàng)建一個(gè) Spring Boot 項(xiàng)目,創(chuàng)建時(shí)需要添加 Spring Security 依賴,創(chuàng)建完成后,添加 jjwt 依賴,完整的 pom.xml 文件如下:
<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>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
</dependency>
然后在項(xiàng)目中創(chuàng)建一個(gè)簡單的 User 對(duì)象實(shí)現(xiàn) UserDetails 接口,如下:
public class User implements UserDetails {
private String username;
private String password;
private List<GrantedAuthority> authorities;
public String getUsername() {
return username;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
//省略getter/setter
}
這個(gè)就是我們的用戶對(duì)象,先放著備用,再創(chuàng)建一個(gè) HelloController,內(nèi)容如下:
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "hello jwt !";
}
@GetMapping("/admin")
public String admin() {
return "hello admin !";
}
}
HelloController 很簡單,這里有兩個(gè)接口,設(shè)計(jì)是 /hello 接口可以被具有 user 角色的用戶訪問,而 /admin 接口則可以被具有 admin 角色的用戶訪問。
3.2 JWT 配置
先準(zhǔn)備一個(gè) JWT 操作的工具類:
public class JwtUtils {
public static final Integer EXPIRE_TIME = 7 * 24 * 60 * 60;
public static final Integer REDIS_TOKEN_EXPIRE_TIME = 30 * 60;
public static final String TOKEN_ISSUER = "javaboy";
/**
* 生成 JWT 字符串
*
* @return
*/
public static String createJWT() throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String jws = Jwts.builder()
//設(shè)置頭部信息(相當(dāng)于 JWT 字符串第一部分的內(nèi)容)
.header().add("type", "JWT")
.and()
//相當(dāng)于 JWT 字符串第二部分的內(nèi)容
//簽名密鑰
.signWith(getPrivateKey())
//設(shè)置過期時(shí)間
.expiration(java.util.Date.from(java.time.Instant.now().plusSeconds(EXPIRE_TIME)))
//設(shè)置用戶名
.subject(authentication.getName())
//設(shè)置用戶權(quán)限,相當(dāng)于把用戶權(quán)限轉(zhuǎn)為字符串 admin,user,xxx
.claim("authorities", authentication.getAuthorities().stream().map(a -> a.getAuthority()).collect(Collectors.joining(",")))
//令牌簽發(fā)時(shí)間
.issuedAt(new Date())
.issuer(TOKEN_ISSUER)
.compact();
return jws;
}
public static PrivateKey getPrivateKey() throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {
ClassPathResource resource = new ClassPathResource("private_key.pem");
byte[] privateKeyBytes = FileCopyUtils.copyToByteArray(resource.getInputStream());
String key = new String(privateKeyBytes, StandardCharsets.UTF_8);
String privateStr = key
.replace("-----BEGIN PRIVATE KEY-----", "")
.replaceAll(System.lineSeparator(), "")
.replace("-----END PRIVATE KEY-----", "");
byte[] privateKeyDecodedBytes = Base64.getDecoder().decode(privateStr);
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(privateKeyDecodedBytes);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
PrivateKey privateKey = keyFactory.generatePrivate(keySpec);
return privateKey;
}
public static RSAPublicKey getPublicKey() throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {
ClassPathResource resource = new ClassPathResource("public_key.pem");
byte[] publicKeyBytes = FileCopyUtils.copyToByteArray(resource.getInputStream());
String key = new String(publicKeyBytes, StandardCharsets.UTF_8);
String publicStr = key
.replace("-----BEGIN PUBLIC KEY-----", "")
.replaceAll(System.lineSeparator(), "")
.replace("-----END PUBLIC KEY-----", "");
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
byte[] publicKeyBase64Bytes = Base64.getDecoder().decode(publicStr);
X509EncodedKeySpec publicKeySpec = new X509EncodedKeySpec(publicKeyBase64Bytes);
PublicKey rsaPublicKey = keyFactory.generatePublic(publicKeySpec);
return (RSAPublicKey) rsaPublicKey;
}
public static Authentication verifyToken(String token) {
try {
Claims claims = Jwts.parser()
.verifyWith(getPublicKey()).build()
.parseSignedClaims(token).getPayload();
String username = claims.getSubject();
// user,admin,xxx
String authorities = claims.get("authorities", String.class);
// 解析用戶權(quán)限
List<SimpleGrantedAuthority> list = Arrays.stream(authorities.split(",")).map(s -> new SimpleGrantedAuthority(s)).collect(Collectors.toList());
return UsernamePasswordAuthenticationToken.authenticated(username, null, list);
} catch (Exception e) {
//令牌校驗(yàn)失敗
return null;
}
}
}
接下來,在用戶登錄成功的時(shí)候,生成 JWT 字符串,并存入到 Redis 中,如下:
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(a -> a.anyRequest().authenticated())
.csrf(c -> c.disable())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.formLogin(f -> f
.successHandler((req, resp, auth) -> {
//登錄成功,生成 JWT 字符串
try {
String token = JwtUtils.createJWT();
//將 JWT 字符串保存到 redis 中
redisTemplate.opsForValue().set(auth.getName() + ":" + token, auth.getName());
// 設(shè)置過期時(shí)間
redisTemplate.expire(auth.getName() + ":" + token, JwtUtils.REDIS_TOKEN_EXPIRE_TIME, TimeUnit.SECONDS);
resp.setContentType("application/json;charset=utf-8");
RespBean respBean = new RespBean(200, token, null);
resp.getWriter().write(new ObjectMapper().writeValueAsString(respBean));
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
} catch (InvalidKeySpecException e) {
throw new RuntimeException(e);
}
})
.permitAll())
.logout(l -> l.logoutSuccessHandler((req, resp, auth) -> {
// 刪除 redis 中的 token
String key = auth.getName() + ":" + req.getHeader("Authorization").replace("Bearer ", "");
System.out.println("key = " + key);
redisTemplate.delete(key);
resp.getWriter().write("logout success");
}).logoutUrl("/logout").permitAll());
http.addFilterAfter(new JwtFilter(redisTemplate), SecurityContextHolderFilter.class);
return http.build();
}
最后再來一個(gè)校驗(yàn)的過濾器,在用法發(fā)送請(qǐng)求的時(shí)候,校驗(yàn) JWT 字符串,如下:
public class JwtFilter extends GenericFilterBean {
StringRedisTemplate redisTemplate;
public JwtFilter(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) {
RespBean result = null;
HttpServletRequest req = (HttpServletRequest) servletRequest;
HttpServletResponse resp = (HttpServletResponse) servletResponse;
resp.setContentType("application/json;charset=utf-8");
try {
//如果是登錄請(qǐng)求,則直接放行
if (req.getRequestURI().equals("/login")) {
filterChain.doFilter(servletRequest, servletResponse);
return;
}
//獲取請(qǐng)求頭中的token
String token = req.getHeader("Authorization");
if (token == null) {
result = new RespBean(500, "令牌為空,請(qǐng)求失敗", null);
PrintWriter out = resp.getWriter();
out.write(new ObjectMapper().writeValueAsString(result));
} else {
//驗(yàn)證token
token = token.replace("Bearer ", "");
//驗(yàn)證token是否有效
//校驗(yàn)通過,則返回認(rèn)證后的 Auhtentication 對(duì)象,否則返回 null
Authentication authentication = JwtUtils.verifyToken(token);
String s = redisTemplate.opsForValue().get(authentication.getName() + ":" + token);
if (s == null) {
// 令牌失效
result = new RespBean(501, "令牌失效,請(qǐng)重新登錄", null);
resp.getWriter().write(new ObjectMapper().writeValueAsString(result));
} else {
//令牌有效,檢查令牌過期時(shí)間,如果臨近過期,則為令牌續(xù)期
Long expire = redisTemplate.getExpire(authentication.getName() + ":" + token);
if (expire != null && expire < 10 * 60) {
//說明令牌還有 10 分鐘過期,則重新設(shè)置新的過期時(shí)間
redisTemplate.expire(authentication.getName() + ":" + token, Duration.ofSeconds(JwtUtils.REDIS_TOKEN_EXPIRE_TIME));
}
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(servletRequest, servletResponse);
}
}
} catch (Exception e) {
} finally {
// 清除上下文
SecurityContextHolder.clearContext();
}
}
}
關(guān)于這個(gè)過濾器,我說如下幾點(diǎn):
- 首先從請(qǐng)求頭中提取出 Authorization 字段,這個(gè)字段對(duì)應(yīng)的 value 就是用戶的 token。
- 將提取出來的 token 字符串調(diào)用 JwtUtils.verifyToken 方法進(jìn)行驗(yàn)證,將驗(yàn)證結(jié)果放到當(dāng)前的 SecurityContext 中,然后執(zhí)行過濾鏈?zhǔn)拐?qǐng)求繼續(xù)執(zhí)行下去。
3.3 Spring Security 配置
最后,在 Spring Security 中配置這個(gè)過濾器。
http.addFilterAfter(new JwtFilter(redisTemplate), SecurityContextHolderFilter.class);
搞定!
3.4 測(cè)試
做完這些之后,我們的環(huán)境就算完全搭建起來了,接下來啟動(dòng)項(xiàng)目然后在 POSTMAN 中進(jìn)行測(cè)試,如下:
圖片
登錄成功后返回的字符串就是經(jīng)過 base64url 轉(zhuǎn)碼的 token,一共有三部分,通過一個(gè) . 隔開,我們可以對(duì)第一個(gè) . 之前的字符串進(jìn)行解碼,即 Header,如下:
圖片
再對(duì)兩個(gè) . 之間的字符解碼,即 payload:
圖片
可以看到,我們?cè)O(shè)置信息,由于 base64 并不是加密方案,只是一種編碼方案,因此,不建議將敏感的用戶信息放到 token 中。
接下來再去訪問 /hello 接口,注意認(rèn)證方式選擇 Bearer Token,Token 值為剛剛獲取到的值,如下:
圖片
可以看到,訪問成功。
四 總結(jié)
這就是 JWT 結(jié)合 Spring Security 的一個(gè)簡單用法。