常見的認證方式
HTTP Basic Auth
HTTP Basic Auth 一種最古老的安全認證方式,這種方式就是簡單的通路API的時候,帶上通路的username和password,由于資訊會暴露出去,是以現在也越來越少用了。
Cookie + Session
服務端驗證後,建立一個 Session 資訊,服務端存儲 Session資訊,并且将 SessionID 存到 cookie,發送回浏覽器。下次用戶端再發起請求,自動帶上 cookie 資訊,服務端通過 cookie 的 SessionID 擷取 Session 資訊進行校驗。
Token
基于 token 的鑒權機制類似于 HTTP 協定也是無狀态的,它不需要在服務端去保留使用者的認證資訊或者會話資訊。第一次登陸成功後,服務端傳回給用戶端一個 token 值,用戶端存儲token,并在每次請求時附送上這個 token 值,服務端通過解析 token 的值判斷使用者的合法性。
JWT
JWT 是 token 的一種優化,把資料直接放在 token 中,然後對 token 加密,服務端擷取token後,解密就可以擷取用戶端資訊,不需要再去資料庫查詢用戶端資訊了。
什麼是 Cookie
HTTP 是無狀态的協定(對于事務處理沒有記憶能力,每次用戶端和服務端會話完成時,服務端不會儲存任何會話資訊):每個請求都是完全獨立的,服務端無法确認目前通路者的身份資訊,無法分辨上一次的請求發送者和這一次的發送者是不是同一個人。是以伺服器與浏覽器為了進行會話跟蹤(知道是誰在通路我),就必須主動的去維護一個狀态,這個狀态用于告知服務端前後兩個請求是否來自同一浏覽器。而這個狀态需要通過 cookie 或者 session 去實作。
cookie 存儲在用戶端:cookie 是伺服器發送到使用者浏覽器并儲存在本地的一小塊資料,它會在浏覽器下次向同一伺服器再發起請求時被攜帶并發送到伺服器上。
cookie 是不可跨域的:每個 cookie 都會綁定單一的域名,無法在别的域名下擷取使用,一級域名和二級域名之間是允許共享使用的(靠的是 domain)。
什麼是 Session
session 是另一種記錄伺服器和用戶端會話狀态的機制。session 是基于 cookie 實作的,session 存儲在伺服器端,sessionId 會被存儲到用戶端的cookie 中。
![](https://img.laitimes.com/img/__Qf2AjLwojIjJCLyojI0JCLicmbw5iYzMDZwYDMlNDNyY2Y4ATY2gTO0gTNldzY2gjY4UjYw8CX5d2bs92Yl1iclB3bsVmdlR2LcNWaw9CXt92Yu4GZjlGbh5yYjV3Lc9CX6MHc0RHaiojIsJye.png)
session 認證流程:
- 使用者第一次請求伺服器的時候,伺服器根據使用者送出的相關資訊,建立對應的 Session。
- 請求傳回時将此 Session 的唯一辨別資訊 SessionID 傳回給浏覽器。
- 浏覽器接收到伺服器傳回的 SessionID 資訊後,會将此資訊存入到 Cookie 中,同時 Cookie 記錄此 SessionID 屬于哪個域名。
- 當使用者第二次通路伺服器的時候,請求會自動判斷此域名下是否存在 Cookie 資訊,如果存在自動将 Cookie 資訊也發送給服務端,服務端會從 Cookie 中擷取 SessionID,再根據 SessionID 查找對應的 Session 資訊,如果沒有找到說明使用者沒有登入或者登入失效,如果找到 Session 證明使用者已經登入可執行後面操作。
根據以上流程可知,SessionID 是連接配接 Cookie 和 Session 的一道橋梁,大部分系統也是根據此原理來驗證使用者登入狀态。
Cookie 和 Session 的差別
- 安全性:Session 比 Cookie 安全,Session 是存儲在伺服器端的,Cookie 是存儲在用戶端的。
- 存取值的類型不同:Cookie 隻支援存字元串資料,想要設定其他類型的資料,需要将其轉換成字元串,Session 可以存任意資料類型。
- 有效期不同:Cookie 可設定為長時間保持,比如我們經常使用的預設登入功能,Session 一般失效時間較短,用戶端關閉(預設情況下)或者 Session 逾時都會失效。
- 存儲大小不同:單個 Cookie 儲存的資料不能超過 4K,Session 可存儲資料遠高于 Cookie,但是當通路量過多,會占用過多的伺服器資源。
什麼是 Token(令牌)
token 是用戶端通路服務端時所需要的資源憑證。用戶端每一次請求都需要攜帶 token,需要把 token 放到 HTTP 的 Header 裡。基于 token 的使用者認證是一種服務端無狀态的認證方式,服務端不用存放 token 資料。用解析 token 的計算時間換取 session 的存儲空間,進而減輕伺服器的壓力,減少頻繁的查詢資料庫。token 完全由應用管理,是以它可以避開同源政策。
token 的身份驗證流程:
- 用戶端使用使用者名跟密碼請求登入。
- 服務端收到請求,去驗證使用者名與密碼。
- 驗證成功後,服務端會簽發一個 token 并把這個 token 發送給用戶端。
- 用戶端收到 token 以後,會把它存儲起來,比如放在 cookie 裡或者 localStorage 裡。
- 用戶端每次向服務端請求資源的時候需要帶着服務端簽發的 token。
- 服務端收到請求,然後去驗證用戶端請求裡面帶着的 token ,如果驗證成功,就向用戶端傳回請求的資料。
JWT 介紹
JSON Web Token(簡稱 JWT)是目前最流行的跨域認證解決方案,是一種認證授權機制。JWT 是為了在網絡應用環境間傳遞聲明而執行的一種基于 JSON 的開放标準(RFC 7519)。JWT 的聲明一般被用來在身份提供者和服務提供者間傳遞被認證的使用者身份資訊,以便于從資源伺服器擷取資源。比如用在使用者登入上。可以使用 HMAC 算法或者是 RSA 的公/私秘鑰對 JWT 進行簽名。因為數字簽名的存在,這些傳遞的資訊是可信的。
JWT 格式
JWT 的資料結構如下圖所示:
它是一個很長的字元串,中間用點
.
分隔成三個部分。注意,JWT 内部是沒有換行的,這裡隻是為了便于展示,将它寫成了幾行。
JWT 的三個部分依次如下:
Header(頭部)
Payload(負載)
Signature(簽名)
Header
Header 部分是一個 JSON 對象,描述 JWT 的中繼資料,通常是下面的樣子:
{
"alg": "HS256",
"typ": "JWT"
}
上面代碼中,alg屬性表示簽名的算法(algorithm),預設是 HMAC SHA256(寫成 HS256);typ屬性表示這個令牌(token)的類型(type),JWT 令牌統一寫為 JWT。最後,将上面的 JSON 對象使用 Base64URL 算法(詳見後文)轉成字元串。
Payload
Payload 部分也是一個 JSON 對象,用來存放實際需要傳遞的資料。JWT 規定了7個官方字段,供選用:
iss (issuer):簽發人
exp (expiration time):過期時間
sub (subject):主題
aud (audience):閱聽人
nbf (Not Before):生效時間
iat (Issued At):簽發時間
jti (JWT ID):編号
除了官方字段,你還可以在這個部分定義私有字段,下面就是一個例子:
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}
前面提到,Header 和 Payload 串型化的算法是 Base64URL。這個算法跟 Base64 算法基本類似,但有一些小的不同。
JWT 作為一個令牌(token),有些場合可能會放到 URL(比如 api.example.com/?token=xxx)。Base64 有三個字元
+
、
/
和
=
,在 URL 裡面有特殊含義,是以要被替換掉:
=
被省略、
+
替換成
-
,
/
_
。這就是 Base64URL 算法。
Signature
Signature 部分是對前兩部分的簽名,防止資料篡改。首先,需要指定一個密鑰(secret)。這個密鑰隻有伺服器才知道,不能洩露給使用者。然後,使用 Header 裡面指定的簽名算法(預設是 HMAC SHA256),按照下面的公式産生簽名。
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload),secret)
算出簽名以後,把 Header、Payload、Signature 三個部分拼成一個字元串,每個部分之間用
.
分隔,就可以傳回給使用者。
JWT 的使用方式
用戶端收到伺服器傳回的 JWT,可以儲存在 Cookie 裡面,也可以儲存在 localStorage。此後,用戶端每次與伺服器通信,都要帶上這個 JWT。你可以把它放在 Cookie 裡面自動發送,但是這樣不能跨域,是以更好的做法是放在 HTTP 請求的頭資訊Authorization字段裡面。
Authorization: Bearer <jwt token>
服務端收到 JWT Token 後,使用密鑰進行解密,就可以得到用戶端的相應資訊了,不需要再去資料庫查詢用戶端資訊。
Token 和 JWT
- 相同:
-
- 都是通路資源的令牌。
- 都可以記錄使用者的資訊。
- 都是使服務端無狀态化。
- 都是隻有驗證成功後,用戶端才能通路服務端上受保護的資源。
- 差別:
-
- Token:服務端驗證用戶端發送過來的 Token 時,還需要查詢資料庫擷取使用者資訊,然後驗證 Token 是否有效。
- JWT:将 Token 和 Payload 加密後存儲于用戶端,服務端隻需要使用密鑰解密進行校驗(校驗也是 JWT 自己實作的)即可,不需要查詢或者減少查詢資料庫,因為 JWT 自包含了使用者資訊和加密的資料。
JWT 實作
github位址:
https://github.com/cr7258/jwt-lab, 本例使用 JJWT(Java JWT)來建立和驗證 JSON Web Token(JWT)。
添加依賴
建立一個 Maven 項目并添加相關依賴:
<dependencies>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.2</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is preferred -->
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>
<!-- Uncomment this next dependency if you are using JDK 10 or earlier and you also want to use
RSASSA-PSS (PS256, PS384, PS512) algorithms. JDK 11 or later does not require it for those algorithms:
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk15on</artifactId>
<version>1.60</version>
<scope>runtime</scope>
</dependency>
-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.1.5.RELEASE</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.58</version>
</dependency>
</dependencies>
編寫過濾器
編寫過濾器,對請求驗證 token:
package com.chengzw.filter;
import com.chengzw.util.JwtService;
import io.jsonwebtoken.Claims;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 過濾器,判斷請求是否包含token
* @author 程治玮
* @since 2021/5/3 10:48 上午
*/
public class MyFilter implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request =(HttpServletRequest)servletRequest;
HttpServletResponse response = (HttpServletResponse)servletResponse;
response.setCharacterEncoding("utf-8");
String token = request.getHeader("authorization"); //擷取請求傳來的token
if( token == null){
response.getWriter().write("請攜帶token");
return;
}
Claims claims = JwtService.parsePersonJWT(token); //驗證token
if (claims == null) {
response.getWriter().write("請攜帶token");
}else {
filterChain.doFilter(request,response);
}
}
}
注冊過濾器
注冊過濾器,并添加需要過濾的 URI 路徑 /user/hello:
package com.chengzw.conf;
import com.chengzw.filter.MyFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 過濾器進行注冊,并添加需要過濾的路徑 /user/hello
* @author 程治玮
* @since 2021/5/3 10:37 上午
*/
@Configuration
public class BeanRegisterConfig {
@Bean
public FilterRegistrationBean createFilterBean() {
//過濾器注冊類
FilterRegistrationBean registration = new FilterRegistrationBean();
registration.setFilter(new MyFilter());
registration.addUrlPatterns("/user/hello"); //需要過濾的接口
return registration;
}
}
編寫JWT基礎工具類
JWT 基礎工具類包含兩個部分:建立 JWT 和解析 JWT,JWS 是加密簽名後的 JWT ,建立 JWS 主要有如下四步:
String jws = Jwts.builder() // 建立 JwtBulder 執行個體
.setSubject("Bob") // 添加 Header 參數和聲明
.signWith(key) // 指定希望對 JWT 簽名的密鑰(可以是對稱密鑰,也可以是非對稱密鑰的私鑰)
.compact(); // 壓縮和簽名
具體實作代碼:
package com.chengzw.util;
import io.jsonwebtoken.*;
import java.security.Key;
import java.util.Date;
import java.util.Map;
/**
* JWT基礎工具類
* @author 程治玮
* @since 2021/5/3 10:43 上午
*/
public class JwtUtils {
/**
* jwt解密,需要密鑰和token,如果解密失敗,說明token無效
* @param jsonWebToken
* @param signingKey
* @return
*/
public static Claims parseJWT(String jsonWebToken, Key signingKey) {
try {
Claims claims = Jwts.parser()
.setSigningKey(signingKey)
.parseClaimsJws(jsonWebToken)
.getBody();
return claims;
} catch (JwtException ex) {
return null;
}
}
/**
* jwt = 頭部(至少指定算法) + 身體(JWT編碼的所有聲明) + 簽名(将标題和正文的組合通過标題中指定的算法計算得出)
* jws:JWT可以加密簽名成為jws
* 建立token
* @param map 主題,也差不多是個人的一些資訊,為了好的移植,采用了map放個人資訊,而沒有采用JSON
* @param audience 發送誰
* @param issuer 個人簽名
* @param jwtId 相當于jwt的主鍵,不能重複
* @param TTLMillis Token過期時間
* @param signingKey 生成簽名密鑰
* @return
*/
public static String createJWT(Map map, String audience, String issuer, String jwtId, long TTLMillis, Key signingKey,SignatureAlgorithm signatureAlgorithm) {
long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis);
//添加構成JWT的參數
JwtBuilder builder = Jwts.builder().setHeaderParam("typ", "JWT")
.setIssuedAt(now)
.setSubject(map.toString())
.setIssuer(issuer)
.setId(jwtId)
.setAudience(audience)
.signWith(signingKey, signatureAlgorithm); //設定簽名使用的簽名算法和簽名使用的秘鑰
//添加Token過期時間
if (TTLMillis >= 0) {
// 過期時間
long expMillis = nowMillis + TTLMillis;
// 現在是什麼時間
Date exp = new Date(expMillis);
// 系統時間之前的token都是不可以被承認的
builder.setExpiration(exp).setNotBefore(now);
}
//生成JWS(加密後的JWT)
return builder.compact();
}
}
封裝 JWT 基礎工具類
對 JWT 基礎工具類進行二次封裝,提供加密和解密的方法:
package com.chengzw.util;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import java.security.Key;
import java.util.Map;
import java.util.UUID;
/**
* JWT服務類,對JwtUtils進行二次封裝,提供加密和解密的方法
* @author 程治玮
* @since 2021/5/3 10:43 上午
*/
public class JwtService {
/**
* token 過期時間, 機關: 秒. 這個值表示 30 天
*/
private static final long TOKEN_EXPIRED_TIME = 30 * 24 * 60 * 60;
/**
* jwt 加密解密密鑰
*/
//簽名密鑰算法
private static SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
//生成簽名密鑰
//方式一:自己定義加密解密密鑰
//private static String JWT_SECRET = "MDk4ZjZiY2Q0NjIxZDM3M2NhZGU0ZTgzMjYyN2I0ZjY=";
//private static byte[] apiKeySecretBytes = DatatypeConverter.parseBase64Binary(JWT_SECRET);
//private static Key signingKey = new SecretKeySpec(apiKeySecretBytes, signatureAlgorithm.getJcaName());
//方式二:傳入簽名算法,自動生成密鑰
private static Key signingKey = Keys.secretKeyFor(SignatureAlgorithm.HS256);
/**
* 個人簽名
*/
private static final String JWT_ISSUER = "CZW";
/**
* 描述:建立令牌
*
* @param map 主題,也差不多是個人的一些資訊,為了好的移植,采用了map放個人資訊,而沒有采用JSON
* @param audience 發送誰
* @return java.lang.String
*/
public static String createPersonToken(Map map, String audience) {
String personToken = JwtUtils.createJWT(map, audience, UUID.randomUUID().toString(), JWT_ISSUER, TOKEN_EXPIRED_TIME, signingKey, signatureAlgorithm);
return personToken;
}
/**
* 描述:解密JWT
*
* @param personToken JWT字元串,也就是token字元串
* @return io.jsonwebtoken.Claims
*/
public static Claims parsePersonJWT(String personToken) {
Claims claims = JwtUtils.parseJWT(personToken, signingKey);
return claims;
}
}
編寫 Controller 入口類
Controller 類提供使用者通路的入口:
package com.chengzw.controller;
import com.chengzw.util.JwtService;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;
/**
* @author 程治玮
* @since 2021/5/3 10:45 上午
*/
@RestController
public class LoginController {
//需要token驗證才能通路
@RequestMapping("user/hello")
public String user(){
return "hello";
}
//擷取token
@RequestMapping("user/token")
public String token(){
Map<String, Object> map = new HashMap<>();
map.put("name", "chengzw");
map.put("age", 21);
return JwtService.createPersonToken(map, "chengzw");
}
}
接口測試
第一次直接請求 /user/hello,會提示我們需要攜帶 token:
❯ curl http://localhost:8080/user/hello
請攜帶token
擷取 token:
❯ curl http://localhost:8080/user/token
#傳回token
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE2MjAwMTIwMTEsInN1YiI6IntuYW1lPWNoZW5nencsIGFnZT0yMX0iLCJpc3MiOiIwOGIxMDFjNC1hMmFjLTQ1OWQtYjU2ZS0wM2FkZTk2OWIwODYiLCJqdGkiOiJDWlciLCJhdWQiOiJjaGVuZ3p3IiwiZXhwIjoxNjIwMDE0NjAzLCJuYmYiOjE2MjAwMTIwMTF9.ZKX5Z3Acajg57MUQJZqFPWVpPbAGBIDiGigm4FgwmqM
然後在請求 Header 中帶上 token 就可以成功通路了:
❯ curl http://localhost:8080/user/hello -H "Authorization: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE2MjAwMTIwMTEsInN1YiI6IntuYW1lPWNoZW5nencsIGFnZT0yMX0iLCJpc3MiOiIwOGIxMDFjNC1hMmFjLTQ1OWQtYjU2ZS0wM2FkZTk2OWIwODYiLCJqdGkiOiJDWlciLCJhdWQiOiJjaGVuZ3p3IiwiZXhwIjoxNjIwMDE0NjAzLCJuYmYiOjE2MjAwMTIwMTF9.ZKX5Z3Acajg57MUQJZqFPWVpPbAGBIDiGigm4FgwmqM"
#傳回結果
hello