認證授權的設計與實現

一、前言

每個網站,必有一個登錄認證授權模塊,常見的登錄授權方式有哪些呢?又該如何實現呢?下面我們將來講解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;
	}

}

六、參考資料