feat(auth): add session-based auth service with tests
这个提交包含在:
@@ -6,11 +6,14 @@ cmake_policy(SET CMP0153 OLD)
|
|||||||
|
|
||||||
find_package(Drogon CONFIG REQUIRED)
|
find_package(Drogon CONFIG REQUIRED)
|
||||||
find_package(Catch2 3 REQUIRED)
|
find_package(Catch2 3 REQUIRED)
|
||||||
|
find_package(OpenSSL REQUIRED)
|
||||||
|
|
||||||
add_library(csp_core
|
add_library(csp_core
|
||||||
src/version.cc
|
src/version.cc
|
||||||
src/db/sqlite_db.cc
|
src/db/sqlite_db.cc
|
||||||
src/app_state.cc
|
src/app_state.cc
|
||||||
|
src/services/crypto.cc
|
||||||
|
src/services/auth_service.cc
|
||||||
)
|
)
|
||||||
|
|
||||||
target_include_directories(csp_core PUBLIC
|
target_include_directories(csp_core PUBLIC
|
||||||
@@ -19,6 +22,7 @@ target_include_directories(csp_core PUBLIC
|
|||||||
|
|
||||||
target_link_libraries(csp_core PUBLIC
|
target_link_libraries(csp_core PUBLIC
|
||||||
SQLite3_lib
|
SQLite3_lib
|
||||||
|
OpenSSL::Crypto
|
||||||
)
|
)
|
||||||
|
|
||||||
add_executable(csp_server
|
add_executable(csp_server
|
||||||
@@ -40,6 +44,7 @@ add_executable(csp_tests
|
|||||||
tests/test_main.cc
|
tests/test_main.cc
|
||||||
tests/version_test.cc
|
tests/version_test.cc
|
||||||
tests/sqlite_db_test.cc
|
tests/sqlite_db_test.cc
|
||||||
|
tests/auth_service_test.cc
|
||||||
)
|
)
|
||||||
|
|
||||||
target_link_libraries(csp_tests PRIVATE
|
target_link_libraries(csp_tests PRIVATE
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "csp/db/sqlite_db.h"
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace csp::services {
|
||||||
|
|
||||||
|
struct AuthResult {
|
||||||
|
int user_id = 0;
|
||||||
|
std::string token;
|
||||||
|
int64_t expires_at = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
class AuthService {
|
||||||
|
public:
|
||||||
|
explicit AuthService(db::SqliteDb& db) : db_(db) {}
|
||||||
|
|
||||||
|
// Throws on error; for controller we will catch and convert to JSON.
|
||||||
|
AuthResult Register(const std::string& username, const std::string& password);
|
||||||
|
AuthResult Login(const std::string& username, const std::string& password);
|
||||||
|
|
||||||
|
std::optional<int> VerifyToken(const std::string& token);
|
||||||
|
|
||||||
|
private:
|
||||||
|
db::SqliteDb& db_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace csp::services
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace csp::crypto {
|
||||||
|
|
||||||
|
std::string RandomHex(size_t bytes);
|
||||||
|
std::string Sha256Hex(const std::string& data);
|
||||||
|
|
||||||
|
} // namespace csp::crypto
|
||||||
145
backend/src/services/auth_service.cc
普通文件
145
backend/src/services/auth_service.cc
普通文件
@@ -0,0 +1,145 @@
|
|||||||
|
#include "csp/services/auth_service.h"
|
||||||
|
|
||||||
|
#include "csp/services/crypto.h"
|
||||||
|
|
||||||
|
#include <sqlite3.h>
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
|
namespace csp::services {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
int64_t NowSec() {
|
||||||
|
using namespace std::chrono;
|
||||||
|
return duration_cast<seconds>(system_clock::now().time_since_epoch()).count();
|
||||||
|
}
|
||||||
|
|
||||||
|
void CheckSqlite(int rc, sqlite3* db, const char* what) {
|
||||||
|
if (rc == SQLITE_OK || rc == SQLITE_DONE || rc == SQLITE_ROW) return;
|
||||||
|
throw std::runtime_error(std::string(what) + ": " + sqlite3_errmsg(db));
|
||||||
|
}
|
||||||
|
|
||||||
|
int64_t StepInt64(sqlite3_stmt* stmt, int col) {
|
||||||
|
return static_cast<int64_t>(sqlite3_column_int64(stmt, col));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string StepText(sqlite3_stmt* stmt, int col) {
|
||||||
|
const unsigned char* t = sqlite3_column_text(stmt, col);
|
||||||
|
return t ? reinterpret_cast<const char*>(t) : std::string();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
AuthResult AuthService::Register(const std::string& username,
|
||||||
|
const std::string& password) {
|
||||||
|
if (username.empty() || password.size() < 6) {
|
||||||
|
throw std::runtime_error("invalid username or password");
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto salt = crypto::RandomHex(16);
|
||||||
|
const auto hash = crypto::Sha256Hex(salt + ":" + password);
|
||||||
|
const auto now = NowSec();
|
||||||
|
|
||||||
|
sqlite3* db = db_.raw();
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
const char* sql =
|
||||||
|
"INSERT INTO users(username,password_salt,password_hash,created_at) "
|
||||||
|
"VALUES(?,?,?,?)";
|
||||||
|
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
|
||||||
|
"prepare insert user");
|
||||||
|
CheckSqlite(sqlite3_bind_text(stmt, 1, username.c_str(), -1, SQLITE_TRANSIENT),
|
||||||
|
db, "bind username");
|
||||||
|
CheckSqlite(sqlite3_bind_text(stmt, 2, salt.c_str(), -1, SQLITE_TRANSIENT), db,
|
||||||
|
"bind salt");
|
||||||
|
CheckSqlite(sqlite3_bind_text(stmt, 3, hash.c_str(), -1, SQLITE_TRANSIENT), db,
|
||||||
|
"bind hash");
|
||||||
|
CheckSqlite(sqlite3_bind_int64(stmt, 4, now), db, "bind created_at");
|
||||||
|
|
||||||
|
const int rc = sqlite3_step(stmt);
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
if (rc != SQLITE_DONE) {
|
||||||
|
// likely UNIQUE constraint
|
||||||
|
throw std::runtime_error(std::string("register failed: ") + sqlite3_errmsg(db));
|
||||||
|
}
|
||||||
|
|
||||||
|
const int user_id = static_cast<int>(sqlite3_last_insert_rowid(db));
|
||||||
|
// Auto-login
|
||||||
|
return Login(username, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthResult AuthService::Login(const std::string& username,
|
||||||
|
const std::string& password) {
|
||||||
|
sqlite3* db = db_.raw();
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
const char* sql =
|
||||||
|
"SELECT id,password_salt,password_hash FROM users WHERE username=?";
|
||||||
|
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
|
||||||
|
"prepare select user");
|
||||||
|
CheckSqlite(sqlite3_bind_text(stmt, 1, username.c_str(), -1, SQLITE_TRANSIENT),
|
||||||
|
db, "bind username");
|
||||||
|
|
||||||
|
const int rc = sqlite3_step(stmt);
|
||||||
|
if (rc != SQLITE_ROW) {
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
throw std::runtime_error("invalid credentials");
|
||||||
|
}
|
||||||
|
|
||||||
|
const int user_id = sqlite3_column_int(stmt, 0);
|
||||||
|
const auto salt = StepText(stmt, 1);
|
||||||
|
const auto stored = StepText(stmt, 2);
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
|
||||||
|
const auto computed = crypto::Sha256Hex(salt + ":" + password);
|
||||||
|
if (computed != stored) {
|
||||||
|
throw std::runtime_error("invalid credentials");
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto token = crypto::RandomHex(32);
|
||||||
|
const auto now = NowSec();
|
||||||
|
const auto expires = now + 7 * 24 * 3600;
|
||||||
|
|
||||||
|
sqlite3_stmt* ins = nullptr;
|
||||||
|
const char* ins_sql =
|
||||||
|
"INSERT INTO sessions(token,user_id,expires_at,created_at) VALUES(?,?,?,?)";
|
||||||
|
CheckSqlite(sqlite3_prepare_v2(db, ins_sql, -1, &ins, nullptr), db,
|
||||||
|
"prepare insert session");
|
||||||
|
CheckSqlite(sqlite3_bind_text(ins, 1, token.c_str(), -1, SQLITE_TRANSIENT), db,
|
||||||
|
"bind token");
|
||||||
|
CheckSqlite(sqlite3_bind_int(ins, 2, user_id), db, "bind user_id");
|
||||||
|
CheckSqlite(sqlite3_bind_int64(ins, 3, expires), db, "bind expires");
|
||||||
|
CheckSqlite(sqlite3_bind_int64(ins, 4, now), db, "bind created_at");
|
||||||
|
CheckSqlite(sqlite3_step(ins), db, "insert session");
|
||||||
|
sqlite3_finalize(ins);
|
||||||
|
|
||||||
|
return AuthResult{.user_id = user_id, .token = token, .expires_at = expires};
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<int> AuthService::VerifyToken(const std::string& token) {
|
||||||
|
if (token.empty()) return std::nullopt;
|
||||||
|
|
||||||
|
sqlite3* db = db_.raw();
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
const char* sql =
|
||||||
|
"SELECT user_id,expires_at FROM sessions WHERE token=?";
|
||||||
|
CheckSqlite(sqlite3_prepare_v2(db, sql, -1, &stmt, nullptr), db,
|
||||||
|
"prepare select session");
|
||||||
|
CheckSqlite(sqlite3_bind_text(stmt, 1, token.c_str(), -1, SQLITE_TRANSIENT),
|
||||||
|
db, "bind token");
|
||||||
|
|
||||||
|
const int rc = sqlite3_step(stmt);
|
||||||
|
if (rc != SQLITE_ROW) {
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int user_id = sqlite3_column_int(stmt, 0);
|
||||||
|
const int64_t expires = StepInt64(stmt, 1);
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
|
||||||
|
if (expires < NowSec()) return std::nullopt;
|
||||||
|
return user_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace csp::services
|
||||||
42
backend/src/services/crypto.cc
普通文件
42
backend/src/services/crypto.cc
普通文件
@@ -0,0 +1,42 @@
|
|||||||
|
#include "csp/services/crypto.h"
|
||||||
|
|
||||||
|
#include <openssl/sha.h>
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <fstream>
|
||||||
|
#include <iomanip>
|
||||||
|
#include <sstream>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace csp::crypto {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
std::string ToHex(const unsigned char* data, size_t len) {
|
||||||
|
std::ostringstream oss;
|
||||||
|
oss << std::hex << std::setfill('0');
|
||||||
|
for (size_t i = 0; i < len; ++i) {
|
||||||
|
oss << std::setw(2) << static_cast<int>(data[i]);
|
||||||
|
}
|
||||||
|
return oss.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::string RandomHex(size_t bytes) {
|
||||||
|
std::vector<unsigned char> buf(bytes);
|
||||||
|
std::ifstream urandom("/dev/urandom", std::ios::in | std::ios::binary);
|
||||||
|
if (!urandom) throw std::runtime_error("cannot open /dev/urandom");
|
||||||
|
urandom.read(reinterpret_cast<char*>(buf.data()), static_cast<std::streamsize>(bytes));
|
||||||
|
if (!urandom) throw std::runtime_error("failed to read /dev/urandom");
|
||||||
|
return ToHex(buf.data(), buf.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Sha256Hex(const std::string& data) {
|
||||||
|
std::array<unsigned char, SHA256_DIGEST_LENGTH> out{};
|
||||||
|
SHA256(reinterpret_cast<const unsigned char*>(data.data()), data.size(), out.data());
|
||||||
|
return ToHex(out.data(), out.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace csp::crypto
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#include <catch2/catch_test_macros.hpp>
|
||||||
|
|
||||||
|
#include "csp/db/sqlite_db.h"
|
||||||
|
#include "csp/services/auth_service.h"
|
||||||
|
|
||||||
|
TEST_CASE("auth register/login/verify") {
|
||||||
|
auto db = csp::db::SqliteDb::OpenMemory();
|
||||||
|
csp::db::ApplyMigrations(db);
|
||||||
|
|
||||||
|
csp::services::AuthService auth(db);
|
||||||
|
|
||||||
|
const auto r = auth.Register("alice", "password123");
|
||||||
|
REQUIRE(r.user_id > 0);
|
||||||
|
REQUIRE(r.token.size() > 10);
|
||||||
|
|
||||||
|
const auto uid = auth.VerifyToken(r.token);
|
||||||
|
REQUIRE(uid.has_value());
|
||||||
|
REQUIRE(uid.value() == r.user_id);
|
||||||
|
|
||||||
|
const auto r2 = auth.Login("alice", "password123");
|
||||||
|
REQUIRE(r2.user_id == r.user_id);
|
||||||
|
REQUIRE(r2.token != r.token);
|
||||||
|
}
|
||||||
在新工单中引用
屏蔽一个用户