Qt OAuth2 概述

OAuth2

RFC 6749 OAuth 2.0定义了一种授权框架,可在不暴露密码等敏感用户凭证的情况下实现资源授权。

OAuth2 框架定义了几种客户端类型(公开和保密)以及流程(隐式、授权代码和其他几种)。对于典型的 Qt 应用程序,客户端类型应视为公共本地应用程序。公开意味着该应用程序不信任嵌入二进制文件中的秘密(如密码)。

RFC 8252 OAuth 2.0 for Native Apps进一步定义了此类应用程序的最佳实践。其中,它将授权代码流定义为推荐流程,因此QtNetworkAuth 提供了该流程的具体实现。

自 Qt 6.9 起,QtNetworkAuth 还提供了对RFC 8628 OAuth 2.0 设备授权授予的支持。该设备流程适用于输入能力有限的连接设备,或者使用用户代理或浏览器不切实际的设备。此类设备包括电视、媒体控制台、机器人机界面和物联网设备。

下表重点介绍了QtNetworkAuth 模块支持的两个具体 OAuth2 流程的主要方面:

方面授权代码流程设备授权流程
网络连接网络连接
用户交互同一设备上的浏览器/用户代理不同设备上的浏览器/用户代理
需要重定向处理需要不需要
设备上的输入能力丰富的输入功能有限或无输入功能
目标桌面和移动应用程序电视、控制台、人机界面、物联网设备

OAuth2 需要使用用户代理(通常是浏览器)。有关详细信息,请参阅Qt OAuth2 浏览器支持

Qt OAuth2 类

QtNetworkAuth 提供了具体和抽象的 OAuth2 类。抽象类用于实现自定义流程,而具体类则提供具体实现。

QtNetworkAuth Qt OAuth2 有两个用于实现 OAuth2 流程的抽象类:

授权代码流程

授权代码流是 推荐用于本地应用程序(如 Qt 应用程序)的OAuth2 流程

下面的代码片段提供了一个设置示例:

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();
}

阶段

授权代码流有两个主要阶段:资源授权(包括必要的用户身份验证)和访问令牌请求。随后是使用访问令牌和刷新访问令牌。下图说明了这些阶段:

  • 在授权阶段,用户通过身份验证,并授权访问资源。这需要用户与浏览器交互。
  • 授权后,收到的授权码将用于申请访问令牌,也可用于申请刷新令牌。
  • 一旦获得访问令牌,应用程序就可以使用它访问感兴趣的资源。访问令牌包含在资源请求中,由资源服务器来验证令牌的有效性。在请求中包含令牌有多种方法,但在HTTPAuthorization 标头中包含令牌可以说是最常见的方法
  • 刷新访问令牌。访问令牌的过期时间通常相对较短,比如一小时后。如果应用程序除了访问令牌外还收到了刷新令牌,则可使用刷新令牌请求新的访问令牌。刷新令牌的有效期较长,应用程序可将其持久化,以避免进入新的授权阶段(从而再次与浏览器交互)。

细节和定制

OAuth2 流程是动态的,一开始很难了解其细节。下图说明了成功授权代码流的主要细节。

为了清晰起见,图中省略了一些不常用的信号,但总的来说说明了细节和主要定制点。自定义点是应用程序可以捕捉(和调用)的各种信号/插槽,以及可通过QAbstractOAuth::setModifyParametersFunction() 和QAbstractOAuth2::setNetworkRequestModifier() 设置的回调。

选择回复处理程序

决定使用或实施哪种回复处理程序取决于所使用的重定向uriredirect_uri 是浏览器在授权阶段结束后的重定向位置。

在本地应用程序中,RFC 8252 概述了三种主要的 URI 方案loopbackhttps 和私人使用。

  • 私人使用 URI:如果操作系统允许应用程序注册自定义 URI 方案,则可以使用。尝试打开带有此类自定义方案的 URL 时,将打开相关的本地应用程序。请参见QOAuthUriSchemeReplyHandler
  • HTTPS URI:如果操作系统允许应用程序注册自定义 HTTPS URL,则可以使用该 URL。尝试打开此 URL 将打开相关本地应用程序。如果操作系统支持,建议使用此方案。请参见QOAuthUriSchemeReplyHandler
  • 环回接口:这些接口通常用于桌面应用程序和开发过程中的应用程序。QOAuthHttpServerReplyHandler 设计用于通过设置本地服务器来处理这些 URI,以处理重定向。

如何选择取决于以下几个因素

  • 授权服务器供应商支持的重定向 URI。各厂商的支持情况不尽相同,通常针对特定的客户端类型和操作系统。此外,支持情况可能因应用程序是否发布而异。
  • 目标平台支持的重定向 URI 方案。
  • 应用程序特定的可用性、安全性和其他要求。

RFC 8252 建议使用https 方案,因为与其他方法相比,它具有安全性和可用性方面的优势。

设备授权流程

设备授权流适用于输入能力有限的连接设备,或者用户-代理/浏览器使用不实用的设备。

以下代码片段提供了一个设置示例:

m_deviceFlow.setAuthorizationUrl(QUrl(authorizationUrl)); m_deviceFlow.setTokenUrl(QUrl(accessTokenUrl)); m_deviceFlow.setRequestedScopeTokens({scope}); m_deviceFlow.setClientIdentifier(clientIdentifier);// 是否需要客户端密文取决于授权服务器m_deviceFlow.setClientIdentifierSecurity(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 码或电子邮件等。
  • 在等待用户完成授权时,设备流会轮询授权服务器以获取令牌。上一步收到的设备代码用于匹配授权会话。轮询间隔由授权服务器决定,通常为 5 秒。
  • 一旦用户接受(或拒绝)授权,授权服务器就会响应轮询请求,提供所需的令牌或错误代码(如果拒绝),授权即告完成。

细节和定制

下图详细说明了流程。该图还说明了有时可能需要的主要定制点(例如专有参数或额外的身份验证凭证)。

刷新令牌

完整的 OAuth2 流程需要用户交互,这可能会影响用户体验。为了尽量减少这些交互,可以从用户角度静默刷新令牌。

刷新令牌要求授权服务器在授权期间提供刷新令牌。提供刷新令牌由授权服务器决定:有些服务器始终提供刷新令牌,有些服务器从不提供刷新令牌,还有些服务器在授权请求中出现特定scope 时才提供刷新令牌。

下图详细说明了刷新令牌:

如上图所示,刷新令牌时也可使用常用的自定义点。

要在应用程序启动后刷新令牌,应用程序需要安全地持久化刷新令牌,并通过QAbstractOAuth2::setRefreshToken() 进行设置。QAbstractOAuth2::refreshTokens然后就可以调用 () 来请求新的令牌。

自 Qt XML 6.9 起,应用程序还可以使用刷新便利功能自动刷新令牌 - 参见QAbstractOAuth2::accessTokenAboutToExpire(),QAbstractOAuth2::autoRefresh, 和QAbstractOAuth2::refreshLeadTime

授权服务器一般不会说明刷新令牌的过期时间(除了服务器的文档)。有效期从几天到几个月不等,甚至更长。此外,与其他令牌一样,用户可以随时撤销令牌,从而使其失效。因此,使用QAbstractOAuth::requestFailed() 或QAbstractOAuth2::serverReportedErrorOccurred() 正确检测刷新尝试失败非常重要。

Qt OpenID Connect 支持

OpenID Connect (OIDC) 是OAuth2 协议之上的一个简单身份层。授权提供了授权用户执行操作的方法,而 OIDC 则可以建立用户的可信身份。

目前,Qt 对 OIDC 的支持仅限于获取ID 标记ID token 是一个JSON 网络令牌(JWT),其中包含有关身份验证事件的声明。

值得注意的是,目前还不支持ID token 验证或ID token 解密。

假设应用程序能够验证收到的令牌,那么令牌就可以用来可靠地确定用户身份(在 OIDC 提供商本身可信的范围内)。

身份令牌是敏感信息,应予以保密。ID 令牌不用于在应用程序接口调用中发送,访问令牌用于此目的。请注意,有些供应商可能会对访问令牌使用相同的JWT 格式,但这不能与使用相同格式的实际 ID 令牌混为一谈。对于 ID 令牌,接收令牌的客户端负责验证令牌,而对于访问令牌,则由接收令牌的资源服务器负责验证。

获取 ID 令牌

获取 ID 令牌与获取访问令牌非常相似。首先,我们需要设置适当的范围。授权服务器供应商可能会支持额外的范围指定符,如profileemail ,但所有 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 令牌是流程的关键部分,如果完全实现,也是一项有点复杂的任务

简而言之,验证包括以下步骤

  • 必要时对令牌进行解密(见 JWE)
  • 提取令牌头、有效载荷和签名
  • 验证签名
  • 验证有效载荷的字段(如aud, iss, exp, nonce, iat)

Qt 目前不支持 ID 令牌验证,但有几个 C++ 库可供选择,如jwt-cpp

ID 令牌验证示例

本节借助jwt-cpp库说明一个简单的验证。作为先决条件,开发环境需要有OpenSSL库,以及应用程序项目源代码目录下的jwt-cppinclude 文件夹。

在应用程序项目的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")

然后添加必要的 include 和库:

    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 token 后,就该对其进行验证了。首先,我们要从 JSON 网络密钥集(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 令牌值

ID 令牌采用JSON 网络令牌(JWT)格式,由标题、有效载荷和签名部分组成,以点 {'.'} 分隔。

读取 ID 令牌的值非常简单。举例来说,假设有一个 struct.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_endpointtoken_endpoint URL 等信息。

虽然这些提供商详细信息可以在应用程序中进行静态配置,但在运行时发现这些详细信息可以在与各种提供商交互时提供更大的灵活性和健壮性。

获取发现文档是一个简单的HTTP GET 请求。该文件通常位于https://<the-domain eg. example.com>/.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 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.