简述: 本文介绍本地客户端 APP 如何通过 cpp-httpliblibcurl 库实现 OAuth 2.0 的授权码流程。侧重于技术人员实现视角如何实现,重点包括以下几个方面:

  • 什么是 OAuth 2.0?它的授权流程是怎样的?
  • cpp-httpliblibcurl 的区别和差异?
  • 如何在本地通过网络库监听并获取默认浏览器返回的授权码 code?
  • 如何使用授权码 code 置换最终的 Token?

[TOC]


本文初发于 “偕臧的小站“,同步转载于此。


  💻 win11 24H2 📎 Visual Studio 2022 📎 C++17


OAuth 2.0 简介

关于 OAuth 2.0 的基本概念,可以参考阮一峰老师的 《理解OAuth 2.0》 以及 Google 的官方文档 《使用 OAuth 2.0 访问 Google API》

其中的 OAuth 2.0 访问链接的参数含义,和必须和可选、含义:

背景

在一些桌面应用(例如 Google 账号登录)中,过去常用 C# WebView2 内嵌浏览器来加载登录页。用户输入账号和密码后,应用即可获取授权凭证,用于后续调用业务 API。

然而这种方式逐渐暴露出风险与限制:

  1. WebView2 中账号密码可能存在泄露风险;
  2. Google 等厂商逐步收缩权限,不再推荐使用内嵌 WebView 登录;
  3. 企业后台安全审核趋严,未来将全面禁止 WebView2 方式的认证。

因此,更安全、更合规的做法是通过 系统默认浏览器(如 Chrome、Firefox)完成登录,并将结果回调给应用。


cpp-httplib 与 libcurl 的区别

在 OAuth 2.0 授权码流程中,获取授权码使用授权码换取 Token的过程,需要用到不同的库:

  • cpp-httplib 可在本地开启 HTTP 服务,监听浏览器的重定向回调并获取授权码。
  • libcurl:擅长发起 HTTP 请求,用于后续向授权服务器请求 Token。

核心点: cpp-httplib 能监听回调,而 libcurl 做不到。

🔍 操作系统对自定义协议的处理

当浏览器访问自定义协议(如 my-app://token?code=xxx)时,操作系统会根据注册信息启动本地应用,并将 URL 作为参数传递进去。在 Windows 上,这通常表现为:

  • 通过命令行参数传递
  • 或通过 WM_COPYDATA 消息传递

libcurl 仅是一个 HTTP 客户端库,无法拦截或处理操作系统层面的自定义协议调用,因此它无法直接获取浏览器重定向中的授权码。

🧩 为什么 cpp-httplib 能监听重定向

cpp-httplib 内置了轻量级 HTTP/HTTPS 服务器。应用可以在本地开启一个端口(如 http://127.0.0.1:8080),并将此端口写入 OAuth 2.0 授权请求的 redirect_uri

当用户在浏览器完成登录后,授权服务器会将页面重定向到该地址,cpp-httplib 即可接收到请求,并提取出授权码。

🛑 为什么 libcurl 无法做到

libcurl 的定位是 发起请求,它不能充当 HTTP 服务端。也就是说,它只能用来交换 Token,而不能用来监听浏览器重定向。


整体流程示例图

结合上述两个库的特点,可以总结为:

  • 授权码获取:依赖 cpp-httplib 本地监听重定向
  • Token 置换:使用 libcurl 发送请求获取

流程如下:

+-------------------+       1. 打开浏览器        +----------------------+
|   本地客户端 exe    | -----------------------> |      浏览器           |
|  (my-app.exe)     |                          |                      |
+-------------------+                          +----------------------+
         |                                               |
         | 2. 浏览器访问授权 URL                            |
         |    https://authserver.com/authorize?client_id=xxx
         v                                               v
+-------------------+                           +----------------------+
|     授权服务器     | <----------------------    |   用户登录/授权        |
|                   |        3. 登录/授权         |                    |
+-------------------+                           +----------------------+
         |
         | 4. 重定向到客户端注册的 redirect_uri (二选一)
         |    [a] 自定义协议:my-app://token?code=xxxx   (推荐)
         |    [b] 回环浏览器方式:http://127.0.0.1:8080/callback?code=xxxx (企业Product环境不允许)
         v
+-------------------+
| 本地客户端 exe      |
| httplib 监听端口   |
| 收到 code          |
+-------------------+
         |
         | 5. 使用 libcurl 发 POST 请求换 token
         v
+-------------------+
|   授权服务器       |
|   /token 接口     |
+-------------------+
         |
         | 6. 返回 JSON {access_token, refresh_token, ...}
         v    使用 access_token 来调用后续业务逻辑接口
+-------------------+
| 本地客户端 exe      |
| 收到并保存 token    |
+-------------------+

代码示例

Google 官方示例代码

Google 官方在 GitHub 上给出了一个简易的 C# 工程示例 oauth-apps-for-windows,展示用 Google 账号登录 oauth 2.0 的流程。

对于公司适配,对于 OAuthUniversalApp 工程,通常只需要修改 clientID 等三个字段,即可将 Google账号 切换为企业集团的 XXXXID账号,可以成功运行跑通。 而另一个两个工程,需要自己适配修改则更多一些。

const string clientID = "xxxxxxxxxxxxxxxxxxxxxxxx";
const string redirectURI = "pw.oauth2:/oauth2redirect";              // 通常还需要企业面板注册和配置才能生效
const string authorizationEndpoint = "https://passport.xxxxxx.com/v1.0/xxxxxx/oauth2/authorize";

自行实现纯 C++ 示例代码

Google 使用示例 OAuth for Apps: Samples for Windows,更多还是参考含义;对于实际工程中直接集成并不适用。还是需要自己写一套来实现适配。

压轴代码来了,我们希望使用存 C++ 来实现这个项目,便于项目的集成。


监听和捕获浏览器的重定向的响应

关于 4.重定向到客户端注册的 redirect_uri 这步骤中,实现有两种方式:

  1. 本地回环方式:通过监听本地浏览器返回的 http://127.0.0.1:8080/callback?code=xxxx ,捕获到所需要 code;但这种方法在 product 环境中,经常会遇到不被允许,但若是正经域名方式可以。即使 Test 环境允许,也需要在 面板上面自行注册,再后台同事来配置等待半个工作日才能生效
  2. 自定义协议(推荐): my-app://token?code=xxxx; 推荐的实现,其中跳转也有两种方式。
    • 一个是将另一个 exe 程序在 注册表指定路径进行注册,再通过调用传参数来获取 code
    • 使用管道来实现,实测更加靠谱(推荐)

示例代码流程

简化版调用流程:主实例负责监听,次实例负责转交 URI,最终由主实例拿到授权码并换 Token。

示例程序实现了 单实例 + 自定义协议回调 + 实例间通过命名管道传 URI 的流程。核心思路是:

  • 程序用一个命名 Mutex 判断“是否为首个实例”。
  • 如果是首个实例
    • 启动一个后台线程作为 命名管道服务器(等待其他实例连接并读取对方写入的字符串)。
    • 把自定义协议 accesories-udcc 注册到当前用户的注册表(HKCU),并打开浏览器发起 OAuth 授权(auth_url)。
    • 首实例等待通过管道收到后续回调(例如包含 code 的 URI),收到后解析并处理(当前只是打印 code/state)。
  • 如果是后续启动的实例(通常由浏览器在 OAuth 回调时启动):
    • 识别到不是首实例后,把命令行参数(浏览器传来的 accesories-udcc://...)通过命名管道写给首实例,然后退出。

这样做的好处:授权流程只在一个主进程里做实际处理,浏览器直接触发一个新进程(由系统打开自定义协议),新进程把 URI 转交给主进程后自己退出,避免多重并行处理或 UI 冲突。


完整源码

// main.cpp 只保留通用代码,自行替换一些实际参数即可正常编译和运行
// 使用命名管道 (\\.\pipe\MyApp_Pipe_v1) 在主/子进程间传递 my-app:// URI
// 特点:主实例创建管道服务器线程;新实例尝试连接管道并写入 URI(然后退出)。

#include <windows.h>
#include <shellapi.h>
#include <shlwapi.h>
#include <string>
#include <map>
#include <sstream>
#include <iostream>
#include <thread>
#include <vector>

#pragma comment(lib, "Shlwapi.lib")

using std::string;
using std::map;
using std::cout;
using std::endl;

// 常量
const char* PIPE_NAME = R"(\\.\pipe\MyApp_Pipe_v1)";
const char* MUTEX_NAME = "Local\\MyApp_Mutex_v1";

//////////////////////////////////////////////////////////////////
// 工具函数(URL 解码、解析 query/fragment、exe 路径等)
//////////////////////////////////////////////////////////////////

string GetExePath() {
    char buf[MAX_PATH];
    DWORD n = GetModuleFileNameA(nullptr, buf, MAX_PATH);
    if (n == 0) return "";
    return string(buf, n);
}

string ToLowerAscii(const string& s) {
    string r = s;
    for (char& c : r) if (c >= 'A' && c <= 'Z') c = char(c - 'A' + 'a');
    return r;
}

string PercentDecode(const string& s) {
    string out;
    out.reserve(s.size());
    auto hex = [](char c)->int {
        if (c >= '0' && c <= '9') return c - '0';
        if (c >= 'A' && c <= 'F') return c - 'A' + 10;
        if (c >= 'a' && c <= 'f') return c - 'a' + 10;
        return -1;
        };
    for (size_t i = 0; i < s.size(); ++i) {
        if (s[i] == '%' && i + 2 < s.size()) {
            int h = hex(s[i + 1]);
            int l = hex(s[i + 2]);
            if (h >= 0 && l >= 0) {
                out.push_back(char((h << 4) | l));
                i += 2;
                continue;
            }
        }
        if (s[i] == '+') out.push_back(' ');
        else out.push_back(s[i]);
    }
    return out;
}

map<string, string> ParseParamsFromUri(const string& uri) {
    map<string, string> out;
    size_t hashPos = uri.find('#');
    size_t qPos = uri.find('?');

    auto parse_part = [&](size_t start, size_t end) {
        if (start == string::npos || start >= end) return;
        string part = uri.substr(start, end - start);
        size_t pos = 0;
        while (pos < part.size()) {
            size_t amp = part.find('&', pos);
            string token = part.substr(pos, (amp == string::npos ? part.size() : amp) - pos);
            size_t eq = token.find('=');
            if (eq != string::npos) {
                string k = token.substr(0, eq);
                string v = token.substr(eq + 1);
                out[PercentDecode(k)] = PercentDecode(v);
            }
            else {
                out[PercentDecode(token)] = "";
            }
            if (amp == string::npos) break;
            pos = amp + 1;
        }
        };

    if (qPos != string::npos) {
        size_t qStart = qPos + 1;
        size_t qEnd = (hashPos != string::npos) ? hashPos : uri.size();
        if (qStart < qEnd) parse_part(qStart, qEnd);
    }
    if (hashPos != string::npos) {
        size_t fStart = hashPos + 1;
        size_t fEnd = uri.size();
        if (fStart < fEnd) parse_part(fStart, fEnd);
    }
    return out;
}

//////////////////////////////////////////////////////////////////
// 命名管道服务器(在主实例中运行)
// 监听管道连接,读取客户端写入的 URI 字符串(以 '\0' 结尾或读取长度)
// 收到后交给 ProcessReceivedUri 处理(当前示例只是打印并解析 code/state)
//////////////////////////////////////////////////////////////////

void ProcessReceivedUri(const string& uri) {
    cout << "[Server] 收到 URI: " << uri << endl;
    auto params = ParseParamsFromUri(uri);
    if (params.count("code")) cout << "[Server] code = " << params["code"] << endl;
    if (params.count("state")) cout << "[Server] state = " << params["state"] << endl;
    // TODO: 在这里继续做 code->token 的交换(HTTP 请求)
}

void PipeServerThreadProc() {
    while (true) {
        // 创建命名管道(单实例:每次连接后 Disconnect 并继续)
        HANDLE hPipe = CreateNamedPipeA(
            PIPE_NAME,
            PIPE_ACCESS_INBOUND, // 只读(客户端写入)
            PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT,
            1,          // 最多一个实例连接
            4096,       // out buffer
            4096,       // in buffer
            0,
            NULL
        );
        if (hPipe == INVALID_HANDLE_VALUE) {
            cout << "[Server] CreateNamedPipeA 失败, GetLastError=" << GetLastError() << endl;
            std::this_thread::sleep_for(std::chrono::seconds(1));
            continue;
        }
        // 等待客户端连接
        BOOL connected = ConnectNamedPipe(hPipe, NULL) ? TRUE : (GetLastError() == ERROR_PIPE_CONNECTED);
        if (!connected) {
            cout << "[Server] ConnectNamedPipe 失败, GetLastError=" << GetLastError() << endl;
            CloseHandle(hPipe);
            continue;
        }
        // 客户端已连接,读取数据
        std::vector<char> buf(4096);
        DWORD bytesRead = 0;
        BOOL readOk = ReadFile(hPipe, buf.data(), (DWORD)buf.size() - 1, &bytesRead, NULL);
        if (!readOk || bytesRead == 0) {
            cout << "[Server] ReadFile 失败或无数据, GetLastError=" << GetLastError() << endl;
            // 断开并继续循环
            DisconnectNamedPipe(hPipe);
            CloseHandle(hPipe);
            continue;
        }
        // 确保以 '\0' 终止
        if (bytesRead >= buf.size()) bytesRead = (DWORD)buf.size() - 1;
        buf[bytesRead] = '\0';
        string uri(buf.data(), bytesRead);
        ProcessReceivedUri(uri);

        // 关闭/断开
        DisconnectNamedPipe(hPipe);
        CloseHandle(hPipe);
        // 继续等待下一个客户端
    }
}

//////////////////////////////////////////////////////////////////
// 发送端(新实例):尝试连接命名管道并写入 URI(带重试)
// 返回 true 表示发送成功(主实例会处理),false 表示发送失败
//////////////////////////////////////////////////////////////////

bool SendUriToPipeServer(const string& uri, int maxRetries = 8, int retryDelayMs = 200) {
    for (int i = 0; i < maxRetries; ++i) {
        // 尝试打开管道
        HANDLE hPipe = CreateFileA(
            PIPE_NAME,
            GENERIC_WRITE,
            0,
            NULL,
            OPEN_EXISTING,
            0,
            NULL
        );
        if (hPipe != INVALID_HANDLE_VALUE) {
            // 成功连接,写入数据(包含 '\0')
            DWORD bytesWritten = 0;
            BOOL ok = WriteFile(hPipe, uri.c_str(), (DWORD)uri.size() + 1, &bytesWritten, NULL);
            if (!ok) {
                cout << "[Client] WriteFile 失败, GetLastError=" << GetLastError() << endl;
                CloseHandle(hPipe);
                return false;
            }
            cout << "[Client] 成功写入管道, bytesWritten=" << bytesWritten << endl;
            CloseHandle(hPipe);
            return true;
        }
        else {
            DWORD err = GetLastError();
            // ERROR_FILE_NOT_FOUND 表示服务器未创建管道(主实例尚未就绪)
            if (err == ERROR_FILE_NOT_FOUND) {
                // 等待并重试
                // cout << "[Client] 管道不存在,等待重试..." << endl;
                Sleep(retryDelayMs);
                continue;
            }
            else {
                cout << "[Client] CreateFile 打开管道失败, GetLastError=" << err << endl;
                Sleep(retryDelayMs);
                continue;
            }
        }
    }
    cout << "[Client] 已达最大重试次数,无法连接管道发送 URI" << endl;
    return false;
}

//////////////////////////////////////////////////////////////////
// main:单实例判断、管道发送/接收、打开浏览器(保留 response_mode=fragment)
//////////////////////////////////////////////////////////////////

int main(int argc, char* argv[]) {
    // 创建命名 Mutex 判断是否首个实例
    HANDLE hMutex = CreateMutexA(NULL, FALSE, MUTEX_NAME);
    if (!hMutex) {
        cout << "CreateMutex 失败: " << GetLastError() << endl;
        return 1;
    }
    bool firstInstance = (GetLastError() != ERROR_ALREADY_EXISTS);
    cout << "firstInstance = " << (firstInstance ? "true" : "false") << endl;

    // 若以协议方式启动,argv[1] 里可能包含 URI(或被引号包裹)
    string rawArg;
    if (argc >= 2) {
        rawArg = argv[1];
        if (!rawArg.empty() && rawArg.front() == '"' && rawArg.back() == '"') rawArg = rawArg.substr(1, rawArg.size() - 2);
    }

    // 对 argv 做 PercentDecode(把 %23 -> '#' 等恢复)
    string decodedArg = PercentDecode(rawArg);

    if (!firstInstance) {
        // 新实例:尝试通过命名管道把 URI 发给主实例(如果带 URI)
        cout << "[Client] 非首实例,尝试通过命名管道发送 URI(如果存在)..." << endl;
        if (!decodedArg.empty()) {
            cout << "[Client] 将发送的 URI (decoded) = " << decodedArg << endl;
            bool sent = SendUriToPipeServer(decodedArg, 12, 200);
            if (!sent) {
                cout << "[Client] 通过管道发送失败,可能主实例未就绪或权限问题。" << endl;
                // 失败后选择退出
            }
            else {
                cout << "[Client] 发送成功,进程退出。" << endl;
                return 0;
            }
        }
        else {
            cout << "[Client] 没有 URI 参数,直接退出。" << endl;
        }
        return 0;
    }

    // 首个实例:启动命名管道服务器线程
    std::thread serverThread(PipeServerThreadProc);
    serverThread.detach();
    cout << "[Server] 命名管道服务器线程已启动,管道名=" << PIPE_NAME << endl;

    // 首个实例:注册自定义协议(HKCU,免管理员)
    string keyErr;
    // 注册函数:写入 HKCU\Software\Classes\my-app\shell\open\command = "exe" "%1"
    // 你可以在安装程序中替代此步骤
    HKEY hKey = NULL;
    string keyRoot = "Software\\Classes\\my-app";
    LONG r = RegCreateKeyExA(HKEY_CURRENT_USER, keyRoot.c_str(), 0, nullptr,
        REG_OPTION_NON_VOLATILE, KEY_WRITE, nullptr, &hKey, nullptr);
    if (r == ERROR_SUCCESS) {
        string desc = "URL:my-app Protocol";
        RegSetValueExA(hKey, nullptr, 0, REG_SZ, (const BYTE*)desc.c_str(), DWORD(desc.size() + 1));
        RegSetValueExA(hKey, "URL Protocol", 0, REG_SZ, (const BYTE*)"", 1);
        RegCloseKey(hKey);

        string cmdKey = keyRoot + "\\shell\\open\\command";
        HKEY hCmd = NULL;
        r = RegCreateKeyExA(HKEY_CURRENT_USER, cmdKey.c_str(), 0, nullptr,
            REG_OPTION_NON_VOLATILE, KEY_WRITE, nullptr, &hCmd, nullptr);
        if (r == ERROR_SUCCESS) {
            string exePath = GetExePath();
            string cmd = "\"" + exePath + "\" \"%1\"";
            RegSetValueExA(hCmd, nullptr, 0, REG_SZ, (const BYTE*)cmd.c_str(), DWORD(cmd.size() + 1));
            RegCloseKey(hCmd);
            cout << "[Server] 已在 HKCU 注册 my-app 协议(当前用户生效)。" << endl;
        }
        else {
            cout << "[Server] 创建 command 子键失败: " << r << endl;
        }
    }
    else {
        cout << "[Server] 创建注册表键失败: " << r << endl;
    }

    // 如果首个实例启动时带有 URI(例如浏览器直接以首实例方式启动),解析并处理
    if (!decodedArg.empty() && ToLowerAscii(decodedArg).rfind("my-app://", 0) == 0) {
        cout << "[Server] 启动时携带 deep-link: " << decodedArg << endl;
        ProcessReceivedUri(decodedArg);
    }

    // 构造 auth_url(保持你最初要求,不修改 response_mode=fragment)
    string redirect_uri = "my-app://token";
    const string auth_url = std::string("https://account.xxxx.com/auth/xxxx/xxxx/auth")
        + "?client_id=my-app"
        + "&state=xxxx"
        + "&response_mode=fragment"
        + "&response_type=code"
        + "&nonce=xxxx"
        + "&scope=xxxx"
        + "&redirect_uri=" + redirect_uri;  // 重点

    // 打开浏览器发起授权(仅用于测试或演示)
    ShellExecuteA(nullptr, "open", auth_url.c_str(), nullptr, nullptr, SW_SHOWNORMAL);
    cout << "[Server] 已打开浏览器,auth_url=" << auth_url << endl;
    cout << "[Server] 等待通过命名管道传入的 my-app:// 回调..." << endl;

    // 简单的消息循环:这里用 GetMessage 以保持进程活跃(便于 Ctrl+C 停止)
    // 你可以把主循环替换为你的 GUI/应用主循环
    MSG msg;
    while (GetMessage(&msg, NULL, 0, 0)) {
        TranslateMessage(&msg);
        DispatchMessage(&msg);
    }

    // 清理(永远到不了这里,除非 PostQuitMessage)
    CloseHandle(hMutex);
    return 0;
}

libcurl 请求用授权码 code 置换 token

下面仅简要说明使用 libcurl 发起用 Code 来置换 Token 请求;

curl --location --request POST "https://account.xxxx.xxxx.com/auth/xxxx/xxxxxxx/xxxxx/xxxxxx/token" \
--header "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "code=xxx-long-code-xxx" \
--data-urlencode "grant_type=xxxx" \
--data-urlencode "client_id=my-app" \
--data-urlencode "redirect_uri=my-app://token"

可以看到返回我们所需要的 access_token, 至此即完成

{
	"access_token": "eyJhbGciOiJSU超级长一段字符串3YV_8_vA",
	"expires_in": xxxx,
	.....
	"scope": "xxxx1 xxxx2"
}

编译 Libcurl 指南

更进一步完善: 简单的验证方式直接在 CMD 终端中,调用上面 CURL 命令来验证可行性,再切换为调用 Libcurl 库来实现。

对于 Libcurl 不熟悉的话,可以查看写的 《C++ 编译和运行 LibCurl 动态库和静态库》 系列文章。


系列地址

QtExamples 欢迎 star ⭐ 和 fork 🍴 这个系列的 C++ / QT / DTK 学习,附学习由浅入深的目录,这里你可以学到如何亲自编写这类软件的经验,这是一系列完整的教程,并且永久免费