OAuth 2.0 概述
RFC 6749 - OAuth 2.0 授权框架规定了使用第三方应用程序授权服务的协议。OAuth 2.0 使用令牌从服务和用户中抽象出授权。这种方法更安全,因为服务所有者无需处理用户凭证。它是RFC 5849OAuth 1.0 的替代品。
OAuth 2.0 框架定义了两种客户端类型(公开或保密),以及授权代码流、隐式代码授予等授权流程。典型的 Qt 应用程序被视为公共本地应用程序。公共客户端应用程序是指不允许将密码等敏感信息嵌入二进制程序的应用程序。
RFC 8252 OAuth 2.0 for Native Apps进一步定义了本地应用程序的最佳实践。具体来说,RFC 8252 建议使用浏览器的授权流。因此,QtNetworkAuth 类提供了该流程的具体实现。
在 Qt 6.9 中,QtNetworkAuth 提供了对RFC 8628- OAuth 2.0 设备授权许可的支持。该设备流程适用于输入能力有限或不实用的设备。在此流程中,授权授予使用的是智能手机等辅助设备,而不是设备。这些设备的例子包括电视、媒体控制台、机器人机界面和物联网设备。然后,用户可以使用智能手机上的应用程序对设备进行授权。
下表重点介绍了Qt Network 授权支持的两个 OAuth 2.0 流程:
方面 | 授权码流程 | 设备授权流程 |
---|---|---|
网络连接 | 网络连接 | 是 |
用户交互 | 同一设备上的浏览器/用户代理 | 不同设备上的浏览器/用户代理 |
需要重定向处理 | 需要 | 不需要 |
设备上的输入能力 | 丰富的输入功能 | 有限或无输入功能 |
目标 | 桌面和移动应用程序 | 电视、控制台、人机界面、物联网设备 |
OAuth 2.0 需要使用用户代理(通常是浏览器)。有关详细信息,请参阅Qt OAuth2 浏览器支持。
OAuth 2.0 类
Qt Network 授权提供了具体和抽象的 OAuth 2.0 类。抽象类用于实现自定义流程,而具体类则提供具体实现。
有关 C++ 类的列表,请参阅QtNetworkAuth 页面。
Qt Network 授权有两个用于实现 OAuth 2.0 流程的抽象类:
- OAuth 2.0 流量实现类提供主要 API,是流量的协调者。抽象类是QAbstractOAuth2 ,具体实现是QOAuth2AuthorizationCodeFlow 和QOAuth2DeviceAuthorizationFlow 。
- 回复处理程序类用于处理来自授权服务器的重定向和回复。回复处理程序的抽象类是QAbstractOAuthReplyHandler ,具体实现类是QOAuthHttpServerReplyHandler 和QOAuthUriSchemeReplyHandler 。回复处理程序的主要区别在于它们处理的重定向类型。QOAuth2AuthorizationCodeFlow 使用回复处理程序来处理重定向,而不基于重定向的QOAuth2DeviceAuthorizationFlow 则不使用回复处理程序。
授权代码流程
本节概述了RFC 6749--授权代码和RFC 8252--本地应用程序的授权请求中针对本地应用程序的授权代码流。
请考虑以下示例设置:
QOAuth2AuthorizationCodeFlow m_oauth; QOAuthUriSchemeReplyHandler m_handler; m_oauth.setAuthorizationUrl(QUrl(authorizationUrl)); m_oauth.setTokenUrl(QUrl(accessTokenUrl)); m_oauth.setClientIdentifier(clientIdentifier); m_oauth.setRequestedScopeTokens({scope}); connect(&m_oauth, &QAbstractOAuth::authorizeWithBrowser, this, &QDesktopServices::openUrl); connect(&m_oauth, &QAbstractOAuth::granted, this, [this]() { // Here we use QNetworkRequestFactory to store the access token m_api.setBearerToken(m_oauth.token().toLatin1()); m_handler.close(); }); m_handler.setRedirectUrl(QUrl{"com.example.myqtapp://oauth2redirect"_L1}); m_oauth.setReplyHandler(&m_handler); // Initiate the authorization if (m_handler.listen()) { m_oauth.grant(); }
授权流程阶段
RFC 6749 授权代码流有两个主要阶段:资源授权(包括任何必要的用户验证),然后是访问令牌请求。随后是使用访问令牌和刷新访问令牌。下图说明了这些阶段:
- 在授权阶段,用户通过身份验证,并授权访问资源。这需要用户与浏览器交互。
- 授权后,收到的授权码将用于申请访问令牌,也可用于申请刷新令牌。
- 一旦获得访问令牌,应用程序就可以使用它访问感兴趣的资源。访问令牌包含在资源请求中,由资源服务器来验证令牌的有效性。有几种方法可将令牌作为不记名令牌请求的一部分。在HTTP
Authorization
标头中包含令牌可以说是最常见的方法。 - 刷新访问令牌。访问令牌的过期时间通常相对较短,例如一小时后。如果应用程序除访问令牌外还收到了刷新令牌,则可使用刷新令牌请求新的访问令牌。应用程序可以保留较长时间的刷新令牌,以避免进入新的授权阶段(从而再次与浏览器交互)。
细节和定制
OAuth 2.0 流程是动态的,一开始实施规范可能会很棘手。下图说明了成功的授权代码流程的主要细节。
为清晰起见,图中省略了一些信号,但总体说明了细节和主要定制点。自定义点是应用程序可以使用的各种信号和插槽,以及可通过QAbstractOAuth::setModifyParametersFunction() 和QAbstractOAuth2::setNetworkRequestModifier() 设置的回调。
选择回复处理程序
决定使用哪种处理程序取决于redirect_uri元素。redirect_uri
设置为授权阶段结束后浏览器重定向的位置。
对于在本地应用程序中接收授权响应,RFC 8252规定了三种主要的响应 URI 方案:私有、回环和 https。
- 私人使用 URI:如果操作系统允许应用程序注册自定义 URI 方案,则可以使用这种方案。尝试使用这种自定义方案打开 URL 时,将打开相关的本地应用程序。请参见QOAuthUriSchemeReplyHandler 。
- HTTPS URI:如果操作系统允许应用程序注册自定义 HTTPS URL,则可以使用该 URL。尝试打开此 URL 将打开相关本地应用程序。如果操作系统支持,建议使用此方案。请参见QOAuthUriSchemeReplyHandler 。
- 环回接口:这些接口通常用于桌面应用程序和开发过程中的应用程序。QOAuthHttpServerReplyHandler 的设计目的是通过设置本地服务器来处理这些 URI 的重定向。
如何选择取决于以下几个因素
- 授权服务器供应商支持的重定向 URI。各厂商的支持情况不尽相同,通常针对特定的客户端类型和操作系统。此外,支持情况可能因应用程序是否发布而异。
- 目标平台支持的重定向 URI 方案。
- 应用程序特定的可用性、安全性和其他要求。
RFC 8252建议使用https
方案,因为与其他方法相比,它具有安全性和可用性方面的优势。
OAuth 2.0 设备授权许可
RFC 8628OAuth 2.0 设备授权授予适用于输入能力有限或不适合使用用户代理浏览器的连接设备。使用此流程的设备示例是需要外部设备授权的智能设备。
请考虑以下示例设置:
m_deviceFlow.setAuthorizationUrl(QUrl(authorizationUrl)); m_deviceFlow.setTokenUrl(QUrl(accessTokenUrl)); m_deviceFlow.setRequestedScopeTokens({scope}); m_deviceFlow.setClientIdentifier(clientIdentifier);// 是否需要客户端机密取决于授权服务器m_deviceFlow.setClientIdentifierScopeTokens({scope}); m_deviceFlow.setClientIdentifier(clientIdentifier);// 是否需要客户端机密取决于授权服务器setClientIdentifierSharedKey(clientSecret); connect( &m_deviceFlow, &QOAuth2DeviceAuthorizationFlow::authorizeWithUserCode, this,[](constQUrl用户代码 QString用户代码, constQUrl&completeVerificationUrl) {if(completeVerificationUrl.isValid()) {// 如果授权服务器提供了一个完整的 URL // 该 URL 已经包含了作为 URL 参数一部分的必要数据,则 // 您可以选择使用该 URL。 qDebug() << "Complete verification uri:" << completeVerificationUrl; }else{// 授权服务器只提供了验证 URL;使用该 URL qDebug() << "Verification uri and usercode:" << verificationUrl << userCode; } ); connect(&m_deviceFlow, &m_deviceFlow.token() { // 这里我们使用 QNetworkRequestFactory来存储访问令牌。QAbstractOAuth::granted, this, [this](){// 这里我们使用 QNetworkRequestFactory 来存储访问令牌m_api.setBearerToken(m_deviceFlow.token().toLatin1()); }); m_deviceFlow.grant();
设备授权授予阶段
设备授权授予流程有三个主要阶段:初始化授权、轮询令牌和完成授权。之后还可选择使用令牌和刷新令牌。下图说明了这些阶段:
- 通过向授权服务器发送 HTTP 请求来初始化授权。授权服务器会响应提供用户代码、验证 URL 和设备代码。
- 授权初始化后,向用户提供用户代码和验证 URL,以完成授权。提供给终端用户的机制各不相同:可以是屏幕上可见的 URL、QR 码、电子邮件等。更多信息请参阅RFC 8628 - 用户交互。
- 在等待终端用户完成授权时,设备流会轮询授权服务器以获取令牌。上一步收到的设备代码用于匹配授权会话。轮询间隔由授权服务器决定,通常为 5 秒。
- 一旦终端用户接受或拒绝授权,授权服务器就会响应轮询请求,提供所需的令牌或错误代码(如果拒绝),授权即告完成。
细节和定制
下图详细说明了设备授权许可流程。图中显示了有时需要的主要定制点。例如,专有参数或附加身份验证凭证。
刷新令牌
刷新令牌要求授权服务器在授权期间提供刷新令牌。是否提供刷新令牌由授权服务器决定:有些服务器可能选择始终提供刷新令牌,有些服务器可能从不提供刷新令牌,还有些服务器会在授权请求中出现特定scope 时提供刷新令牌。
下图详细说明了刷新令牌:
如上图所示,刷新令牌时也可使用常用的自定义点。
要在应用程序启动后刷新令牌,应用程序需要安全地持久化刷新令牌,并通过QAbstractOAuth2::setRefreshToken 进行设置。然后就可以调用QAbstractOAuth2::refreshTokens 来请求新的令牌。
Qt 6.9 的新功能是,应用程序可以自动刷新令牌 - 参见QAbstractOAuth2::accessTokenAboutToExpire,QAbstractOAuth2::autoRefresh, 和QAbstractOAuth2::refreshLeadTime 。
授权服务器一般不会说明刷新令牌的过期时间(除了服务器的文档)。它们的有效期可以是几天、几个月或更长。此外,与其他令牌一样,刷新令牌可随时被用户撤销,从而失效。因此,通过QAbstractOAuth::requestFailed 或QAbstractOAuth2::serverReportedErrorOccurred 正确检测刷新尝试失败非常重要。
OAuth 2.0 流程需要许多用户交互,这可能会干扰用户体验。为了尽量减少这些交互,可以为用户静默刷新令牌。更多信息请参阅RFC 6749 --刷新访问令牌。
Qt OpenID Connect 支持
OpenID Connect (OIDC)是 OAuth 2.0 上的一个简单身份层。OIDC 可以使用授权服务器来验证用户身份。使用 OIDC 还可以访问简单的用户配置文件信息。
目前,Qt 对 OIDC 的支持仅限于获取 ID 令牌。ID 令牌是一个JSON 网络令牌(JWT),其中包含有关身份验证事件的声明。
注意: 目前尚未实现 ID 令牌验证或 ID 令牌解密。您必须使用第三方 JWT 库进行 JWT 令牌签名或验证。
假定应用程序能够验证收到的令牌,令牌就可以用来可靠地确定用户身份(只要 OIDC 提供商本身是可信的)。
身份令牌是敏感信息,应保密,与访问令牌不同。ID 令牌不用于在应用程序接口调用中发送,访问令牌用于此目的。请注意,有些供应商可能会对访问令牌使用相同的 JWT 格式,但这不能与使用相同格式的实际 ID 令牌相混淆。对于 ID 令牌,接收令牌的客户端负责验证令牌,而对于访问令牌,则由接受令牌的资源服务器负责验证。
获取 ID 令牌
获取 ID 令牌与获取访问令牌类似。首先,我们需要设置适当的范围。授权服务器供应商可能支持额外的范围指定符,如profile
和email
,但所有 OIDC 请求都必须包含openid
范围:
m_oauth.setRequestedScopeTokens({"openid"});
对于 OIDC,强烈建议使用nonce 参数。这可通过确保设置适当的NonceMode 来实现。
// This is for illustrative purposes, 'Automatic' is the default mode m_oauth.setNonceMode(QAbstractOAuth2::NonceMode::Automatic);
最后一步,我们可以直接监听QAbstractOAuth2::granted 信号或QAbstractOAuth2::idTokenChanged :
connect(&m_oauth, &QAbstractOAuth2::idTokenChanged, this, [this](const QString &token) { Q_UNUSED(token); // Handle token });
验证 ID 令牌
验证接收到的 ID 令牌是身份验证流程的关键部分,如果完全实现,则是一项有些复杂的任务。请参阅OpenID Connect ID 验证的全部内容。
小结一下,验证包括以下步骤:
- 必要时对令牌进行解密(参见 JWE)
- 提取令牌头、有效载荷和签名
- 验证签名
- 验证有效载荷的字段(如
aud, iss, exp, nonce, iat
)
Qt 目前不支持 ID 令牌验证,但有第三方 JWT 库,如jwt-cpp。
ID 令牌验证示例
本节介绍一个简单的验证示例。作为先决条件,开发环境需要在应用程序项目源代码目录下的 include 文件夹中包含OpenSSL库和jwt-cpp。
在应用程序项目的CMakeLists.txt
文件中,我们首先检查是否满足了前提条件:
find_package(OpenSSL 1.0.0 QUIET) set(JWT_CPP_INCLUDE_DIR "${CMAKE_SOURCE_DIR}/include") if(OPENSSL_FOUND AND EXISTS "${JWT_CPP_INCLUDE_DIR}/jwt-cpp/jwt.h")
然后添加必要的包含和库:
target_include_directories(networkauth_oauth_snippets PRIVATE "${JWT_CPP_INCLUDE_DIR}") target_link_libraries(networkauth_oauth_snippets PRIVATE OpenSSL::SSL OpenSSL::Crypto) target_compile_definitions(networkauth_oauth_snippets PRIVATE JWT_CPP_AVAILABLE)
在应用程序源文件中,包含验证库:
#ifdef JWT_CPP_AVAILABLE #include "jwt-cpp/jwt.h" #endif
应用程序收到 ID 令牌后,就该对其进行验证了。首先,我们要从 JSON Web Key Sets (JWKS) 中找到匹配的密钥,请参阅OpenID Connect Discovery)。
try { const auto jwt = jwt::decode(m_oauth.idToken().toStdString()); const auto jwks = jwt::parse_jwks(m_jwks->toJson(QJsonDocument::Compact).toStdString()); const auto jwk = jwks.get_jwk(jwt.get_key_id());
然后进行实际验证:
// 这里我们使用模数和指数来推导密钥 const auton=jwk.get_jwk_claim("n").as_string();// 模数 const autoe=jwk.get_jwk_claim("e").as_string();// 指数 if(n.empty()||e.empty()) { qWarning() << "Modulus or exponent empty"; return false; }if(jwt.get_algorithm()!= "RS256") {// 本示例仅支持 RS256 qWarning() << "Unsupported algorithm:" << jwt.get_algorithm(); return false; }if(jwk.get_jwk_claim("kty").as_string()!= "RSA") { qWarning() << "Unsupported key type:" << jwk.get_jwk_claim("kty").as_string(); return false; }if(jwk.has_jwk_claim("use")&&jwk.get_jwk_claim("use").as_string()!= "sig") { qWarning() << "Key not for signature" << jwk.get_jwk_claim("use").as_string(); return false; }// Simple minimal verification (omits special cases and eg. 'sub' verification) . // jwt-cpp does check also 'exp', 'iat', and 'nbf' if they are present. const autokeyPEM=jwt::helper::create_public_key_from_rsa_components(n,e);autoverifier=jwt::verify().allow_algorithm(jwt::algorithm::rs256(keyPEM)) .with_claim("nonce",jwt::claim(m_oauth.nonce().toStdString()) .with_issuer(m_oidcConfig->value("issuer"_L1).toString().toStdString()) .with_audience(std::string(clientIdentifier.data()) .leeway(60UL); verifier.verify(jwt); qDebug() << "ID Token verified successfully"; return true; }catch(conststd::exception&e) {// 处理错误。或者将错误参数传递给 jwt-cpp 调用 qWarning() << "ID Token verification failed" << e.what(); return false; }
读取 ID 标记值
ID 令牌采用JSON Web 令牌 (JWT)格式,由标题、有效载荷和签名部分组成,以点分隔.
。
读取 ID 令牌的值非常简单。举例来说,假设有一个结构体:
struct IDToken { QJsonObject header; QJsonObject payload; QByteArray signature; };
和一个函数:
std::optional<IDToken> parseIDToken(const QString &token) const;
可以用以下方法提取令牌:
if (token.isEmpty()) return std::nullopt; QList<QByteArray> parts = token.toLatin1().split('.'); if (parts.size() != 3) return std::nullopt; QJsonParseError parsing; QJsonDocument header = QJsonDocument::fromJson( QByteArray::fromBase64(parts.at(0), QByteArray::Base64UrlEncoding), &parsing); if (parsing.error != QJsonParseError::NoError || !header.isObject()) return std::nullopt; QJsonDocument payload = QJsonDocument::fromJson( QByteArray::fromBase64(parts.at(1), QByteArray::Base64UrlEncoding), &parsing); if (parsing.error != QJsonParseError::NoError || !payload.isObject()) return std::nullopt; QByteArray signature = QByteArray::fromBase64(parts.at(2), QByteArray::Base64UrlEncoding); return IDToken{header.object(), payload.object(), signature};
在某些情况下,令牌可能加密为JSON Web Encryption (JWE),内部包含一个 JWT 令牌。在这种情况下,必须先解密令牌。
OpenID Connect 发现
OpenID Connect Discovery定义了发现所需 OpenID 提供商详细信息的方法,以便与其进行交互。这包括authorization_endpoint
和token_endpoint
URL 等信息。
虽然这些提供商详细信息可以在应用程序中进行静态配置,但在运行时发现这些详细信息可以在与各种提供商交互时提供更大的灵活性和健壮性。
获取发现文档是一个简单的 HTTP GET 请求。该文档通常位于https://<domain name>/.well-known/openid_configuration
中。
m_network->get(request, this, [this](QRestReply &reply) { if (reply.isSuccess()) { if (auto doc = reply.readJson(); doc && doc->isObject()) m_oidcConfig = doc->object(); // Store the configuration } });
值得注意的是,对于令牌验证,jwks_uri字段提供了访问当前(公共)安全凭证的链接。使用该链接就无需在应用程序中直接硬编码此类凭证。这也有助于密钥轮换;供应商可能会不时更改使用的密钥,因此确保使用最新密钥非常重要。
获取密钥同样只需简单的 HTTP GET 请求:
m_network->get(request, this, [this](QRestReply &reply) { if (reply.isSuccess()) { if (auto doc = reply.readJson(); doc && doc->isObject()) m_jwks = doc; // Use the keys later to verify tokens } });
密钥集通常包含多个密钥。正确的密钥会在 JWT 标头中显示(必须注意正确匹配密钥,仅检查密钥 id(kid
)字段是不够的)。
OpenID 用户信息端点
访问用户信息的另一种方法是使用OpenID Connect UserInfo Endpoint(如果 OIDC 提供商支持)。用户信息的 URL 位于OpenID Connect Discovery文档的userinfo_endpoint
字段。
UserInfo 端点不使用 ID 令牌,而是使用访问令牌进行访问。访问 UserInfo 与使用访问令牌访问其他资源类似。
假设已收到访问令牌并设置了访问令牌,例如:
QNetworkRequestFactory userInfoApi(url); userInfoApi.setBearerToken(m_oauth.token().toLatin1());
那么访问 UserInfo 就是 HTTP GET 请求:
m_network->get(userInfoApi.createRequest(), this, [this](QRestReply用户信息){if(reply.isSuccess()) {if(autodoc=reply.readJson(); doc&& doc->isObject()) qDebug() << doc->object(); // Use the userinfo });
© 2025 The Qt Company Ltd. Documentation contributions included herein are the copyrights of their respective owners. The documentation provided herein is licensed under the terms of the GNU Free Documentation License version 1.3 as published by the Free Software Foundation. Qt and respective logos are trademarks of The Qt Company Ltd. in Finland and/or other countries worldwide. All other trademarks are property of their respective owners.