From a6d087d5a93028c74a52f61814fda78439abeeac Mon Sep 17 00:00:00 2001 From: anygen-build-bot Date: Thu, 12 Feb 2026 09:00:27 +0000 Subject: [PATCH] feat(auth): add session-based auth service with tests --- backend/CMakeLists.txt | 5 + backend/include/csp/services/auth_service.h | 30 ++++ backend/include/csp/services/crypto.h | 10 ++ backend/src/services/auth_service.cc | 145 ++++++++++++++++++++ backend/src/services/crypto.cc | 42 ++++++ backend/tests/auth_service_test.cc | 23 ++++ 6 files changed, 255 insertions(+) create mode 100644 backend/include/csp/services/auth_service.h create mode 100644 backend/include/csp/services/crypto.h create mode 100644 backend/src/services/auth_service.cc create mode 100644 backend/src/services/crypto.cc create mode 100644 backend/tests/auth_service_test.cc diff --git a/backend/CMakeLists.txt b/backend/CMakeLists.txt index 21b96f0..3bbac23 100644 --- a/backend/CMakeLists.txt +++ b/backend/CMakeLists.txt @@ -6,11 +6,14 @@ cmake_policy(SET CMP0153 OLD) find_package(Drogon CONFIG REQUIRED) find_package(Catch2 3 REQUIRED) +find_package(OpenSSL REQUIRED) add_library(csp_core src/version.cc src/db/sqlite_db.cc src/app_state.cc + src/services/crypto.cc + src/services/auth_service.cc ) target_include_directories(csp_core PUBLIC @@ -19,6 +22,7 @@ target_include_directories(csp_core PUBLIC target_link_libraries(csp_core PUBLIC SQLite3_lib + OpenSSL::Crypto ) add_executable(csp_server @@ -40,6 +44,7 @@ add_executable(csp_tests tests/test_main.cc tests/version_test.cc tests/sqlite_db_test.cc + tests/auth_service_test.cc ) target_link_libraries(csp_tests PRIVATE diff --git a/backend/include/csp/services/auth_service.h b/backend/include/csp/services/auth_service.h new file mode 100644 index 0000000..f8d45be --- /dev/null +++ b/backend/include/csp/services/auth_service.h @@ -0,0 +1,30 @@ +#pragma once + +#include "csp/db/sqlite_db.h" + +#include +#include + +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 VerifyToken(const std::string& token); + + private: + db::SqliteDb& db_; +}; + +} // namespace csp::services diff --git a/backend/include/csp/services/crypto.h b/backend/include/csp/services/crypto.h new file mode 100644 index 0000000..89c1d83 --- /dev/null +++ b/backend/include/csp/services/crypto.h @@ -0,0 +1,10 @@ +#pragma once + +#include + +namespace csp::crypto { + +std::string RandomHex(size_t bytes); +std::string Sha256Hex(const std::string& data); + +} // namespace csp::crypto diff --git a/backend/src/services/auth_service.cc b/backend/src/services/auth_service.cc new file mode 100644 index 0000000..5c7bb68 --- /dev/null +++ b/backend/src/services/auth_service.cc @@ -0,0 +1,145 @@ +#include "csp/services/auth_service.h" + +#include "csp/services/crypto.h" + +#include + +#include +#include + +namespace csp::services { + +namespace { + +int64_t NowSec() { + using namespace std::chrono; + return duration_cast(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(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(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(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 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 diff --git a/backend/src/services/crypto.cc b/backend/src/services/crypto.cc new file mode 100644 index 0000000..5f80f8f --- /dev/null +++ b/backend/src/services/crypto.cc @@ -0,0 +1,42 @@ +#include "csp/services/crypto.h" + +#include + +#include +#include +#include +#include +#include +#include + +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(data[i]); + } + return oss.str(); +} + +} // namespace + +std::string RandomHex(size_t bytes) { + std::vector 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(buf.data()), static_cast(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 out{}; + SHA256(reinterpret_cast(data.data()), data.size(), out.data()); + return ToHex(out.data(), out.size()); +} + +} // namespace csp::crypto diff --git a/backend/tests/auth_service_test.cc b/backend/tests/auth_service_test.cc new file mode 100644 index 0000000..3d5a1b4 --- /dev/null +++ b/backend/tests/auth_service_test.cc @@ -0,0 +1,23 @@ +#include + +#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); +}