認證授權的設計與實現

狼爺發表於2021-05-02

一、前言

每個網站,必有一個登入認證授權模組,常見的登入授權方式有哪些呢?又該如何實現呢?下面我們將來講解SSO、OAuth等相關知識,並在實踐中的應用姿勢。

二、認證 (authentication) 和授權 (authorization)

這兩個術語通常在安全性方面相互結合使用,尤其是在獲得對系統的訪問許可權時。兩者都是非常重要的主題,通常與網路相關聯,作為其服務基礎架構的關鍵部分。然而,這兩個術語在完全不同的概念上是非常不同的。雖然它們通常使用相同的工具在相同的上下文中使用,但它們彼此完全不同。

身份驗證意味著確認您自己的身份,而授權意味著授予對系統的訪問許可權。簡單來說,身份驗證是驗證您的身份的過程,而授權是驗證您有權訪問的過程。

authentication 證明你是你,authorization 證明你有這個許可權。身份驗證是授權的第一步,因此始終是第一步。授權在成功驗證後完成。

例子:你要登陸論壇,輸入使用者名稱張三,密碼1234,密碼正確,證明你張三確實是張三,這就是 authentication;再一check使用者張三是個版主,所以有許可權加精刪別人帖,這就是 authorization。

三、單點登入(SSO)

單點登入(Single Sign On),簡稱為 SSO,是比較流行的企業業務整合的解決方案之一。SSO的定義是在多個應用系統中,使用者只需要登入一次就可以訪問所有相互信任的應用系統。

舉例來說,QQ音樂和騰訊新聞是騰訊公司旗下的兩個不同的應用系統,如果使用者在騰訊新聞登入過之後,當他訪問QQ音樂時無需再次登入,那麼就說明QQ音樂和騰訊新聞之間實現了單點登入。

3.1 父域Cookie

最簡單是實現方式是,將 Cookie 的 domain 屬性設定為當前域的父域,那麼就認為它是父域 Cookie。Cookie 有一個特點,即父域中的 Cookie 被子域所共享,換言之,子域會自動繼承父域中的 Cookie。

  • 系統1:a.zxy.com
  • 系統2:b.zxy.com
  • 登入系統:login.zxy.com
sequenceDiagram 系統1->>系統1:已登入狀態,登入cookie在zxy.com域 系統2->>系統2:需要登入 系統2->>登入系統:登入(攜帶登入cookie資訊) 登入系統->>登入系統:登入驗證 登入系統-->>系統2:登入成功 系統2->>系統2:訪問資源

3.2 CAS

還有一種方式,那就是CAS(Central Authentication Service)(中心認證服務) 。可參考OAuth2.0,應用系統檢查當前請求有沒有 Ticket,如果沒有,說明使用者在當前系統中尚未登入,那麼就將頁面跳轉至認證中心。由於這個操作會將認證中心的 Cookie 自動帶過去,因此,認證中心能夠根據 Cookie 知道使用者是否已經登入過了。如果認證中心發現使用者尚未登入,則返回登入頁面,等待使用者登入,如果發現使用者已經登入過了,就不會讓使用者再次登入了,而是會跳轉回目標 URL ,並在跳轉前生成一個 Ticket,拼接在目標 URL 的後面,回傳給目標應用系統。

CAS 流程圖

四、OAuth

4.1 四種方式

OAuth 2.0定義了四種授權方式。

  • 授權碼模式(authorization code)
  • 簡化模式(implicit)
  • 密碼模式(resource owner password credentials)
  • 客戶端模式(client credentials)

4.1.1 授權碼模式

授權碼模式(authorization code)是功能最完整、流程最嚴密的授權模式。它的特點就是通過客戶端的後臺伺服器,與“服務提供商”的認證伺服器進行互動。

sequenceDiagram Resource Owner->>Client: 1. 使用者訪問客戶端 Client->>User Agent: 2. 客戶端將使用者導向認證伺服器 User Agent->>Authorization Server: 3. response_type=code&client_id={客戶端的ID}&redirect_uri={重定向URI}&scope={許可權範圍}&state={state} User Agent->>Resource Owner: 4. 使用者選擇是否給予客戶端授權 User Agent->>Authorization Server: 5. 使用者給予授權 Authorization Server-->>User Agent: 6. 重定向URL?code={code}&state={state} User Agent-->>Client: 7. 重定向URL?code={code}&state={state} Client->>Authorization Server: 8. grant_type=authorization_code&client_id={client_id}&code={code}&state={state}&redirect_uri={redirect_uri} Authorization Server-->>Client: 9. expires_in access_token refresh_token scope

4.1.2 簡化模式

簡化模式(implicit grant type)不通過第三方應用程式的伺服器,直接在瀏覽器中向認證伺服器申請令牌,跳過了"授權碼"這個步驟,因此得名。所有步驟在瀏覽器中完成,令牌對訪問者是可見的,且客戶端不需要認證。

sequenceDiagram Resource Owner->>Client: 1. 使用者訪問客戶端 Client->>User Agent: 2. 客戶端將使用者導向認證伺服器 User Agent->>Authorization Server: 3. authorize?response_type=token&client_id={客戶端的ID}&redirect_uri={重定向URI}&scope={許可權範圍}&state={state} User Agent->>Resource Owner: 4. 使用者選擇是否給予客戶端授權 User Agent->>Authorization Server: 5. 使用者給予授權 Authorization Server-->>User Agent: 6. expires_in access_token refresh_token scope state,並在URI的Hash部分包含了訪問令牌 User Agent->>WebHosted Client Resource: 7. 瀏覽器向資源伺服器發出請求 WebHosted Client Resource-->>User Agent: 8. 返回可以從Hash值中獲取令牌的程式碼指令碼 User Agent->>User Agent: 9. 根據指令碼提取令牌 User Agent->>Client: 10. access_token

4.1.3 密碼模式

密碼模式(Resource Owner Password Credentials Grant)中,使用者向客戶端提供自己的使用者名稱和密碼。客戶端使用這些資訊,向"服務商提供商"索要授權。

sequenceDiagram Resource Owner->>Client: 1. 使用者名稱和密碼 Client->>Authorization Server: 2. grant_type=password&username={username}&password={password}&scope={許可權範圍} Authorization Server-->>Client: 3. expires_in access_token refresh_token

4.1.4 客戶端模式

客戶端模式(Client Credentials Grant)指客戶端以自己的名義,而不是以使用者的名義,向"服務提供商"進行認證。嚴格地說,客戶端模式並不屬於OAuth框架所要解決的問題。在這種模式中,使用者直接向客戶端註冊,客戶端以自己的名義要求"服務提供商"提供服務,其實不存在授權問題。

sequenceDiagram Client->>Authorization Server: 1. grant_type=client_credentials&scope={許可權範圍} Authorization Server-->>Client: 2. expires_in access_token refresh_token

4.2 更新令牌

如果使用者訪問的時候,客戶端的"訪問令牌"已經過期,則需要使用"更新令牌"申請一個新的訪問令牌。

sequenceDiagram Client->>Authorization Server: 1. grant_type=refresh_token&refresh_token={refresh_token} Authorization Server-->>Client: 2. expires_in access_token refresh_token

4.3 微信小程式登入的例子

小程式可以通過微信官方提供的登入能力方便地獲取微信提供的使用者身份標識,快速建立小程式內的使用者體系。

使用的是OAuth2.0中的授權碼模式。呼叫 wx.login() 獲取 臨時登入憑證code ,並回傳到開發者伺服器。呼叫 auth.code2Session 介面,換取 使用者唯一標識 OpenID 、 使用者在微信開放平臺帳號下的唯一標識UnionID(若當前小程式已繫結到微信開放平臺帳號) 和 會話金鑰 session_key。之後開發者伺服器可以根據使用者標識來生成自定義登入態,用於後續業務邏輯中前後端互動時識別使用者身份。

微信小程式登入

五、JWT

JSON Web Token (JWT)是一個開放標準(RFC 7519),它定義了一種緊湊的、自包含的方式,用於作為JSON物件在各方之間安全地傳輸資訊。該資訊可以被驗證和信任,因為它是數字簽名的。

JWT的最常見場景,一旦使用者登入,後續每個請求都將包含JWT,允許使用者訪問該令牌允許的路由、服務和資源。單點登入是現在廣泛使用的JWT的一個特性,因為它的開銷很小,並且可以輕鬆地跨域使用。

JWT由三部分組成,它們之間用圓點“.”連線。這三部分分別是:Header、Payload、Signature。因此,一個典型的JWT看起來是這個樣子的:“xxx.yyy.zzz”

JWT的第一部分Header典型的由兩部分組成:型別(“JWT”)和演算法名稱(比如:HMAC SHA256或者RSA等等)。

{
  "alg": "HS256",
  "typ": "JWT"
}

JWT的第二部分Payload,也就是我們資料的存放地方,特別注意不要在裡面存放敏感資訊。它包含宣告,宣告是關於實體(通常是使用者)和其他資料的宣告。宣告有三種型別: registered, public 和 private。

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}

JWT的第三部分Signature,為了得到簽名部分,你必須有編碼過的header、編碼過的payload、一個祕鑰,簽名演算法是header中指定的那個,然後對它們簽名即可。

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret)

Java實現

<dependency>
	<groupId>io.jsonwebtoken</groupId>
	<artifactId>jjwt</artifactId>
	<version>0.9.1</version>
</dependency>
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.crypto.spec.SecretKeySpec;
import javax.xml.bind.DatatypeConverter;
import java.security.Key;
import java.util.Date;

public class Test {
	private static final Logger logger = LoggerFactory.getLogger(Test.class);
	private static String secret = "zhongxy@123456";
	private static ObjectMapper objectMapper = new ObjectMapper();

	public static void main(String[] args) throws Exception {
		UserInfo userInfo = new UserInfo(); // 自定義的登入物件
		userInfo.setId(6);
		userInfo.setName("測試");
		logger.info("UserInfo:" + objectMapper.writeValueAsString(userInfo));

		String token = generateToken(userInfo, 60 * 1000);
		logger.info("token:" + token);

		Object result = check(token);
		logger.info("check:" + objectMapper.writeValueAsString(result));
	}

	// 生成token
	public static String generateToken(UserInfo userInfo, long ttlSecs) {
		//The JWT signature algorithm we will be using to sign the token
		SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

		long nowMillis = System.currentTimeMillis();
		Date now = new Date(nowMillis);

		//We will sign our JWT with our ApiKey secret
		byte[] apiKeySecretBytes = DatatypeConverter.parseBase64Binary(secret);
		Key signingKey = new SecretKeySpec(apiKeySecretBytes, signatureAlgorithm.getJcaName());

		//Let's set the JWT Claims
		JwtBuilder builder = null;
		try {
			builder = Jwts.builder()
                    .setIssuedAt(now)
                    .setIssuer(objectMapper.writeValueAsString(userInfo))
                    .signWith(signatureAlgorithm, signingKey);
		} catch (JsonProcessingException e) {
			e.printStackTrace();
			return null;
		}

		//if it has been specified, let's add the expiration
		if (ttlSecs >= 0) {
			long expMillis = nowMillis + ttlSecs * 1000;
			Date exp = new Date(expMillis);
			builder.setExpiration(exp);
		}

		//Builds the JWT and serializes it to a compact, URL-safe string
		return builder.compact();
	}

	// 從token中反向解析出UserInfo
	public static UserInfo check(String token) {
		try {
			//This line will throw an exception if it is not a signed JWS (as expected)
			Claims claims = Jwts.parser()
					.setSigningKey(DatatypeConverter.parseBase64Binary(secret))
					.parseClaimsJws(token).getBody();
			String userInfoStr = claims.getIssuer();
			return objectMapper.readValue(userInfoStr, UserInfo.class);
		} catch (Exception e) {
			e.printStackTrace();
		}
		return null;
	}

}

六、參考資料

相關文章