feat(net): ApiClient(QtNetwork 共享会话) + AuthService(验证码+RSA+login2)
This commit is contained in:
parent
d32cbbf7c4
commit
3d59387ab1
|
|
@ -0,0 +1,110 @@
|
||||||
|
#include "ApiClient.hpp"
|
||||||
|
|
||||||
|
#include <QByteArray>
|
||||||
|
#include <QEventLoop>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonParseError>
|
||||||
|
#include <QJsonValue>
|
||||||
|
#include <QNetworkAccessManager>
|
||||||
|
#include <QNetworkReply>
|
||||||
|
#include <QNetworkRequest>
|
||||||
|
#include <QUrl>
|
||||||
|
|
||||||
|
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>}
|
||||||
|
// 便于上层统一通过 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<Impl>(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
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QString>
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
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> impl_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace geopro::net
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
#include "AuthService.hpp"
|
||||||
|
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QJsonValue>
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
|
#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
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
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
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
find_package(OpenSSL REQUIRED)
|
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_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)
|
target_compile_features(geopro_net PUBLIC cxx_std_17)
|
||||||
set_target_properties(geopro_net PROPERTIES AUTOMOC OFF AUTOUIC OFF AUTORCC OFF)
|
set_target_properties(geopro_net PROPERTIES AUTOMOC OFF AUTOUIC OFF AUTORCC OFF)
|
||||||
|
|
|
||||||
|
|
@ -40,8 +40,11 @@ target_link_libraries(geopro_tests PRIVATE geopro_data)
|
||||||
# net 层:RSA 加密器。测试需直接用 OpenSSL 生成/解密密钥,故显式 find_package
|
# net 层:RSA 加密器。测试需直接用 OpenSSL 生成/解密密钥,故显式 find_package
|
||||||
# 并链接 OpenSSL(geopro_net 的 PUBLIC 链接通常已传递,这里显式以防头文件找不到)。
|
# 并链接 OpenSSL(geopro_net 的 PUBLIC 链接通常已传递,这里显式以防头文件找不到)。
|
||||||
find_package(OpenSSL REQUIRED)
|
find_package(OpenSSL REQUIRED)
|
||||||
|
find_package(Qt6 COMPONENTS Core Network REQUIRED)
|
||||||
target_sources(geopro_tests PRIVATE net/test_rsa.cpp)
|
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 等运行时
|
# geopro_data 链 Qt6::Core,测试 exe 运行(含 gtest 发现)需要 Qt6Core.dll 等运行时
|
||||||
# DLL 在旁。复用 app 同样的 TARGET_RUNTIME_DLLS POST_BUILD 拷贝。
|
# DLL 在旁。复用 app 同样的 TARGET_RUNTIME_DLLS POST_BUILD 拷贝。
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
// net 层端到端登录连通测试(真实站点)。
|
||||||
|
// 复刻已实测通过的流程:getImageCode -> verifyCodeCheck -> RSA -> login2,
|
||||||
|
// 关键在 ApiClient 内单一 QNetworkAccessManager 共享 JSESSIONID 会话。
|
||||||
|
// 需要 QCoreApplication 提供事件循环(ApiClient 用 QEventLoop 同步等待)。
|
||||||
|
// 网络不可达时本用例会失败(属环境问题,非逻辑问题)。
|
||||||
|
|
||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <fstream>
|
||||||
|
#include <sstream>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#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();
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue