diff --git a/src/net/ApiClient.cpp b/src/net/ApiClient.cpp new file mode 100644 index 0000000..30fc5b4 --- /dev/null +++ b/src/net/ApiClient.cpp @@ -0,0 +1,110 @@ +#include "ApiClient.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace geopro::net { + +namespace { + +constexpr int kHttpStatusUnset = 0; +const char* const kContentTypeJson = "application/json"; +const char* const kTokenHeader = "geomativeauthorization"; + +// 把响应体 JSON 解析进信封。解析失败或非对象时把原文/错误写入 rawError。 +void parseBody(const QByteArray& body, ApiResponse& resp) { + if (body.isEmpty()) { + resp.rawError = QStringLiteral("empty response body"); + return; + } + QJsonParseError perr{}; + const QJsonDocument doc = QJsonDocument::fromJson(body, &perr); + if (perr.error != QJsonParseError::NoError || !doc.isObject()) { + resp.rawError = QStringLiteral("JSON parse error: %1; body: %2") + .arg(perr.errorString(), QString::fromUtf8(body)); + return; + } + const QJsonObject obj = doc.object(); + resp.code = obj.value(QStringLiteral("code")).toInt(); + resp.msg = obj.value(QStringLiteral("msg")).toString(); + const QJsonValue dataVal = obj.value(QStringLiteral("data")); + if (dataVal.isObject()) { + resp.data = dataVal.toObject(); + } else { + // data 可能是标量(如 verifyCodeCheck 返回 true)。包成 {"value": } + // 便于上层统一通过 data["value"] 取用,同时不丢信息。 + resp.data = QJsonObject{{QStringLiteral("value"), dataVal}}; + } +} + +} // namespace + +struct ApiClient::Impl { + QString baseUrl; + QString token; + QNetworkAccessManager nam; // 唯一实例:保证 cookie / JSESSIONID 全程共享 + + explicit Impl(QString url) : baseUrl(std::move(url)) {} + + QNetworkRequest buildRequest(const QString& path) const { + QNetworkRequest req{QUrl(baseUrl + path)}; + req.setHeader(QNetworkRequest::ContentTypeHeader, QString::fromLatin1(kContentTypeJson)); + if (!token.isEmpty()) { + req.setRawHeader(QByteArray(kTokenHeader), token.toUtf8()); + } + return req; + } + + // 阻塞等待 reply 完成,解析为 ApiResponse。调用方负责 reply->deleteLater()。 + static ApiResponse await(QNetworkReply* reply) { + QEventLoop loop; + QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); + loop.exec(); + + ApiResponse resp; + const QVariant statusVar = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute); + resp.httpStatus = statusVar.isValid() ? statusVar.toInt() : kHttpStatusUnset; + + const QByteArray body = reply->readAll(); + if (reply->error() != QNetworkReply::NoError) { + resp.rawError = reply->errorString(); + } + // 即便有传输层错误,服务端仍可能回带 JSON 体(如 4xx);尽量解析。 + if (!body.isEmpty()) { + parseBody(body, resp); + } + return resp; + } +}; + +ApiClient::ApiClient(QString baseUrl) : impl_(std::make_unique(std::move(baseUrl))) {} + +ApiClient::~ApiClient() = default; + +void ApiClient::setToken(const QString& token) { impl_->token = token; } + +ApiResponse ApiClient::get(const QString& path) { + QNetworkRequest req = impl_->buildRequest(path); + QNetworkReply* reply = impl_->nam.get(req); + ApiResponse resp = Impl::await(reply); + reply->deleteLater(); + return resp; +} + +ApiResponse ApiClient::postJson(const QString& path, const QJsonObject& body) { + QNetworkRequest req = impl_->buildRequest(path); + const QByteArray payload = QJsonDocument(body).toJson(QJsonDocument::Compact); + QNetworkReply* reply = impl_->nam.post(req, payload); + ApiResponse resp = Impl::await(reply); + reply->deleteLater(); + return resp; +} + +} // namespace geopro::net diff --git a/src/net/ApiClient.hpp b/src/net/ApiClient.hpp new file mode 100644 index 0000000..77c1965 --- /dev/null +++ b/src/net/ApiClient.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include + +class QNetworkAccessManager; + +namespace geopro::net { + +// 服务端统一响应信封:{code, data, msg}。 +// httpStatus 为 HTTP 状态码(如 200);code/data/msg 解析自响应体 JSON。 +// 网络层错误(连接失败、超时、JSON 解析失败)写入 rawError,此时 httpStatus 可能为 0。 +struct ApiResponse { + int httpStatus = 0; + int code = 0; + QJsonObject data; + QString msg; + QString rawError; +}; + +// QtNetwork 的同步 HTTP 封装。 +// 内部持有【唯一一个】QNetworkAccessManager 成员,默认共享 cookie jar, +// 因此同一 ApiClient 实例发出的多次请求处于同一会话(共享 JSESSIONID)。 +// 这是登录流程 getImageCode -> verifyCodeCheck -> login2 串联的关键。 +class ApiClient { +public: + explicit ApiClient(QString baseUrl); + ~ApiClient(); + + ApiClient(const ApiClient&) = delete; + ApiClient& operator=(const ApiClient&) = delete; + + // 设置令牌;注入请求头 geomativeauthorization。token 值本身已含 "Geomative " 前缀。 + void setToken(const QString& token); + + // 同步 GET / POST(JSON)。用 QNetworkReply + QEventLoop 阻塞等待响应。 + ApiResponse get(const QString& path); + ApiResponse postJson(const QString& path, const QJsonObject& body); + +private: + struct Impl; + std::unique_ptr impl_; +}; + +} // namespace geopro::net diff --git a/src/net/AuthService.cpp b/src/net/AuthService.cpp new file mode 100644 index 0000000..e946093 --- /dev/null +++ b/src/net/AuthService.cpp @@ -0,0 +1,80 @@ +#include "AuthService.hpp" + +#include +#include +#include + +#include "ApiClient.hpp" +#include "crypto/RsaEncryptor.hpp" + +namespace geopro::net { + +namespace { + +constexpr int kCodeSuccess = 200; + +const char* const kPathImageCode = "/business/system/personalUser/getImageCode"; +const char* const kPathVerifyCode = "/business/system/personalUser/verifyCodeCheck"; +const char* const kPathLogin = "/admin/tenant/auth/login2"; + +// 统一的错误信息:优先用服务端 msg,否则回退到传输层 rawError,最后给通用文案。 +QString errorFrom(const ApiResponse& resp, const QString& fallback) { + if (!resp.msg.isEmpty()) return resp.msg; + if (!resp.rawError.isEmpty()) return resp.rawError; + return fallback; +} + +} // namespace + +AuthService::AuthService(ApiClient& api, std::string rsaPublicKeyPem) + : api_(api), rsaPublicKeyPem_(std::move(rsaPublicKeyPem)) {} + +AuthService::Captcha AuthService::fetchCaptcha() { + const ApiResponse resp = api_.get(QString::fromLatin1(kPathImageCode)); + Captcha cap; + if (resp.code != kCodeSuccess) { + return cap; // 失败时返回空 codeId/code,调用方据此判断 + } + cap.codeId = resp.data.value(QStringLiteral("id")).toString(); + cap.code = resp.data.value(QStringLiteral("code")).toString(); + return cap; +} + +AuthService::LoginResult AuthService::login(const QString& username, const QString& password, + const QString& code, const QString& codeId) { + // 1) 校验验证码(与 captcha 同会话)。 + const QJsonObject verifyBody{{QStringLiteral("code"), code}, + {QStringLiteral("codeId"), codeId}}; + const ApiResponse verify = api_.postJson(QString::fromLatin1(kPathVerifyCode), verifyBody); + if (verify.code != kCodeSuccess) { + return {false, QString(), errorFrom(verify, QStringLiteral("verifyCodeCheck failed"))}; + } + + // 2) RSA 加密密码(PKCS#1 v1.5 -> base64)。 + std::string encrypted; + try { + RsaEncryptor enc(rsaPublicKeyPem_); + encrypted = enc.encryptBase64(password.toStdString()); + } catch (const std::exception& e) { + return {false, QString(), + QStringLiteral("RSA encryption failed: %1").arg(QString::fromUtf8(e.what()))}; + } + + // 3) login2:checkCode 传空串。 + const QJsonObject loginBody{ + {QStringLiteral("username"), username}, + {QStringLiteral("password"), QString::fromStdString(encrypted)}, + {QStringLiteral("checkCode"), QString()}}; + const ApiResponse login = api_.postJson(QString::fromLatin1(kPathLogin), loginBody); + if (login.code != kCodeSuccess) { + return {false, QString(), errorFrom(login, QStringLiteral("login2 failed"))}; + } + + const QString token = login.data.value(QStringLiteral("accessToken")).toString(); + if (token.isEmpty()) { + return {false, QString(), QStringLiteral("login2 succeeded but accessToken missing")}; + } + return {true, token, QString()}; +} + +} // namespace geopro::net diff --git a/src/net/AuthService.hpp b/src/net/AuthService.hpp new file mode 100644 index 0000000..23573fc --- /dev/null +++ b/src/net/AuthService.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include +#include + +namespace geopro::net { + +class ApiClient; + +// 登录编排:复刻已实测通过的 getImageCode -> verifyCodeCheck -> RSA -> login2 流程。 +// 依赖外部注入的 ApiClient(单实例、共享会话);RSA 公钥 PEM 由调用方读取后传入。 +class AuthService { +public: + AuthService(ApiClient& api, std::string rsaPublicKeyPem); + + // 验证码:服务端把答案明文回传(data.code),答案 + data.id 用于下一步校验。 + struct Captcha { + QString codeId; + QString code; + }; + Captcha fetchCaptcha(); + + struct LoginResult { + bool ok = false; + QString token; + QString error; + }; + + // 校验验证码 -> RSA 加密密码 -> login2。成功返回 {true, accessToken, ""}; + // 任一步失败返回 {false, "", <服务端 msg 或本地错误>}。 + LoginResult login(const QString& username, const QString& password, const QString& code, + const QString& codeId); + +private: + ApiClient& api_; + std::string rsaPublicKeyPem_; +}; + +} // namespace geopro::net diff --git a/src/net/CMakeLists.txt b/src/net/CMakeLists.txt index f72be36..4741214 100644 --- a/src/net/CMakeLists.txt +++ b/src/net/CMakeLists.txt @@ -1,6 +1,10 @@ find_package(OpenSSL REQUIRED) -add_library(geopro_net STATIC crypto/RsaEncryptor.cpp) +find_package(Qt6 COMPONENTS Core Network REQUIRED) +add_library(geopro_net STATIC + crypto/RsaEncryptor.cpp + ApiClient.cpp + AuthService.cpp) target_include_directories(geopro_net PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) -target_link_libraries(geopro_net PUBLIC OpenSSL::SSL OpenSSL::Crypto) +target_link_libraries(geopro_net PUBLIC OpenSSL::SSL OpenSSL::Crypto Qt6::Core Qt6::Network) target_compile_features(geopro_net PUBLIC cxx_std_17) set_target_properties(geopro_net PROPERTIES AUTOMOC OFF AUTOUIC OFF AUTORCC OFF) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 4158cb2..f6f9264 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -40,8 +40,11 @@ target_link_libraries(geopro_tests PRIVATE geopro_data) # net 层:RSA 加密器。测试需直接用 OpenSSL 生成/解密密钥,故显式 find_package # 并链接 OpenSSL(geopro_net 的 PUBLIC 链接通常已传递,这里显式以防头文件找不到)。 find_package(OpenSSL REQUIRED) +find_package(Qt6 COMPONENTS Core Network REQUIRED) target_sources(geopro_tests PRIVATE net/test_rsa.cpp) -target_link_libraries(geopro_tests PRIVATE geopro_net OpenSSL::SSL OpenSSL::Crypto) +# 端到端登录连通测试(ApiClient + AuthService),需 Qt6::Core/Network 与事件循环。 +target_sources(geopro_tests PRIVATE net/test_auth.cpp) +target_link_libraries(geopro_tests PRIVATE geopro_net OpenSSL::SSL OpenSSL::Crypto Qt6::Core Qt6::Network) # geopro_data 链 Qt6::Core,测试 exe 运行(含 gtest 发现)需要 Qt6Core.dll 等运行时 # DLL 在旁。复用 app 同样的 TARGET_RUNTIME_DLLS POST_BUILD 拷贝。 diff --git a/tests/net/test_auth.cpp b/tests/net/test_auth.cpp new file mode 100644 index 0000000..21c683e --- /dev/null +++ b/tests/net/test_auth.cpp @@ -0,0 +1,45 @@ +// net 层端到端登录连通测试(真实站点)。 +// 复刻已实测通过的流程:getImageCode -> verifyCodeCheck -> RSA -> login2, +// 关键在 ApiClient 内单一 QNetworkAccessManager 共享 JSESSIONID 会话。 +// 需要 QCoreApplication 提供事件循环(ApiClient 用 QEventLoop 同步等待)。 +// 网络不可达时本用例会失败(属环境问题,非逻辑问题)。 + +#include + +#include +#include +#include +#include + +#include "ApiClient.hpp" +#include "AuthService.hpp" + +namespace { + +// 读取整个文件内容(PEM 公钥)。 +std::string slurp(const char* path) { + std::ifstream f(path); + std::stringstream s; + s << f.rdbuf(); + return s.str(); +} + +} // namespace + +TEST(AuthLiveTest, FullLoginFlowReturnsToken) { + int argc = 0; + char** argv = nullptr; + QCoreApplication app(argc, argv); + + geopro::net::ApiClient api("http://tenant.geomative.cn/pop-api"); + geopro::net::AuthService auth( + api, slurp("D:/Git/lanbingtech/geopro/resources/rsa_public_key.pem")); + + auto cap = auth.fetchCaptcha(); + ASSERT_FALSE(cap.codeId.isEmpty()); + ASSERT_FALSE(cap.code.isEmpty()); + + auto r = auth.login("sydk", "123456", cap.code, cap.codeId); + EXPECT_TRUE(r.ok) << r.error.toStdString(); + EXPECT_TRUE(r.token.startsWith("Geomative ")) << r.token.toStdString(); +}