OAuth2.0 作为现代互联网的授权标准协议,其核心价值在于允许第三方应用在用户授权后访问特定资源,而无需共享用户密码。以 GitHub 的 OAuth 流程为例,典型授权过程分为三步:
这是 github 的 oauth2.0 接入文档,在文档中有详细描述整个认证流程。
https://docs.github.com/zh/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps
如图,三步就可以完成整个流程。
从 github 的认证流程文档我们就能看到,作为接入方的我们需要为 github 定义三个常量:
这就导致了接入不同的 OAuth 提供方时,需为每个提供方维护不同端点。当接入多个平台时会产生大量重复工作。
OAuth 协议中没有定义如何认证用户,而依赖认证系统比如 Google 自己通过账号密码进行用户认证,也就是常说的“OAuth2.0 仅负责授权,不验证用户身份“。
这看似和我们第三方没有什么关系,但其实会间接导致另一个问题:
OAuth 协议最后只会给第三方访问令牌(Token)来授权第三方使用用户的部分数据,而没有告诉第三方这个用户是谁,第三方无法区分 A 用户和 B 用户,进而第三方也无法为这位用户分配属于他自己系统的唯一身份。
这里有一个常见误解,许多应用使用 OAuth 2.0 实现“社交登录”(如“用 Google 账号登录”),那这些应用是如何知道用户是谁的呢?
答案是:虽然 OAuth 没有规定如何识别用户是谁,但有了 Token 之后,第三方其实可以调用各个 Provider 的接口获取用户信息,如用户的 id / openid,这样第三方就知道用户是谁。
所以我们平时使用的 ”OAuth 第三方登录“,实则已经不是只有 OAuth 了,只是部分依赖了 OAuth 协议。
而上面提到的解决方案(通过接口再次调用接口获取用户信息)有什么问题呢?
由于获取用户信息的接口没有被纳入规范,所以各个 Provider 的接口千奇百怪,并不好适配,而大多数情况下,用户信息是被需要的,这就导致又需要为不同的 Provider 写不同的代码。
题外话:如果你和我一样 对 ”授权“,”认证“ 有点模糊,那么可以问问参考下下面 Deepseek 的答案:
如何理解”授权“, ”认证“。
这两个概念确实打脑壳,让 deepseek r1 帮我理一下:
• 授权(Authorization):OAuth 2.0 的核心功能是允许第三方应用(Client)在用户同意后,安全地获取访问资源的权限(如访问用户的云存储文件或社交媒体数据)。
• 认证(Authentication):验证用户身份(例如确认用户是“谁”)并非 OAuth 2.0 的职责,而是由 **授权服务器(Authorization Server)**通过其他机制(如用户名/密码、多因素认证等)完成的。
OAuth 2.0 的流程中隐含认证,但协议本身不定义认证
在 OAuth 流程中,用户通常需要登录到授权服务器(如 Google、Facebook)以同意授权请求。虽然这看似是“认证”,但:
- 认证由授权服务器独立完成:OAuth 2.0 协议不规定如何认证用户,而是依赖授权服务器自行实现认证逻辑(例如通过传统登录表单或生物识别)。
- OAuth 仅关注授权结果:协议只关心用户是否同意授权,并返回访问令牌(Access Token),而不关心用户是如何被认证的。
实际场景中的误解
• “使用 OAuth 登录”的真相:许多应用使用 OAuth 2.0 实现“社交登录”(如“用 Google 账号登录”),这实际上是通过 OAuth 的授权流程间接获取用户身份信息。但这种身份信息是由授权服务器提供的(例如通过 OpenID Connect 扩展),而非 OAuth 2.0 原生支持的功能。
DiscoveryUrl 是 OIDC 协议中的一个标准化机制,用于动态获取 OIDC 提供者的配置信息。它简化了客户端的配置,使客户端能够自动适应不同的 OIDC 提供者。通过 DiscoveryUrl,客户端可以获取授权端点、令牌端点、用户信息端点等关键信息,从而实现身份验证和授权流程。
一般的 DiscoveryUrl 通常是 OIDC 提供者的基础 URL 加上标准路径 /.well-known/openid-configuration。例如:https://accounts.google.com/.well-known/openid-configuration
访问这个地址会返回如下内容:
{
"issuer": "<https://accounts.google.com>",
"authorization_endpoint": "<https://accounts.google.com/o/oauth2/v2/auth>",
"token_endpoint": "<https://oauth2.googleapis.com/token>",
"userinfo_endpoint": "<https://openidconnect.googleapis.com/v1/userinfo>",
"jwks_uri": "<https://www.googleapis.com/oauth2/v3/certs>",
"response_types_supported": ["code", "token", "id_token", "code token", "code id_token", "token id_token", "code token id_token"],
"id_token_signing_alg_values_supported": ["RS256"],
"scopes_supported": ["openid", "email", "profile"]
}
可以看到这里面包括了 OAuth 需要用到的所有端点(Endpoint),所以有了 DiscoveryUrl,我们就能实现配置信息的动态获取。
在各个语言应该都有 OIDC 库用于请求 DiscoveryUrl 获取 OAuth 流程中需要的配置。
例如 golang 中的这个库:https://github.com/coreos/go-oidc
provider, err := oidc.NewProvider(ctx, "<https://accounts.google.com>")
if err != nil {
// handle error
}
// Configure an OpenID Connect aware OAuth2 client.
oauth2Config := oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: redirectURL,
// Discovery returns the OAuth2 endpoints.
Endpoint: provider.Endpoint(),
// "openid" is a required scope for OpenID Connect flows.
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
而大多数情况下,用户信息是被需要的,所以规范就诞生了:ID Token.
OIDC 协议规定通过 code 换取 token 的时候,除了返回 access_token,还会返回 id_token。
ID Token 是一个 JWT(JSON Web Token),包含用户的身份信息(如用户 ID、姓名、电子邮件等)。包含标准声明(如 iss、sub、aud、exp 等)和自定义声明。
例如:
{
"iss": "<https://accounts.google.com>",
"sub": "1234567890",
"aud": "client-id",
"exp": 1672531199,
"name": "John Doe",
"email": "[email protected]"
}
其中 sub 就是用户的唯一标识符。
!需要注意的是,JWT 需要另外的用 Provider 提供的公钥来验证其有效性,这点我们后面在“安全”部分详细介绍下。
另外 OIDC 同时也定义了 “UserInfo Endpoint”
IDC 中的 UserInfo Endpoint 规定了返回的用户信息格式。根据 OpenID Connect Core 1.0 规范,UserInfo Endpoint 返回的响应必须是一个 JSON 对象,包含用户的相关声明(claims)。
必须包含的字段:
可选字段:
现在我们要获取用户信息就有了比较统一的规范,有两个方法:
为什么 ID Token 是 JWT 格式,而不是明文?
我们知道在通过 code 交换 token 的过程中,都是直接由服务端直接发起,并且流程全程使用HTTPS加密,ID Token 应该不会被泄露或者伪造才对。
我们来演练下如果没有 JWT 的防护可能发生的问题:
现在“支付宝“是 OAuth 使用方,而“百度“是提供方,另有个有钱的“百度用户“,百度账户 baidu_id = ‘richman’。
有一个有能力的攻击者劫持了 baidu.com 域名,现在支付宝在进行 百度 OAuth 授权过程中的信息就会被攻击者劫持,于是他使用自己的账号进行登录,并替换了 ID Token,把 ID Token 中的 sub 由 ‘poolman’ 替换成了 ‘richman’,而 支付宝 在收到 ID Token 之后并没有校验 ID Token 是否被篡改,于是攻击者登录上 支付宝 的账号变成了 richman,现在攻击者就可以随意使用 richman 的账号做非法的事情了。
JWT 如何避免这次攻击?只需要 支付宝 在使用 ID Token 的时候使用 百度 提供的公钥来校验下 ID Token 是否被篡改即可。
// JWT 验证示例(使用提供方公钥)
keySet := oidc.NewRemoteKeySet(ctx, "<https://www.googleapis.com/oauth2/v3/certs>")
verifier := provider.Verifier(&oidc.Config{ClientID: clientID})
idToken, _ := verifier.Verify(ctx, rawIDToken)
claims := struct {
Email string `json:"email"`
Sub string `json:"sub"`
}{}
idToken.Claims(&claims)
这里就引生出安全设计原则 :纵深防御(Defense in Depth)
截止 2025-02-18,Github 上 star 最多的两个库是:https://github.com/coreos/go-oidc(2k star)(前文提到了) 和https://github.com/zitadel/oidc(1.5k star)。
这两个库我都试用了,可以提供一些建议:
zitadel/oidc 和 coreos/go-oidc 最大的功能区别是:前者支持 OP(OpenID Provider,OIDC 提供者)功能给,而后者只支持 RP(Relying Party,依赖方)。
zitadel/oidc 看似很强,但实际使用过程中,如果你不需要用的 OP 功能,那么还是不要碰这个库,因为这个库代码很复杂并且没有说明文档,大而全但定制复杂,有点过度设计嫌疑,不要以为 OIDC 是标准就无需定制了,并不是每个 Provider 都严格按照标准执行,很可能会需要针对不同 Provider 打补丁。
而后者 coreos/go-oidc 没有过度设计,就算这个库无法提供对应功能或者需要定制,我们可以直接回退到使用 oauth2 库并部分使用这个库的工具方法。
© 2024 bysir's Blog - by
Creght