OAuth way to explore 2023-05-04T09:08:45Z Copyright © 2010-2018, V2EX 授权码 + PKCE 模式| OIDC & OAuth2.0 认证协议最佳实践系列 [03] tag:www.v2ex.com,2023-05-04:/t/937303 2023-05-04T09:10:45Z 2023-05-04T09:08:45Z Authing member/Authing 图片 在上一篇文章中,我们介绍了 OIDC 授权码模式,本次我们将重点围绕 授权码 + PKCE 模式( Authorization Code With PKCE )进行介绍 ,从而让你的系统快速具备接入用户认证的标准体系。OIDC & OAuth2.0 认证协议最佳实践系列 02 - 授权码模式( Authorization Code )接入 Authing 图片

为什么会有 PKCE 模式:

PKCE 是 Proof Key for Code Exchange 的缩写,PKCE 是一种用于增强授权码模式安全性的方法,它可以防止恶意应用程序通过截获授权码和重定向 URI 来获得访问令牌。PKCE 通过将随机字符串( code_verifier )和其 SHA-256 哈希值( code_challenge )与授权请求一起发送,确保访问令牌只能由具有相应 code_verifier 的应用程序使用,保障用户的安全性。

[ OAuth 2.0 协议扩展] PKCE 扩展协议:为了解决公开客户端的授权安全问题

「面向对象」 public 客户端,其本身没有能力保存密钥信息(恶意攻击者可以通过反编译等手段查看到客户端的密钥 client_secret , 也就可以通过授权码 code 换取 access_token , 到这一步,恶意应用就可以拿着 token 请求资源服务器了)

「原理」 PKCE 协议本身是对 OAuth 2.0 的扩展, 它和之前的授权码流程大体上是一致的, 区别在于在向授权服务器的 authorize endpoint 请求时,需要额外的 code_challenge 和 code_challenge_method 参数;向 token endpoint 请求时, 需要额外的 code_verifier 参数。最后授权服务器会对这三个参数进行对比验证, 通过后颁发令牌。

01.授权码 + PKCE 模式( Authorization Code With PKCE )

如果你的应用是一个 SPA 前端应用或移动端 App ,建议使用授权码 + PKCE 模式来完成用户的认证和授权。授权码 + PKCE 模式适合不能安全存储密钥的场景(例如前端浏览器)

我们解释下 code_verifier 和 code_challenge 对于每一个 OAuth/OIDC 请求,客户端会先创建一个代码验证器 code_verifier

code_verifier:在 [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~" 范围内,生成 43-128 位的随机字符串。

code_challenge:则是对 code_verifier 通过 code_challenge_method 例如 sha256 转换得来的。

用大白话讲下就是在认证是用户携带的是加密后的 code_challenge ,在用户认证成功通过 code 获取 Token 时,客户端证明自己的方式则是把 code_verifier 原文发送,认证中心收到获取 Token 请求时通过 code_verifier + code_challenge_method 进行转换,发现最终结果与 code_challenge 匹配则返回 Token ,否则拒绝。

1.1 整体流程

整体上,有以下流程:

1.用户点击登录。 2.在你的应用中,生成 code_verifier 和 code_challenge 。 3.拼接登录链接(包含 code_challenge ) 跳转到 Authing 请求认证。 4.Authing 发现用户没有登录,重定向到认证页面,要求用户完成认证。 5.用户在浏览器完成认证。 6.Authing 服务器通过浏览器通过重定向将授权码( code )发送到你的应用前端。 7.你的应用将授权码 (code) 和 code_verifier 发送到 Authing 请求获取 Token. 8.Authing 校验 code 、code_verifier 和 code_challenge 。 9.校验通过,Authing 则返回 AccessToken 和 IdToken 以及可选的 RefreshToken 。 10.你的应用现在知道了用户的身份,后续使用 AccessToken 换取用户信息,调用资源方的 API 等

图片

1.2 准备接入

1.2.1 整体流程

需要先在 Authing 创建应用: 图片 配置登录回调和登出回调,配置为你实际项目的地址,我们在这里配置 localhost 用于测试。 若你想匹配多个登录 /登出回调 可以使用 ‘*’ 号进行通配,登录 /登出回调可以是如下格式 图片

图片 在协议配置中,我们勾选 authorization_code 并且使用 code 作为返回类型,如下图所示:PKCE 模式使用的是 code_verifier 来换取 Token ,所以需要配置获取 Token 的方式为 null 图片

1.3 接入测试

1.3.1 所需调用接口列表

GET${host}/oidc/auth 发起登录(拼接你的发起登录地址) POST${host}/oidc/token 获取 TokenGET${host}/oidc/me 获取用户信息 POST${host}/oidc/token/introspection 校验 TokenPOST${host}/oidc/token 刷新 TokenPOST${host}/oidc/revocation 吊销 TokenGET${host}/session/end 登出 

1.3.2 Run in Postman 所需调用接口列表

https://app.getpostman.com/run-collection/24730905-5d29e488-719e-4ffe-af21-a7c18298d328?action=collection%2Ffork&collection-url=entityId%3D24730905-5d29e488-719e-4ffe-af21-a7c18298d328%26entityType%3Dcollection%26workspaceId%3D13ff793c-024c-459d-b1f6-87e91c4769ed#env%5BAuthing%20OIDC%5D=W3sia2V5IjoiaG9zdCIsInZhbHVlIjoiaHR0cHM6Ly9kZWVwbGFuZy5hdXRoaW5nLmNuIiwiZW5hYmxlZCI6dHJ1ZSwidHlwZSI6ImRlZmF1bHQifSx7ImtleSI6ImNsaWVudF9pZCIsInZhbHVlIjoiNjM4MmNmNDg2ZTVhNjk0NDNhZjI5NzFiIiwiZW5hYmxlZCI6dHJ1ZSwidHlwZSI6ImRlZmF1bHQifSx7ImtleSI6ImNsaWVudF9zZWNyZXQiLCJ2YWx1ZSI6Ijc3NWMyM2NlMjkwYzkwZDQwNDUxNGU3MDgyMDkzZWIzIiwiZW5hYmxlZCI6dHJ1ZSwidHlwZSI6ImRlZmF1bHQifSx7ImtleSI6ImFjY2Vzc190b2tlbiIsInZhbHVlIjoiIiwiZW5hYmxlZCI6dHJ1ZSwidHlwZSI6ImRlZmF1bHQifSx7ImtleSI6ImlkX3Rva2VuIiwidmFsdWUiOiIiLCJlbmFibGVkIjp0cnVlLCJ0eXBlIjoiZGVmYXVsdCJ9LHsia2V5IjoicmVmcmVzaF90b2tlbiIsInZhbHVlIjoiIiwiZW5hYmxlZCI6dHJ1ZSwidHlwZSI6ImRlZmF1bHQifV0=

1.3.3 发起登录

GET${host}/oidc/auth

这是基于浏览器的 OIDC 的起点,请求对用户进行身份验证,并会在验证成功后返回授权码到您所指定的 redirect_uri 。

生成 code_challenge 和 code_verifier

在线生成 https://tonyxu-io.github.io/pkce-generator/

离线生成 首先,我们要生成一个 code_challenge 和 code_verifier,以下是使用 Javascript 语言生成 PKCE 所需要的 code_verifier 和 code_challenge 的脚本:

// 生成随机字符串 function generateRandomString(length) { var result = ''; var characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; var charactersLength = characters.length; for (var i = 0; i < length; i++) { result += characters.charAt(Math.floor(Math.random() * charactersLength)); } return result; } // 生成 code_verifier var codeVerifier = generateRandomString(128); // 对 code_verifier 进行 SHA-256 编码,并将其转换为 base64url 格式的 code_challenge var sha256 = new jsSHA("SHA-256", "TEXT"); sha256.update(codeVerifier); var codeChallenge = btoa(String.fromCharCode.apply(null, new Uint8Array(sha256.getHash("ARRAYBUFFER")))) .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); // 将 code_verifier 和 code_challenge 用对象形式返回 var pkce = { codeVerifier: codeVerifier, codeChallenge: codeChallenge }; 

以上代码使用 jsSHA 库计算 SHA-256 哈希值,使用 base64url 编码将哈希值转换为 code_challenge 。你可以将以上代码复制到你的 Javascript 代码中,并使用 pkce.codeVerifier 和 pkce.codeChallenge 调用 OAuth 2.0 授权请求。

举例

code_verifier:4aHg5fN1AGdbnBAfVKMf9ZMK4PUOBTwQSKKk9V8wYXOFYDZklMl7dzDUhnQi4sYhzGb6PWCkNQqLP70K1DNOneEDq8iyASepAdGjGBBmCs4BGCDDJNwLrGpnJEfmrI66 

code_verifier 的长度为 43 ~ 128 ,我们生成的是 128 位

code_challenge:OhMk95M9qWkKd06--utVtRzQh8Y0Qtqo4cPqqzMJyMw 

发起登录地址(浏览器中打开) https://{host}/oidc/auth?scope=openid+profile+offline_access+username+email+phone&redirect_uri=http://localhost:8080/&response_type=code&prompt=consent&nOnce=6e187def-1a19-4067-8875-653f024d5a9f&client_id={client_id}&state=1676881862&code_challenge={code_challenge}&code_challenge_method=S256

体验地址 https://oidc-authorizationcode-withpkce.authing.cn/oidc/auth?scope=openid+profile+offline_access+username+email+phone&redirect_uri=http://localhost:8080/&response_type=code&prompt=consent&nOnce=6e187def-1a19-4067-8875-653f024d5a9f&client_id=63f30f5bf629268cc27d93c6&state=1676881862&code_challenge=OhMk95M9qWkKd06--utVtRzQh8Y0Qtqo4cPqqzMJyMw&code_challenge_method=S256

参数说明 图片

1.3.4 获取 Token

POST${host}/oidc/token 用户在 Authing 侧完成登录操作后,Authing 会将生成的 code 作为参数回调到 redirect_uri 地址,此时通过 code 换 token 接口即可拿到对应的访问令牌 access_token

请求参数 图片 请求示例

curl --location --request POST 'https://{host}/oidc/token' \ --header 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode 'client_id={应用 ID}' \ --data-urlencode 'client_secret={应用密钥}' \ --data-urlencode 'grant_type=authorization_code' \ --data-urlencode 'redirect_uri={发起登录时指定的 redirect_uri}' \ --data-urlencode 'code={/oidc/auth 返回的 code}' \ --data-urlencode 'code_verifier={code_verifier}' 

响应示例(成功)

{ "scope": "openid username email phone offline_access profile", "token_type": "Bearer", "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImVtSzBGbVRIa0xlQWFjeS1YWEpVT3J6SzkxV243TkdoNGFlYUVlSjVQOUUifQ.eyJzdWIiOiI2M2ViNTNjNDQxYTVjMmYwNWYyNGJiMDMiLCJhdWQiOiI2M2ViNDU4NTE1NmQ5NzcxMDFkZDM3NTAiLCJzY29wZSI6Im9wZW5pZCB1c2VybmFtZSBlbWFpbCBwaG9uZSBvZmZsaW5lX2FjY2VzcyBwcm9maWxlIiwiaWF0IjoxNjc2MzY2OTE0LCJleHAiOjE2Nzc1NzY1MTQsImp0aSI6ImVmVU04enNrbl92LXYzeXZfbDVHRV9fQ2JEY0NNZDhEVDFnYVI0bHRqcHAiLCJpc3MiOiJodHRwczovL29pZGMtYXV0aG9yaXphdGlvbi1jb2RlLmF1dGhpbmcuY24vb2lkYyJ9.E3gAYzCQbJmrtM5zl91OPHm2YPnDxzRejw75oVMF1tLqCS0trj6CSBxyxP3Z9t6Eb_oAu1f_3I6XC3KC-l0DTM6q7_R2rnW4LWlik2rDCLuGpG0FqFScLZhwafmrPsVn93yaBQfEEoaLviqKhj3DgOymKqHZzFG3taaz2k_pWsxt4z97DtKjRTiqyMvcSfHsVrjSKELaC-5S_PHPWcQ70iX85IwUb6i5ldZGxYmODCvChNC9p4D4IOT3atvyEHgBTmjA9ZKI-T7hCVHSO91WZY3l1p4iWdi6KdP1oMGTy8WbmUHG9SiWO1Efh_9I5ZpRzVNWXINLv-lZ0d2aZKjg2w", "expires_in": 1209600, "id_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI2M2ViNTNjNDQxYTVjMmYwNWYyNGJiMDMiLCJhdWQiOiI2M2ViNDU4NTE1NmQ5NzcxMDFkZDM3NTAiLCJpYXQiOjE2NzYzNjY5MTQsImV4cCI6MTY3NzU3NjUxNCwiaXNzIjoiaHR0cHM6Ly9vaWRjLWF1dGhvcml6YXRpb24tY29kZS5hdXRoaW5nLmNuL29pZGMiLCJub25jZSI6IjhiYjg3MjdhLWU1MGUtNDUzOC05ZmZmLWZhOTFlNWQ0Y2MwYSIsIm5hbWUiOm51bGwsImdpdmVuX25hbWUiOm51bGwsIm1pZGRsZV9uYW1lIjpudWxsLCJmYW1pbHlfbmFtZSI6bnVsbCwibmlja25hbWUiOm51bGwsInByZWZlcnJlZF91c2VybmFtZSI6bnVsbCwicHJvZmlsZSI6bnVsbCwicGljdHVyZSI6Imh0dHBzOi8vZmlsZXMuYXV0aGluZy5jby9hdXRoaW5nLWNvbnNvbGUvZGVmYXVsdC11c2VyLWF2YXRhci5wbmciLCJ3ZWJzaXRlIjpudWxsLCJiaXJ0aGRhdGUiOm51bGwsImdlbmRlciI6IlUiLCJ6b25laW5mbyI6bnVsbCwibG9jYWxlIjpudWxsLCJ1cGRhdGVkX2F0IjoiMjAyMy0wMi0xNFQwOToyNjoyOC4wNjhaIiwiZW1haWwiOm51bGwsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwicGhvbmVfbnVtYmVyIjoiMTg1MTY4Mjk5OTUiLCJwaG9uZV9udW1iZXJfdmVyaWZpZWQiOnRydWUsInVzZXJuYW1lIjpudWxsfQ.GweoWBCEyHQGP6G9ohbfBMUMALlbZMM9hRAes1De7BM", "refresh_token": "KanvCEmonS_FgCRdFftOCwka2f8Qjj4tcsIfJF-VC1W" } 

响应示例(失败)

{ "error": "invalid_grant", "error_description": "授权码无效或已过期" } 

1.3.5 所需调用接口列表

GET${host}/oidc/me 获取用户信息 此端点是 OIDC 获取用户端点,可以通过 AccessToken 获取有关当前登录用户的信息。

请求参数 图片 请求示例

curl --location --request GET 'https://{host}/oidc/me?access_token={access_token}' 

响应示例(成功)

{ "name": null, "given_name": null, "middle_name": null, "family_name": null, "nickname": null, "preferred_username": null, "profile": null, "picture": "https://files.authing.co/authing-console/default-user-avatar.png", "website": null, "birthdate": null, "gender": "U", "zoneinfo": null, "locale": null, "updated_at": "2023-02-14T09:26:28.068Z", "email": "xxx@authing.cn", "email_verified": true, "phone_number": "185xxxx9995", "phone_number_verified": true, "username": "neo", "sub": "63eb53c441a5c2f05f24bb03" } 

响应示例(失败)

{ "error": "invalid_grant", "error_description": "Access Token 无效" } 

1.3.6 校验 Token

POST${host}/oidc/auth 此端点接受 access_token 、id_token 、refresh_token ,并返回一个布尔值,指示它是否处于活动状态。如果令牌处于活动状态,还将返回有关令牌的其他数据。如果 token 无效、过期或被吊销,则认为它处于非活动状态。

access_token 可以使用 RS256 签名算法或 HS256 签名算法进行签名。下面是这两种签名算法的区别:

RS256 是使用 RSA 算法的一种数字签名算法,它使用公钥 /私钥对来加密和验证信息。 RS256 签名生成的令牌比 HS256 签名生成的令牌更加安全,因为使用 RSA 密钥对进行签名可以提供更高的保护级别。使用 RS256 签名算法的令牌可以使用公钥进行验证,公钥可以通过 JWK 端点获取。

HS256 是使用对称密钥的一种数字签名算法。它使用同一个密钥进行签名和验证。 HS256 签名算法在性能方面比 RS256 签名算法更快,因为它使用的是对称密钥,而不是使用 RSA 公钥 /私钥对来签名和验证。使用 HS256 签名算法的令牌可以通过 shared secret (应用密钥)进行验证。

在实际应用中,RS256 算法更加安全,但同时也更加消耗资源,如果系统需要高性能,可以选择 HS256 签名算法。

验证 Token 分为两种方式

本地验证与使用 Authing 在线验证。我们建议在本地验证 JWT Token ,因为可以节省你的服务器带宽并加快验证速度。你也可以选择将 Token 发送到 Authing 的验证接口由 Authing 进行验证并返回结果,但这样会造成网络延迟,而且在网络拥塞时可能会有慢速请求。

以下是本地验证和在线验证的优劣对比: 图片

在线校验

需要注意的是,id_token 目前无法在线校验,因为 id_token 只是一个标识,若需要校验 id_token 则需要您在离线自行校验

请求参数 图片

请求示例

curl --location --request POST 'https://{host}/oidc/token/introspection' \ --header 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode 'client_id={应用 ID}' \ --data-urlencode 'client_secret={应用密钥}' \ --data-urlencode 'token={ token }' \ --data-urlencode 'token_type_hint={token_type_hint}' 

校验 access_token 响应示例(校验通过)

{ "active": true, "sub": "63eb53c441a5c2f05f24bb03", "client_id": "63eb4585156d977101dd3750", "exp": 1677648467, "iat": 1676438867, "iss": "https://oidc-authorization-code.authing.cn/oidc", "jti": "ObgavGBUocr1wsrUvtDLHmuFSgoebxsiOY4JNRqIhaQ", "scope": "offline_access username profile openid phone email", "token_type": "Bearer" } 

校验 access_token 响应示例(校验未通过)

{ "active": false } 

校验 refresh_token 响应示例 (校验通过)

{ "active": true, "sub": "63eb53c441a5c2f05f24bb03", "client_id": "63eb4585156d977101dd3750", "exp": 1679030867, "iat": 1676438867, "iss": "https://oidc-authorization-code.authing.cn/oidc", "jti": "6F2TO1v1YZ1_N7I3jXYHjK-vZzKtlD0IiP5KPoUFUCQ", "scope": "offline_access username profile openid phone email" } 

校验 refresh_token 响应示例(校验未通过)

{ "active": false } 

离线校验 可参考文档( Authing 开发者文档): https://docs.authing.cn/v2/guides/faqs/how-to-validate-user-token.html#%E6%9C%AC%E5%9C%B0%E9%AA%8C%E8%AF%81

我们简单说下,若您使用离线校验应该对 token 进行如下规则的校验 1.格式校验 - 校验 token 格式是否是 JWT 格式 2.类型校验 - 校验 token 是否是目标 token 类型,比如 access_token 、id_token 、refresh_token 3.issuer 校验 - 校验 token 是否为信赖的 issuer 颁发 4.签名校验 - 校验 token 签名是否由 issuer 签发,防止伪造 5.有效期校验 - 校验 token 是否在有效期内 6.claims 校验 - 是否符合与预期的一致

示例代码 下面是一个示例 Java 代码,可以用于在本地校验 OIDC RS256 和 HS256 签发的 access_token

import com.nimbusds.jose.JWSObject; import com.nimbusds.jwt.JWTClaimsSet; import com.nimbusds.jwt.SignedJWT; import java.net.URL; import java.text.ParseException; import java.util.Date; public class OIDCValidator { private static final String ISSUER = "https://your-issuer.com"; private static final String AUDIENCE = "your-client-id"; private final URL jwkUrl; public OIDCValidator(final URL jwkUrl) { this.jwkUrl = jwkUrl; } public JWTClaimsSet validateToken(final String accessToken) throws ParseException { final SignedJWT signedJWT = SignedJWT.parse(accessToken); if (signedJWT == null) { throw new RuntimeException("Access token is null or empty"); } final JWTClaimsSet claims = signedJWT.getJWTClaimsSet(); if (claims == null) { throw new RuntimeException("No claims present in the access token"); } if (!claims.getIssuer().equals(ISSUER)) { throw new RuntimeException("Invalid issuer in access token"); } if (!claims.getAudience().contains(AUDIENCE)) { throw new RuntimeException("Invalid audience in access token"); } final JWSObject jwsObject = signedJWT.getJWSObject(); if (jwsObject == null) { throw new RuntimeException("No JWS object found in the access token"); } // Fetch the JWKs from the JWK set URL final JWKSet jwkSet = JWKSet.load(jwkUrl); final JWK jwk = jwkSet.getKeyByKeyId(jwsObject.getHeader().getKeyID()); if (jwk == null) { throw new RuntimeException("No JWK found for the access token"); } if (!jwsObject.verify(jwk.getKey())) { throw new RuntimeException("Invalid signature in access token"); } if (claims.getExpirationTime() == null || claims.getExpirationTime().before(new Date())) { throw new RuntimeException("Expired access token"); } return claims; } } 

这段代码使用 Nimbus JOSE+JWT 库来解析和验证 JWT token 。它使用指定的 issuer 和 audience 值对 access_token 进行验证,并验证 JWT 中 claims 的格式、类型、签名、有效期和 issuer 。如果发生任何验证错误,则将抛出 RuntimeException 。使用时需要传入对应的 JWK URL 和 access_token 进行调用,例如:

final URL jwkUrl = new URL("https://your-issuer.com/.well-known/jwks.json"); final OIDCValidator validator = new OIDCValidator(jwkUrl); final String accessToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE1MTYyMzkwMjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; final JWTClaimsSet claims = validator.validateToken(accessToken); 

这个示例只校验了 RS256 和 HS256 签名算法。

1.3.7 刷新 Token

POST${host}/oidc/token 此功能用于用户 token 的刷新操作,在 token 获取阶段需要先获取到 refresh_token 。

请求参数 图片 请求参数

curl --location --request POST 'https://{host}/oidc/token' \ --header 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode 'client_id={应用 ID}' \ --data-urlencode 'client_secret={应用密钥}' \ --data-urlencode 'refresh_token={刷新令牌}' \ --data-urlencode 'grant_type=refresh_token' 

响应示例(成功)

{ "refresh_token": "6F2TO1v1YZ1_N7I3jXYHjK-vZzKtlD0IiP5KPoUFUCQ", "scope": "offline_access username profile openid phone email", "token_type": "Bearer", "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImVtSzBGbVRIa0xlQWFjeS1YWEpVT3J6SzkxV243TkdoNGFlYUVlSjVQOUUifQ.eyJzdWIiOiI2M2ViNTNjNDQxYTVjMmYwNWYyNGJiMDMiLCJhdWQiOiI2M2ViNDU4NTE1NmQ5NzcxMDFkZDM3NTAiLCJzY29wZSI6Im9mZmxpbmVfYWNjZXNzIHVzZXJuYW1lIHByb2ZpbGUgb3BlbmlkIHBob25lIGVtYWlsIiwiaWF0IjoxNjc2NDQ0MjY4LCJleHAiOjE2Nzc2NTM4NjgsImp0aSI6IkEtZUlQYkJ5N3lJLTliUmp1RnJHeXNCSXdjbWtOUl9WalpYODB2aU05VFkiLCJpc3MiOiJodHRwczovL29pZGMtYXV0aG9yaXphdGlvbi1jb2RlLmF1dGhpbmcuY24vb2lkYyJ9.Kk3jSK5BSUEDVTQMdMAdG5cBCxZt31vQiD-XYHNA84Gd3Mo8eDLcQpjMEzQ8HJ4_b9IgMOz5ydXz0zAQ6AjLMW3Rl49qhTGDB7Kq7tHTFmDO8itoO2LQTCLPCPtP3TkoOgptlFD_sd32nefH-HojNhuqwKw469Byw3xnW5xEs3wSuOoUdHwR2n9j1T1Zgp3e90xmBjbtbofQE1z0IWtCnrfJ9ujWsKXoN_7OAXbCTa-Ak_DhgLHU7xutQaaBOgD28lLLT5xclgBWfv7Leyx_kBnVGT5Jvo1tfA6AUEp6wJO4GUBzsijLefI04VDzBGypNuFJlw_jOhSp-SWxJjQSwQ", "expires_in": 1209600, "id_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI2M2ViNTNjNDQxYTVjMmYwNWYyNGJiMDMiLCJhdWQiOiI2M2ViNDU4NTE1NmQ5NzcxMDFkZDM3NTAiLCJpYXQiOjE2NzY0NDQyNjgsImV4cCI6MTY3NzY1Mzg2OCwiaXNzIjoiaHR0cHM6Ly9vaWRjLWF1dGhvcml6YXRpb24tY29kZS5hdXRoaW5nLmNuL29pZGMiLCJuYW1lIjpudWxsLCJnaXZlbl9uYW1lIjpudWxsLCJtaWRkbGVfbmFtZSI6bnVsbCwiZmFtaWx5X25hbWUiOm51bGwsIm5pY2tuYW1lIjpudWxsLCJwcmVmZXJyZWRfdXNlcm5hbWUiOm51bGwsInByb2ZpbGUiOm51bGwsInBpY3R1cmUiOiJodHRwczovL2ZpbGVzLmF1dGhpbmcuY28vYXV0aGluZy1jb25zb2xlL2RlZmF1bHQtdXNlci1hdmF0YXIucG5nIiwid2Vic2l0ZSI6bnVsbCwiYmlydGhkYXRlIjpudWxsLCJnZW5kZXIiOiJVIiwiem9uZWluZm8iOm51bGwsImxvY2FsZSI6bnVsbCwidXBkYXRlZF9hdCI6IjIwMjMtMDItMTRUMDk6MjY6MjguMDY4WiIsImVtYWlsIjpudWxsLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsInBob25lX251bWJlciI6IjE4NTE2ODI5OTk1IiwicGhvbmVfbnVtYmVyX3ZlcmlmaWVkIjp0cnVlLCJ1c2VybmFtZSI6bnVsbH0.DGoJrzkgti44zw-MotVM1KpLxbJTzc5pfh-xYun_xDQ" } 

响应示例(失败)

{ "error": "invalid_grant", "error_description": "Refresh Token 无效或已过期" } 

1.3.8 撤回 Token

POST${host}/oidc/auth

撤销 access_token / refresh_token 。

请求参数 图片 请求示例

curl --location --request POST 'https://{host}/oidc/token/revocation' \ --header 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode 'client_id={应用 ID}' \ --data-urlencode 'client_secret={应用密钥}' \ --data-urlencode 'token= {token}' \ --data-urlencode 'token_type_hint={token_type_hint}' 

响应示例(成功)

HTTP 200 OK

响应示例(失败)

{ "error": "xxxx", "error_description": "xxxx" } 

1.3.9 用户登出

GET${host}/oidc/session/end 使用此操作通过删除用户的浏览器会话来注销用户。 post_logout_redirect_uri 可以指定在执行注销后重定向的地址。否则,浏览器将重定向到默认页面 请求参数 图片 请求示例(浏览器访问)

GET https://oidc-authorization-code.authing.cn/oidc/session/end?id_token_hint={id_token}&post_logout_redirect_uri=http://localhost:8080/&state=1676452381 

02.本章总结

本章我们介绍了 OIDC 授权码模式的接入流程以及相关接口的调用方式,对于小白来说可能需要整体跑一遍流程才能熟悉,我们也建议你 fork 我们的 postman collection 跑一遍流程,对 PKCE 模式你就基本掌握啦。 接下来我们还会介绍 OIDC 的授权码+PKCE 流程,以及接入 Authing 的方式,需要你对授权码模式的流程有一定了解哦。

往期精彩内容 什么是事件驱动( EDA )?为什么它是技术领域的主要驱动力? 图片

]]> OIDC & OAuth2.0 协议及其授权模式详解|认证协议最佳实践系列 [1] tag:www.v2ex.com,2023-03-09:/t/922672 2023-03-09T11:01:17Z 2023-03-09T10:59:17Z Authing member/Authing OIDC / OAuth2.0 是一种开放的标准,可以帮助应用程序安全地访问用户的资源,而无需将用户的凭据(如用户名和密码)暴露给应用程序,我们可以通过标准协议,建立集中的用户目录和统一认证中心,将内外部业务系统的登录认证统一到认证中心,实现集中化的管理,从而避免每套业务系统都要搭建一套用户体系所造成的管理侧不便及安全侧的风险。

本文将带各位详细了解 OAuth 2.0 & OIDC 及其授权模式

01 协议介绍

1.1 OAuth 2.0 & OIDC

OAuth 2.0 是一个授权框架,使应用程序能够获得对 HTTP 服务上用户帐户的有限访问权限,例如 Facebook 、GitHub 和 DigitalOcean 。它通过将用户认证委托给托管用户帐户的服务,并授权第三方应用程序访问用户帐户来实现。

OpenID Connect (OIDC) 是建立在 OAuth 2.0 框架之上的简单身份层。它在 OAuth 2.0 提供的授权的基础上添加了认证。

初看上面这段话你可能很难理解,这里用白话解释下,OAuth 2.0 在设计之初,是为了 API 安全的问题,它是一个授权协议,而 OIDC 则在 OAuth2.0 协议的基础上,提供了用户认证、获取用户信息等的标准实现,当然我们也可以理解获取用户信息也是一个 API ,用 OAuth 2.0 也没问题的,考虑到读者的感受,在这里不要过于纠结,只要记住 OIDC 是完全兼容 OAuth2.0 的,我们现在也推荐用 OIDC

1.2 术语介绍

啥啥啥,写的这都是啥,小白看到这些脑袋都大了,我在这里重点说下 OIDC/OAuth2.0 协议交互时所参与的几个角色,等你对协议熟悉了,可以反过头来再看下相关的介绍,在接下来的授权模式介绍中,我们会结合这四个角色,介绍下不同授权模式的流程。

1.3 Client 类型介绍

OAuth2.0 / OIDC 中定义了 2 种 Client 类型:

我们在这里解释下:

Confidential Clients 机密型应用:能够安全的存储凭证( client_secret ),例如有后端服务,你的前端是 Vue ,后台是 Java ,那么可以理解为机密性应用,因为你的后端能够安全的保存 client_secret ,而不会将 client_secret 直接暴露给用户,此时你可以使用授权码模式。

Public Clients 公共型应用:无法安全存储凭证( Client Secrets ),例如 SPA 、移动端、或者完全前后端分离的应用,应当使用授权码 + PKCE 模式

1.4 OIDC 授权模式与选型建议

重点来啦!我们要了解授权模式,才能更好的针对系统类型进行授权模式的选型,避免由于授权模式选型不当所造成的开发工作增加和安全侧的漏洞。

02 授权模式详细介绍

2.1 授权码模式( Authorization Code )

授权码模式适合应用具备后端服务器的场景。授权码模式要求应用必须能够安全存储密钥,用于后续使用授权码换 Access Token 。授权码模式需要通过浏览器与终端用户交互完成认证授权,然后通过浏览器重定向将授权码发送到后端服务,之后进行授权码换 Token 以及 Token 换用户信息。

整体上,有以下流程

  1. 在你的应用中,让用户访问登录链接,浏览器跳转到 Authing ,用户在 Authing 完成认证。
  2. 浏览器接收到一个从 Authing 服务器发来的授权码。
  3. 浏览器通过重定向将授权码发送到你的应用后端。
  4. 你的应用服务将授权码发送到 Authing 获取 AccessToken 和 IdToken ,如果需要,还会返回 refresh token 。
  5. 你的应用后端现在知道了用户的身份,后续可以保存用户信息,重定向到前端其他页面,使用 AccessToken 调用资源方的其他 API 等等。

流程图如下

2.2 授权码 + PKCE 模式( Authorization Code With PKCE )

如果你的应用是一个 SPA 前端应用或移动端 App ,建议使用授权码 + PKCE 模式来完成用户的认证和授权。授权码 + PKCE 模式适合不能安全存储密钥的场景(例如前端浏览器) 。

我们解释下 code_verifier 和 code_challenge 。

code_verifier:在 [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~" 范围内,生成 43-128 位的随机字符串。 code_challenge:则是对 code_verifier 通过 code_challenge_method 例如 sha256 转换得来的。

用大白话讲下就是在认证是用户携带的是加密后的 code_challenge ,在用户认证成功获取 Token 时,客户端证明自己的方式则是把 code_verifier 原文发送,认证中心收到获取 Token 请求时通过 code_verifier + code_challenge_method 进行转换,发现最终结果与 code_challenge 匹配则返回 Token ,否则拒绝。

整体上,有以下流程:

  1. 在你的应用中,让用户访问登录链接(包含 code_challenge ) ,浏览器跳转到 Authing ,用户在 Authing 完成认证。
  2. 浏览器接收到一个从 Authing 服务器发来的授权码。
  3. 浏览器通过重定向将授权码发送到你的应用前端。
  4. 你的应用将授权码和 code_verifier 发送到 Authing 获取 AccessToken 和 IdToken ,如果需要,还会返回 Refresh token 。
  5. 你的应用前端现在知道了用户的身份,后续使用 Access token 换取用户信息,重定向到前端其他页面,使用 AccessToken 调用资源方的其他 API 等等。

流程图如下

2.3 客户端凭证模式( Client Credentials )

Client Credentials 模式用于进行服务器对服务器间的授权( M2M 授权),期间没有用户的参与。你需要创建编程访问账号,并将 AK 、SK 密钥对交给你的资源调用方。

注意:Client Credentials 模式不支持 Refresh Token

整体上,有以下流程

  1. 资源调用方将他的凭证 AK 、SK 以及需要请求的权限 scope 发送到 Authing 授权端点。
  2. 如果凭证正确,并且调用方具备资源权限,Authing 为其颁发 AccessToken 。

流程图如下

2.4 隐式模式( Implicit )(不推荐)

隐式模式适合不能安全存储密钥的场景(例如前端浏览器),不推荐此模式,建议采用其他模式。在隐式模式中,应用不需要使用 code 换 token ,无需请求 /token 端点,AccessToken 和 IdToken 会直接从认证端点返回

注意:因为隐式模式用于不能安全存储密钥的场景,所以隐式模式不支持获取 Refresh Token

整体上,有以下流程

  1. 在你的应用中,让用户访问登录链接,浏览器跳转到 Authing ,用户在 Authing 完成认证。
  2. Authing 将浏览器重定向到你的应用回调地址,AccessToken 和 IdToken 作为 URL hash 传递。
  3. 你的应用从 URL 中取出 token 。
  4. 你的应用可以将 AccessToken 与 IdToken 保存,以便后续使用,例如携带 AccessToken 访问资源服务器,携带 IdToken 请求服务端从而服务端能够辨别用户身份。

流程图如下

2.5 密码模式( Password )(不推荐)

不推荐使用此模式,尽量使用其他模式。只有其他模式都无法解决问题时才会考虑使用密码模式。如果使用密码模式,请确保你的应用代码逻辑非常安全,不会被黑客攻击,否则将会直接泄露用户的账密。一般用于改造集成非常古老的应用,否则绝对不要把它作为你的第一选择。

整体上,有以下流程

  1. 你的应用让用户输入账密信息。
  2. 你的应用将用户账密发送到 Authing 。
  3. 如果账密正确,Authing 返回 token 。

流程图如下

2.6 设备代码模式( Device Code )(几乎用不到)

对于一些连接到互联网的输入受限设备,设备不会直接验证用户身份,而是让用户通过链接或二维码转到手机或电脑上进行认证,从而避免了用户无法轻松输入文本所带来的糟糕体验。

Device Code Flow 这个与前面几个不太一样,开始不再是由资源持有者发起,而是由客户端开始。甚至登录的方法与客户端还没有特别的关联。

大致流程说明如下

  1. 客户端发起向认证服务器取得 device_code 和 user_code 。
  2. 客户端通过二维码或者其他方式将 user_code 交给资源持有者。
  3. 资源持有者透过某个端点 (endpoint) 与 user_code 进行认证。
  4. 客户端通过 device_code 轮训认证服务器是否有人认证,若有人认证则会返回 access_token 。

流程图如下

一个设计良好的系统,都需要一个标准、安全、可扩展的用户认证协议,无论是 ToC 还是 ToE ,接来下我们还会针对具体的授权模式结合实际场景讲一下具体的模式。

本章总结

  1. 本章我们介绍了 OIDC 和 OAuth2.0 两个协议,在使用起来,这两个协议并没有太大的区别,OAuth2.0 是一个授权协议,OIDC 协议则是在 OAuth2.0 的提供的授权的基础上添加了认证。

  2. 授权模式,我们分别介绍了授权码模式( Authorization Code ) 、授权码 + PKCE 模式( Authorization Code With PKCE ) 、客户端凭证模式( Client Credentials ) 、隐式模式( Implicit )(不推荐)、密码模式( Password ) (不推荐)、设备代码模式( Device Code )(几乎用不到) 。

  3. 授权模式选择

]]>
OIDC & OAuth2.0 认证协议最佳实践系列 02 - 授权码模式(Authorization Code)接入 Authing tag:www.v2ex.com,2023-03-03:/t/920916 2023-03-03T11:00:02Z 2023-03-03T10:58:02Z Authing member/Authing 在上一篇文章中,我们整体介绍 OIDC / OAuth2.0 协议,本次我们将重点围绕授权码模式( Authorization Code )以及接入 Authing 进行介绍,从而让你的系统快速具备接入用户认证的标准体系。

接入 Authing 后的优势: 在整个 Authing 的身份源中,已经包含了社会化登录方式 微信、微博、QQ 、FB 、TW ...等等,企业登录方式 飞书、钉钉、企微、AD 等等,只要你完成了接入 Authing 就意味着你的业务系统具备了这些能力。

01 授权码模式( Authorization Code )

1.1 整体流程

整体上,有以下流程

  1. 在你的应用中,让用户访问登录链接,浏览器跳转到 Authing ,用户在 Authing 完成认证。
  2. 浏览器接收到一个从 Authing 服务器发来的授权码。
  3. 浏览器通过重定向将授权码发送到你的应用后端。
  4. 你的应用服务将授权码发送到 Authing 获取 AccessToken 和 IdToken ,如果需要,还会返回 refresh token 。
  5. 你的应用后端现在知道了用户的身份,后续可以保存用户信息,重定向到前端其他页面,使用 AccessToken 调用资源方的其他 API 等等。

流程图如下:

1.2 准备接入

创建应用:

配置登录回调和登出回调,配置为你实际项目的地址,我们在这里配置 localhost 用于测试。

若你想匹配多个登录 /登出回调,可以使用 ‘*’ 号进行通配,登录 /登出回调可以是如下格式:

在协议配置中,我们勾选 authorization_code 并且使用 code 作为返回类型,如下图所示:

1.3 接入测试

GET${host}/oidc/auth 发起登录(拼接你的发起登录地址) POST${host}/oidc/token 获取 Token GET${host}/session/me 获取用户信息 POST${host}/oidc/token/introspection 校验 Token POST${host}/oidc/token 刷新 Token POST${host}/oidc/revocation 吊销 Token GET${host}/session/end 登出 

以下要介绍的接口可以通过我们的在线 postman collection 自行 fork 体验。

GET${host}/oidc/auth 

这是基于浏览器的 OIDC 的起点,请求对用户进行身份验证,并会在验证成功后返回授权码到您所指定的 redirect_uri 。

拼接发起登录地址(浏览器中打开): https://{host}/oidc/auth?scope=openid+profile+email+phone+username&redirect_uri={redirect_uri}&response_type=code&client_id={应用 ID}&state={state}

如您需要额外获取 refresh_token 则请求格式为: https://{host}/oidc/auth? scope=openid+profile+offline_access+username+email+phone&redirect_uri=http://localhost:8080/&response_type=code&client_id={应用 ID}&prompt=consent&state={state}

点此体验https://oidc-authorization-code.authing.cn/oidc/auth?scope=openid+profile+offline_access+username+email+phone&redirect_uri=http://localhost:8080/&response_type=code&prompt=consent&nOnce=054d3c0e-9df9-46f2-a8fa-a7f479032660&client_id=63eb4585156d977101dd3750&state=1676366724

参数说明

POST${host}/oidc/token 

用户在 Authing 侧完成登录操作后,Authing 会将生成的 code 作为参数回调到 redirect_uri 地址,此时通过 code 换 token 接口即可拿到对应的访问令牌 access_token 。

请求参数

请求事例

curl --location --request POST 'https://{host}/oidc/token' \ --header 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode 'client_id={应用 ID}' \ --data-urlencode 'client_secret={应用密钥}' \ --data-urlencode 'grant_type=authorization_code' \ --data-urlencode 'redirect_uri={发起登录时指定的 redirect_uri}' \ --data-urlencode 'code={/oidc/auth 返回的 code}' 

响应示例 (成功)

{ "scope": "openid username email phone offline_access profile", "token_type": "Bearer", "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImVtSzBGbVRIa0xlQWFjeS1YWEpVT3J6SzkxV243TkdoNGFlYUVlSjVQOUUifQ.eyJzdWIiOiI2M2ViNTNjNDQxYTVjMmYwNWYyNGJiMDMiLCJhdWQiOiI2M2ViNDU4NTE1NmQ5NzcxMDFkZDM3NTAiLCJzY29wZSI6Im9wZW5pZCB1c2VybmFtZSBlbWFpbCBwaG9uZSBvZmZsaW5lX2FjY2VzcyBwcm9maWxlIiwiaWF0IjoxNjc2MzY2OTE0LCJleHAiOjE2Nzc1NzY1MTQsImp0aSI6ImVmVU04enNrbl92LXYzeXZfbDVHRV9fQ2JEY0NNZDhEVDFnYVI0bHRqcHAiLCJpc3MiOiJodHRwczovL29pZGMtYXV0aG9yaXphdGlvbi1jb2RlLmF1dGhpbmcuY24vb2lkYyJ9.E3gAYzCQbJmrtM5zl91OPHm2YPnDxzRejw75oVMF1tLqCS0trj6CSBxyxP3Z9t6Eb_oAu1f_3I6XC3KC-l0DTM6q7_R2rnW4LWlik2rDCLuGpG0FqFScLZhwafmrPsVn93yaBQfEEoaLviqKhj3DgOymKqHZzFG3taaz2k_pWsxt4z97DtKjRTiqyMvcSfHsVrjSKELaC-5S_PHPWcQ70iX85IwUb6i5ldZGxYmODCvChNC9p4D4IOT3atvyEHgBTmjA9ZKI-T7hCVHSO91WZY3l1p4iWdi6KdP1oMGTy8WbmUHG9SiWO1Efh_9I5ZpRzVNWXINLv-lZ0d2aZKjg2w", "expires_in": 1209600, "id_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI2M2ViNTNjNDQxYTVjMmYwNWYyNGJiMDMiLCJhdWQiOiI2M2ViNDU4NTE1NmQ5NzcxMDFkZDM3NTAiLCJpYXQiOjE2NzYzNjY5MTQsImV4cCI6MTY3NzU3NjUxNCwiaXNzIjoiaHR0cHM6Ly9vaWRjLWF1dGhvcml6YXRpb24tY29kZS5hdXRoaW5nLmNuL29pZGMiLCJub25jZSI6IjhiYjg3MjdhLWU1MGUtNDUzOC05ZmZmLWZhOTFlNWQ0Y2MwYSIsIm5hbWUiOm51bGwsImdpdmVuX25hbWUiOm51bGwsIm1pZGRsZV9uYW1lIjpudWxsLCJmYW1pbHlfbmFtZSI6bnVsbCwibmlja25hbWUiOm51bGwsInByZWZlcnJlZF91c2VybmFtZSI6bnVsbCwicHJvZmlsZSI6bnVsbCwicGljdHVyZSI6Imh0dHBzOi8vZmlsZXMuYXV0aGluZy5jby9hdXRoaW5nLWNvbnNvbGUvZGVmYXVsdC11c2VyLWF2YXRhci5wbmciLCJ3ZWJzaXRlIjpudWxsLCJiaXJ0aGRhdGUiOm51bGwsImdlbmRlciI6IlUiLCJ6b25laW5mbyI6bnVsbCwibG9jYWxlIjpudWxsLCJ1cGRhdGVkX2F0IjoiMjAyMy0wMi0xNFQwOToyNjoyOC4wNjhaIiwiZW1haWwiOm51bGwsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwicGhvbmVfbnVtYmVyIjoiMTg1MTY4Mjk5OTUiLCJwaG9uZV9udW1iZXJfdmVyaWZpZWQiOnRydWUsInVzZXJuYW1lIjpudWxsfQ.GweoWBCEyHQGP6G9ohbfBMUMALlbZMM9hRAes1De7BM", "refresh_token": "KanvCEmonS_FgCRdFftOCwka2f8Qjj4tcsIfJF-VC1W" } 

响应示例 (失败)

{ "error": "invalid_grant", "error_description": "授权码无效或已过期" } 
GET${host}/session/me 获取用户信息 

此端点是 OIDC 获取用户端点,可以通过 AccessToken 获取有关当前登录用户的信息。

请求参数

请求示例

curl --location --request GET 'https://{host}/oidc/me?access_token={access_token}' 

响应示例 (成功)

{ "name": null, "given_name": null, "middle_name": null, "family_name": null, "nickname": null, "preferred_username": null, "profile": null, "picture": "https://files.authing.co/authing-console/default-user-avatar.png", "website": null, "birthdate": null, "gender": "U", "zoneinfo": null, "locale": null, "updated_at": "2023-02-14T09:26:28.068Z", "email": "xxx@authing.cn", "email_verified": true, "phone_number": "185xxxx9995", "phone_number_verified": true, "username": "neo", "sub": "63eb53c441a5c2f05f24bb03" } 

响应示例 (失败)

{ "error": "invalid_grant", "error_description": "Access Token 无效" } 
POST${host}/oidc/token/introspection 

此端点接受 access_token 、id_token 、refresh_token ,并返回一个布尔值,指示它是否处于活动状态。如果令牌处于活动状态,还将返回有关令牌的其他数据。如果 token 无效、过期或被吊销,则认为它处于非活动状态。

1.验证 Token 分为两种方式

本地验证与使用 Authing 在线验证。我们建议在本地验证 JWT Token ,因为可以节省你的服务器带宽并加快验证速度。你也可以选择将 Token 发送到 Authing 的验证接口由 Authing 进行验证并返回结果,但这样会造成网络延迟,而且在网络拥塞时可能会有慢速请求。

access_token 可以使用 RS256 签名算法或 HS256 签名算法进行签名。下面是这两种签名算法的区别

RS256 是使用 RSA 算法的一种数字签名算法,它使用公钥 /私钥对来加密和验证信息。RS256 签名生成的令牌比 HS256 签名生成的令牌更加安全,因为使用 RSA 密钥对进行签名可以提供更高的保护级别。使用 RS256 签名算法的令牌可以使用公钥进行验证,公钥可以通过 JWK 端点获取。

HS256 是使用对称密钥的一种数字签名算法。它使用同一个密钥进行签名和验证。HS256 签名算法在性能方面比 RS256 签名算法更快,因为它使用的是对称密钥,而不是使用 RSA 公钥 /私钥对来签名和验证。使用 HS256 签名算法的令牌可以通过 shared secret (应用密钥)进行验证。 在实际应用中,RS256 算法更加安全,但同时也更加消耗资源,如果系统需要高性能,可以选择 HS256 签名算法。

以下是本地验证和在线验证的优劣对比:

2.在线验证

需要注意的是,id_token 目前无法在线校验,因为 id_token 只是一个标识,若需要校验 id_token 则需要您在离线自行校验。

请求参数

请求示例

curl --location --request POST 'https://{host}/oidc/token/introspection' \ --header 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode 'client_id={应用 ID}' \ --data-urlencode 'client_secret={应用密钥}' \ --data-urlencode 'token={ token }' \ --data-urlencode 'token_type_hint={token_type_hint}' 

校验 access_token 响应示例(校验通过)

{ "active": true, "sub": "63eb53c441a5c2f05f24bb03", "client_id": "63eb4585156d977101dd3750", "exp": 1677648467, "iat": 1676438867, "iss": "https://oidc-authorization-code.authing.cn/oidc", "jti": "ObgavGBUocr1wsrUvtDLHmuFSgoebxsiOY4JNRqIhaQ", "scope": "offline_access username profile openid phone email", "token_type": "Bearer" } 

校验 access_token 响应示例(校验未通过)

{ "active": false } 

校验 refresh_token 响应示例 (校验通过)

{ "active": true, "sub": "63eb53c441a5c2f05f24bb03", "client_id": "63eb4585156d977101dd3750", "exp": 1679030867, "iat": 1676438867, "iss": "https://oidc-authorization-code.authing.cn/oidc", "jti": "6F2TO1v1YZ1_N7I3jXYHjK-vZzKtlD0IiP5KPoUFUCQ", "scope": "offline_access username profile openid phone email" } 

校验 refresh_token 响应示例(校验未通过)

{ "active": false } 

3.离线校验

可参考文档:离线校验( 文档链接: https://docs.authing.cn/v2/guides/faqs/how-to-validate-user-token.html#

我们简单说下,若您使用离线校验应该对 token 进行如下规则的校验

1.格式校验 - 校验 token 格式是否是 JWT 格式 2.类型校验 - 校验 token 是否是目标 token 类型,比如 access_token 、id_token 、refresh_token 3.issuer 校验 - 校验 token 是否为信赖的 issuer 颁发 4.签名校验 - 校验 token 签名是否由 issuer 签发,防止伪造 5.时间校验 - 校验 token 是否在有效期内 6.claims 校验 - 是否符合与预期的一致

以上 6 点均校验通过,我们才能认为 token 是有效且合法的

下面是一个示例 Java 代码,可以用于在本地校验 OIDC RS256 和 HS256 签发的 access_token 。

import com.nimbusds.jose.JWSObject; import com.nimbusds.jwt.JWTClaimsSet; import com.nimbusds.jwt.SignedJWT; import java.net.URL; import java.text.ParseException; import java.util.Date; public class OIDCValidator { private static final String ISSUER = "https://your-issuer.com"; private static final String AUDIENCE = "your-client-id"; private final URL jwkUrl; public OIDCValidator(final URL jwkUrl) { this.jwkUrl = jwkUrl; } public JWTClaimsSet validateToken(final String accessToken) throws ParseException { final SignedJWT signedJWT = SignedJWT.parse(accessToken); if (signedJWT == null) { throw new RuntimeException("Access token is null or empty"); } final JWTClaimsSet claims = signedJWT.getJWTClaimsSet(); if (claims == null) { throw new RuntimeException("No claims present in the access token"); } if (!claims.getIssuer().equals(ISSUER)) { throw new RuntimeException("Invalid issuer in access token"); } if (!claims.getAudience().contains(AUDIENCE)) { throw new RuntimeException("Invalid audience in access token"); } final JWSObject jwsObject = signedJWT.getJWSObject(); if (jwsObject == null) { throw new RuntimeException("No JWS object found in the access token"); } // Fetch the JWKs from the JWK set URL final JWKSet jwkSet = JWKSet.load(jwkUrl); final JWK jwk = jwkSet.getKeyByKeyId(jwsObject.getHeader().getKeyID()); if (jwk == null) { throw new RuntimeException("No JWK found for the access token"); } if (!jwsObject.verify(jwk.getKey())) { throw new RuntimeException("Invalid signature in access token"); } if (claims.getExpirationTime() == null || claims.getExpirationTime().before(new Date())) { throw new RuntimeException("Expired access token"); } return claims; } } 

这段代码使用 Nimbus JOSE+JWT 库来解析和验证 JWT token 。它使用指定的 issuer 和 audience 值对 access_token 进行验证,并验证 JWT 中 claims 的格式、类型、签名、有效期和 issuer 。如果发生任何验证错误,则将抛出 RuntimeException 。使用时需要传入对应的 JWK URL 和 access_token 进行调用,例如:

final URL jwkUrl = new URL("https://your-issuer.com/.well-known/jwks.json"); final OIDCValidator validator = new OIDCValidator(jwkUrl); final String accessToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE1MTYyMzkwMjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; final JWTClaimsSet claims = validator.validateToken(accessToken); 

这个示例只校验了 RS256 和 HS256 签名算法。

POST${host}/oidc/token 

此功能用于用户 token 的刷新操作,在 token 获取阶段需要先获取到 refresh_token 。

请求参数

请求示例

curl --location --request POST 'https://{host}/oidc/token' \ --header 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode 'client_id={应用 ID}' \ --data-urlencode 'client_secret={应用密钥}' \ --data-urlencode 'refresh_token={刷新令牌}' \ --data-urlencode 'grant_type=refresh_token' 

响应示例(成功)

{ "refresh_token": "6F2TO1v1YZ1_N7I3jXYHjK-vZzKtlD0IiP5KPoUFUCQ", "scope": "offline_access username profile openid phone email", "token_type": "Bearer", "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImVtSzBGbVRIa0xlQWFjeS1YWEpVT3J6SzkxV243TkdoNGFlYUVlSjVQOUUifQ.eyJzdWIiOiI2M2ViNTNjNDQxYTVjMmYwNWYyNGJiMDMiLCJhdWQiOiI2M2ViNDU4NTE1NmQ5NzcxMDFkZDM3NTAiLCJzY29wZSI6Im9mZmxpbmVfYWNjZXNzIHVzZXJuYW1lIHByb2ZpbGUgb3BlbmlkIHBob25lIGVtYWlsIiwiaWF0IjoxNjc2NDQ0MjY4LCJleHAiOjE2Nzc2NTM4NjgsImp0aSI6IkEtZUlQYkJ5N3lJLTliUmp1RnJHeXNCSXdjbWtOUl9WalpYODB2aU05VFkiLCJpc3MiOiJodHRwczovL29pZGMtYXV0aG9yaXphdGlvbi1jb2RlLmF1dGhpbmcuY24vb2lkYyJ9.Kk3jSK5BSUEDVTQMdMAdG5cBCxZt31vQiD-XYHNA84Gd3Mo8eDLcQpjMEzQ8HJ4_b9IgMOz5ydXz0zAQ6AjLMW3Rl49qhTGDB7Kq7tHTFmDO8itoO2LQTCLPCPtP3TkoOgptlFD_sd32nefH-HojNhuqwKw469Byw3xnW5xEs3wSuOoUdHwR2n9j1T1Zgp3e90xmBjbtbofQE1z0IWtCnrfJ9ujWsKXoN_7OAXbCTa-Ak_DhgLHU7xutQaaBOgD28lLLT5xclgBWfv7Leyx_kBnVGT5Jvo1tfA6AUEp6wJO4GUBzsijLefI04VDzBGypNuFJlw_jOhSp-SWxJjQSwQ", "expires_in": 1209600, "id_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI2M2ViNTNjNDQxYTVjMmYwNWYyNGJiMDMiLCJhdWQiOiI2M2ViNDU4NTE1NmQ5NzcxMDFkZDM3NTAiLCJpYXQiOjE2NzY0NDQyNjgsImV4cCI6MTY3NzY1Mzg2OCwiaXNzIjoiaHR0cHM6Ly9vaWRjLWF1dGhvcml6YXRpb24tY29kZS5hdXRoaW5nLmNuL29pZGMiLCJuYW1lIjpudWxsLCJnaXZlbl9uYW1lIjpudWxsLCJtaWRkbGVfbmFtZSI6bnVsbCwiZmFtaWx5X25hbWUiOm51bGwsIm5pY2tuYW1lIjpudWxsLCJwcmVmZXJyZWRfdXNlcm5hbWUiOm51bGwsInByb2ZpbGUiOm51bGwsInBpY3R1cmUiOiJodHRwczovL2ZpbGVzLmF1dGhpbmcuY28vYXV0aGluZy1jb25zb2xlL2RlZmF1bHQtdXNlci1hdmF0YXIucG5nIiwid2Vic2l0ZSI6bnVsbCwiYmlydGhkYXRlIjpudWxsLCJnZW5kZXIiOiJVIiwiem9uZWluZm8iOm51bGwsImxvY2FsZSI6bnVsbCwidXBkYXRlZF9hdCI6IjIwMjMtMDItMTRUMDk6MjY6MjguMDY4WiIsImVtYWlsIjpudWxsLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsInBob25lX251bWJlciI6IjE4NTE2ODI5OTk1IiwicGhvbmVfbnVtYmVyX3ZlcmlmaWVkIjp0cnVlLCJ1c2VybmFtZSI6bnVsbH0.DGoJrzkgti44zw-MotVM1KpLxbJTzc5pfh-xYun_xDQ" } 

响应示例(失败)

{ "error": "invalid_grant", "error_description": "Refresh Token 无效或已过期" } 
POST${host}/oidc/token/revocation 

撤销 access_token / refresh_token

请求参数

请求示例

curl --location --request POST 'https://{host}/oidc/token/revocation' \ --header 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode 'client_id={应用 ID}' \ --data-urlencode 'client_secret={应用密钥}' \ --data-urlencode 'token= {token}' \ --data-urlencode 'token_type_hint={token_type_hint}' 

响应示例(成功)

HTTP 200 OK 

响应示例(失败)

{ "error": "xxxx", "error_description": "xxxx" } 
GET${host}/oidc/session/end 

使用此操作通过删除用户的浏览器会话来注销用户。 post_logout_redirect_uri 可以指定在执行注销后重定向的地址。否则,浏览器将重定向到默认页面

请求参数

请求示例 (浏览器访问)

GET https://oidc-authorization-code.authing.cn/oidc/session/end?id_token_hint={id_token}&post_logout_redirect_uri=http://localhost:8080/&state=1676452381 

02 SSO (Single Sign-On) 单点登录 & SLO (Single Logout) 单点登出

2.1 SSO 实现

SSO(Single Sign-On) 单点登录,即同时访问多个应用仅需要登录一次。

举例:我们现在有两个站点分别是 uthing.com ething.com 我们希望用户在 uthing.com 进行认证后,跳转到 ething 后无需二次认证,反之也是一样的。

具体流程

1.用户访问 uthing.com ,uthing 前端发现用户未认证,跳转至 Authing 进行认证。

2.用户在 Authing 发起认证完成,Authing 创建 SSO Session ,下发临时授权码 (code) 重定向到 uthing 后台。

需要注意的是,在这里我们也可以重定向到前端页面,再由前端页面自行判断如果是 Authing 回调请求,则携带临时 code 到 uthing 后台去获取 token 。

3.uthing 后台通过 code 向 Authing 换取 access_token 、id_token 、refresh_token 等,并下发给前端。

4.uthing 前端通过 access_token 可以直接向 Authing OIDC 用户信息端点获取当前用户信息。

5.uthing 前端在在后续请求后台时,携带由 Authing 颁发的 access_token ,后台在接受到用户请求后去 Authing 校验 Token 是否有效,有效则可放行,若 Token 校验失败或已过期则返回错误信息。

6.用户访问 ething.com ,ething 跳转至 Authing 进行认证。

7.由于用户在 Authing 已经完成认证,创建了 sso_session ,Authing 侧直接下发临时授权码 (code) ,无需二次认证,后续流程同 1 。

我们发现,用户在 uthing 认证成功的时候,再访问 ething 的时候会向 Authing 跳转一下,才能完成后续流程,这是由于 ething.comuthing.com 并不是同一个站点,无法实现 cookie 共享,如果你的产品地址是 : uthing.xxx.com ething.xxx.com 我们则可以利用相同域下 cookie 共享的方式实现 SSO ,从而避免此问题。

2.2 SLO 实现

SLO(Single Logout) 单点登出,即多个应用仅需要登出一次,其他应用也自动登出 。

则 SLO 流程如下

1.用户在某个站点登出,我们则需要调用 OIDC 登出端点销毁 Token ,由于是 cookie 共享实现的 SSO ,然后清除 xxx.com 对应会话的 cookie 即可。

2.ething / uting 应当在每次发起请求前,判断 cookie 中是否存在登录态,若不存在,则需要跳转默认页面提示用户已经登出。

如果你的产品地址不是同一个域,例如: uthing.com ething.com

则 SLO 流程如下

1.用户在某个站点登出,我们则需要调用 OIDC 登出端点销毁 Token ,在 Authing 的应用配置中,你应当先把应用都添加到 SSO 中,或者 uthing.comething.com 使用 Authing 的同一个自建应用,当用户在某个站点登出后,另外一个站点的 Token 也会失效。 2.用户未登出的站点发起请求,当后台校验 Token 失败后,则下发清除 cookie 的命令并跳转默认页面提示用户已经登出,需要登录。

03 本章总结

本章我们介绍了 OIDC 授权码模式的接入流程以及相关接口的调用方式,对于小白来说可能需要整体跑一遍流程才能熟悉,我们也建议你 fork 我们的 postman collection 跑一遍流程,对授权码模式你就基本掌握啦。

接下来我们还会介绍 OIDC 的授权码+PKCE 流程,以及接入 Authing 的方式,需要你对授权码模式的流程有一定了解哦。

]]>
Java 小小写个开源 OAuth 客户端工具 tag:www.v2ex.com,2023-01-06:/t/907085 2023-01-06T12:56:39Z 2023-01-06T14:44:11Z hanbings member/hanbings 目前正在适配常见的国外平台,迟一些会做国内的像 QQ 微信 WB 百度 阿里 什么的

欢迎 Star 跟进! Github:Fluocean

Fluocean

主要特性:

Github OAuth 示例

洋流提供了许多的重载方法,用于应对不同情况下的请求,有些带自有请求头的,也有要求必须要 Scope 的。

// 创建 OAuth 原始处理器 OAuth<GithubAccess, GithubAccess.Wrong> oauth = new GithubOAuth( "id", "secret", "https://exmaple.com/api/v0/login/oauth/github/callback" ); // 生成授权 url String url = oauth.authorize(); // 生成带参数或指定 scope String spec = oauth.authorize(List.of("email"), Map.of("Accept", "application/json")); //解析回调的 url 并获取 token // 输入原始 url 自动解析 code 以及 state oauth.token("url"); // 更改回调地址 oauth.token("url", "redirect"); // 手动指定参数 oauth.token("code", "state", "redirect"); // 处理返回值 oauth.token("code", "state", "redirect") .succeed(data -> System.out.println(data.accessToken())) .fail(wrong -> System.out.println(wrong.errorDescription())) .except(throwable -> System.out.println(throwable.getMessage())); // 假设请求成功 直接获取数据 GithubAccess access = oauth.token("code", "state", "redirect").data(); 

使用 Socks 代理

oauth.proxy(() -> new Request.Proxy( Proxy.Type.SOCKS, "127.0.0.1", 10086, "username", "password" ) ); 

更换 State 生成器

默认随机生成 UUID 并设置 300 秒有效期

oauth.state( Lazy.of(() -> new OAuthState(300, () -> UUID.randomUUID().toString())) ); 

更换 Http 客户端

默认使用 Okhttp 发起请求

// 实现比较繁杂 就不展示啦 x oauth.request(Lazy.of(OAuthRequest::new)); 

更换 Json 解析器

默认使用 Gson 作为 Json 解析器

oauth.serialization( Lazy.of(() -> new Serialization() { final Gson gson = new Gson(); @Override public <T> T object(Class<T> type, String raw) { return gson.fromJson(raw, type); } @Override public <K, V> Map<K, V> map(Class<K> key, Class<V> value, String raw) { return gson.fromJson(raw, new TypeToken<Map<K, V>>() { }.getType()); } @Override public <T> List<T> list(Class<T> type, String raw) { return gson.fromJson(raw, new TypeToken<List<T>>() { }.getType()); } }) ); 
]]>
腾讯的网页授权真难申请,微博秒搞定! tag:www.v2ex.com,2019-03-15:/t/545172 2019-03-15T17:38:51Z 2019-03-15T17:35:51Z botian member/botian 关于 native 应用程序在使用 OAuth 2.0 的一些问题 tag:www.v2ex.com,2016-05-05:/t/276537 2016-05-05T07:35:09Z 2016-05-05T07:32:09Z Gonster member/Gonster WebView 的安全问题

很多手机 app 用第三方身份提供商通过 OAuth2.0 (或者类似 OpenID Connect 的方式)做登录的,身份提供商一般是允许手机应用登录的时候用 WebView 打开提供商的登录页面的。

WebView 的请求内容可以被手机应用拦截,那用 OAuth2.0 的意义呢,密码不是会被手机应用获取到吗,或者一般像 QQ 这些是靠他们提供的 SDK 保证密码安全的吗?

installed native application 相关的问题

或者说是客户端类型是 public 的相关的问题, OAuth2.0 协议里认为手机 app 这类的程序是不能保证客户端密码安全的。

public 类型的客户端如果是 web app 还好,它是有一个固定的 URL 的,也就是说通过redirect_uri能保证只有这个 web app 能获得授权码,或者说也能保证只有这个网站能用这个客户端 ID 。

但是本地应用程序这类的,redirect_uri也挺不靠谱的:

  1. 比如客户端获得授权码可能是通过在本地监听某个端口,可能在授权服务器上注册的就是127.0.0.1:port之类的重定向 uri 。
  2. 客户端在本地注册一个 uri scheme ,授权服务器上注册scheme:XXXXX
  3. 甚至有些情况下是允许用户来拷贝授权码到客户端里面去的,比如在这种情况 Google 会让客户端使用urn:ietf:wg:oauth:2.0:oob这样的redirect_uri来打开拷贝授权码的页面。

这些客户端在授权服务器上注册的意义似乎并不是很大啊,而且如果客户端 ID (客户端 ID 没有要求要保密)被其他人获取了以后,其他人也很有可能能冒充使用啊。

或者说这情况是不是还是应该让这类本地安装的程序的服务器端来做授权,让程序和他的服务器端来通信更佳合适,或者客户端ID也应该要随机生成不能随意遍历和预测?

]]>
如何创建一个 OAuth 服务 tag:www.v2ex.com,2013-11-22:/t/90239 2013-11-22T10:25:46Z 2013-11-22T10:22:46Z lepture member/lepture
http://lepture.com/en/2013/create-oauth-server ]]>
在oauth1.0中签名的话必须有consumer_key 与consumer_secret ,这样的话在桌面应用中岂不是这两个都给暴漏了 tag:www.v2ex.com,2013-07-31:/t/77551 2013-07-31T07:45:04Z 2013-08-01T08:04:14Z ksc010 member/ksc010 OAuth.io tag:www.v2ex.com,2013-05-28:/t/70390 2013-05-28T04:40:26Z 2013-05-28T18:32:43Z Livid member/Livid http://oauth.io/

OAuth that just works. ]]>
OAuth 2.0 tag:www.v2ex.com,2013-05-19:/t/69408 2013-05-19T09:50:08Z 2013-05-20T02:44:46Z Livid member/Livid ]]> weibo的OAuth问题 tag:www.v2ex.com,2012-06-05:/t/38486 2012-06-05T11:38:30Z 2012-06-06T15:05:26Z techzhou member/techzhou
难道要24h以后 再授权一次 不是这么奇葩吧 ]]>
腾讯微博的OAuth问题...... tag:www.v2ex.com,2011-07-27:/t/16211 2011-07-27T05:43:54Z 2012-05-19T21:44:15Z fanzeyi member/fanzeyi http://oauth.googlecode.com/svn/code/python/oauth/oauth.py

然后我就逐行的检查 HMAC 加密的代码.. 发现没有一点问题..

在 腾讯微博开放平台的论坛上也看到有好多人出现这个问题 似乎是和 urlencode 有关 但是具体的也没人给出个正确的解法...

在 Github 上有个 andelf/pyqqweibo 的 repo 我直接给他的 oauth.py 改过来用了 但是还是一样不行..

下面的是 Signature Base String..

GET&https%3A%2F%2Fopen.t.qq.com%2Fcgi-bin%2Frequest_token&oauth_callback%3Dnull%26oauth_consumer_key%3D11dca692584b4cc2835151b3c925ed1d%26oauth_nonce%3D93468450%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1311745309%26oauth_version%3D1.0

密匙部分我记得带 & 号了... 有搞过 腾讯OAuth的嘛 求教! ]]>
搞定 OAuth 的感觉实在是太爽了 tag:www.v2ex.com,2010-07-25:/t/900 2010-07-25T02:17:45Z 2010-09-30T03:54:56Z Livid member/Livid img.ly API tag:www.v2ex.com,2010-07-24:/t/884 2010-07-24T17:19:05Z 2010-07-25T17:20:37Z Livid member/Livid ubao snddm index pchome yahoo rakuten mypaper meadowduck bidyahoo youbao zxmzxm asda bnvcg cvbfg dfscv mmhjk xxddc yybgb zznbn ccubao uaitu acv GXCV ET GDG YH FG BCVB FJFH CBRE CBC GDG ET54 WRWR RWER WREW WRWER RWER SDG EW SF DSFSF fbbs ubao fhd dfg ewr dg df ewwr ewwr et ruyut utut dfg fgd gdfgt etg dfgt dfgd ert4 gd fgg wr 235 wer3 we vsdf sdf gdf ert xcv sdf rwer hfd dfg cvb rwf afb dfh jgh bmn lgh rty gfds cxv xcv xcs vdas fdf fgd cv sdf tert sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf shasha9178 shasha9178 shasha9178 shasha9178 shasha9178 liflif2 liflif2 liflif2 liflif2 liflif2 liblib3 liblib3 liblib3 liblib3 liblib3 zhazha444 zhazha444 zhazha444 zhazha444 zhazha444 dende5 dende denden denden2 denden21 fenfen9 fenf619 fen619 fenfe9 fe619 sdf sdf sdf sdf sdf zhazh90 zhazh0 zhaa50 zha90 zh590 zho zhoz zhozh zhozho zhozho2 lislis lls95 lili95 lils5 liss9 sdf0ty987 sdft876 sdft9876 sdf09876 sd0t9876 sdf0ty98 sdf0976 sdf0ty986 sdf0ty96 sdf0t76 sdf0876 df0ty98 sf0t876 sd0ty76 sdy76 sdf76 sdf0t76 sdf0ty9 sdf0ty98 sdf0ty987 sdf0ty98 sdf6676 sdf876 sd876 sd876 sdf6 sdf6 sdf9876 sdf0t sdf06 sdf0ty9776 sdf0ty9776 sdf0ty76 sdf8876 sdf0t sd6 sdf06 s688876 sd688 sdf86