diff --git a/CMakeLists.txt b/CMakeLists.txt index dc41378..86062ce 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -11,6 +11,7 @@ include(CTest) enable_testing() find_package(SQLite3 REQUIRED) +find_package(OpenSSL REQUIRED) add_subdirectory(submodules/drogon) add_subdirectory(submodules/json) @@ -25,6 +26,9 @@ add_executable(auth_service src/db/transaction.cpp src/db/migrations_runner.cpp src/repo/user_repository.cpp + src/repo/session_repository.cpp + src/util/time_utils.cpp + src/security/session_token_service.cpp ) target_include_directories(auth_service @@ -43,6 +47,7 @@ target_link_libraries(auth_service PRIVATE drogon SQLite::SQLite3 nlohmann_json::nlohmann_json + OpenSSL::Crypto ) add_executable(auth_service_tests diff --git a/auth.db b/auth.db index 6381f6b..abf1d3a 100644 Binary files a/auth.db and b/auth.db differ diff --git a/auth.db-shm b/auth.db-shm deleted file mode 100644 index 164ad7c..0000000 Binary files a/auth.db-shm and /dev/null differ diff --git a/auth.db-wal b/auth.db-wal deleted file mode 100644 index b498427..0000000 Binary files a/auth.db-wal and /dev/null differ diff --git a/src/application/application.cpp b/src/application/application.cpp index f5857a2..00fbba8 100644 --- a/src/application/application.cpp +++ b/src/application/application.cpp @@ -3,7 +3,10 @@ #include "db/migrations_runner.hpp" #include "db/sqllite_db.hpp" #include "http/controllers/health_controller.hpp" +#include "repo/session_repository.hpp" #include "repo/user_repository.hpp" +#include "security/session_token_service.hpp" +#include "util/time_utils.hpp" #include #include @@ -32,16 +35,55 @@ int Application::run() const { MigrationsRunner runner(db); runner.run_file("migrations/001_init.sql"); - UserRepository user_repository(db); - const auto existing_user = user_repository.find_by_email("test@example.com"); - if(!existing_user.has_value()) { - const auto created_user = user_repository.create( - "test@example.com", "dummy_hash", "2026-04-05T12:00:00Z", "2026-04-05T12:00:00Z"); + UserRepository user_repository(db); + SessionRepository session_repository(db); - std::cout << "Created user: id=" << created_user.id << ", email=" << created_user.email << '\n'; - } else { - std::cout << "User already exists: id=" << existing_user->id - << ", email=" << existing_user->email << '\n'; + SessionTokenService token_service; + + const auto token_pair = token_service.generate(); + + std::cout << "Now UTC: " << now_utc_iso8601() << '\n'; + std::cout << "Expires at: " << expires_at_from_now(settings_.session_ttl) << '\n'; + std::cout << "Generated raw token: " << token_pair.raw_token << '\n'; + std::cout << "Generated token hash: " << token_pair.token_hash << '\n'; + + if(false) { + session_repository.revoke_by_token_hash("dummy_token_hash", "2026-04-05T13:00:00Z"); + + const auto revoked_session = session_repository.find_by_token_hash("dummy_token_hash"); + + if(revoked_session.has_value() && revoked_session->revoked_at.has_value()) { + std::cout << "Session revoked at: " << *revoked_session->revoked_at << '\n'; + } + } + + if(false) { + const auto existing_session = session_repository.find_by_token_hash("dummy_token_hash"); + + if(!existing_session.has_value()) { + const auto created_session = session_repository.create( + 1, "dummy_token_hash", "2026-04-05T12:00:00Z", "2026-04-06T12:00:00Z"); + + std::cout << "Created session: id=" << created_session.id + << ", user_id=" << created_session.user_id << '\n'; + } else { + std::cout << "Session already exists: id=" << existing_session->id + << ", user_id=" << existing_session->user_id << '\n'; + } + } + + if(false) { + const auto existing_user = user_repository.find_by_email("test@example.com"); + if(!existing_user.has_value()) { + const auto created_user = user_repository.create( + "test@example.com", "dummy_hash", "2026-04-05T12:00:00Z", "2026-04-05T12:00:00Z"); + + std::cout << "Created user: id=" << created_user.id << ", email=" << created_user.email + << '\n'; + } else { + std::cout << "User already exists: id=" << existing_user->id + << ", email=" << existing_user->email << '\n'; + } } register_routes(); diff --git a/src/config/settings/settings.hpp b/src/config/settings/settings.hpp index 527b801..cca761f 100644 --- a/src/config/settings/settings.hpp +++ b/src/config/settings/settings.hpp @@ -4,7 +4,9 @@ #include struct Settings { - std::string host{ "127.0.0.1" }; - std::uint16_t port{ 8080 }; - std::string db_path{ "auth.db" }; + std::string host{ "127.0.0.1" }; + std::uint16_t port{ 8080 }; + std::string db_path{ "auth.db" }; + std::chrono::seconds session_ttl{ 86400 }; + bool secure_cookies{ false }; }; \ No newline at end of file diff --git a/src/domain/Session.hpp b/src/domain/Session.hpp new file mode 100644 index 0000000..2bb14a7 --- /dev/null +++ b/src/domain/Session.hpp @@ -0,0 +1,13 @@ +#pragma once +#include +#include +#include + +struct Session { + std::int64_t id{}; + std::int64_t user_id{}; + std::string token_hash; + std::string created_at; + std::string expires_at; + std::optional revoked_at; +}; \ No newline at end of file diff --git a/src/repo/session_repository.cpp b/src/repo/session_repository.cpp new file mode 100644 index 0000000..6a67ea9 --- /dev/null +++ b/src/repo/session_repository.cpp @@ -0,0 +1,77 @@ +#include "repo/session_repository.hpp" + +#include "db/statement.hpp" + +#include + +namespace { +Session read_session(Statement &stmt) { + Session session; + session.id = stmt.column_int64(0); + session.user_id = stmt.column_int64(1); + session.token_hash = stmt.column_text(2); + session.created_at = stmt.column_text(3); + session.expires_at = stmt.column_text(4); + + if(!stmt.column_is_null(5)) { + session.revoked_at = stmt.column_text(5); + } + return session; +} +} // namespace + +SessionRepository::SessionRepository(SqlliteDb &db) + : db_(db) {} + +Session SessionRepository::create(std::int64_t user_id, std::string_view token_hash, + std::string_view created_at, std::string_view expires_at) const { + auto stmt = + db_.prepare("INSERT INTO sessions(user_id, token_hash, created_at, expires_at, revoked_at) " + "VALUES(?1, ?2, ?3, ?4, NULL);"); + stmt.bind_int64(1, user_id); + stmt.bind_text(2, token_hash); + stmt.bind_text(3, created_at); + stmt.bind_text(4, expires_at); + + stmt.execute(); + + const auto session_id = db_.last_insert_rowid(); + auto select_stmt = + db_.prepare("SELECT id, user_id, token_hash, created_at, expires_at, revoked_at " + "FROM sessions " + "WHERE id = ?1 " + "LIMIT 1;"); + + select_stmt.bind_int64(1, session_id); + + if(!select_stmt.step()) { + throw std::runtime_error("Failed to load created session"); + } + + return read_session(select_stmt); +} + +std::optional SessionRepository::find_by_token_hash(std::string_view token_hash) const { + auto stmt = db_.prepare("SELECT id, user_id, token_hash, created_at, expires_at, revoked_at " + "FROM sessions " + "WHERE token_hash = ?1 " + "LIMIT 1;"); + stmt.bind_text(1, token_hash); + + if(!stmt.step()) { + return std::nullopt; + } + + return read_session(stmt); +} + +void SessionRepository::revoke_by_token_hash(std::string_view token_hash, + std::string_view revoked_at) const { + auto stmt = db_.prepare("UPDATE sessions " + "SET revoked_at = ?1 " + "WHERE token_hash = ?2 AND revoked_at IS NULL;"); + stmt.bind_text(1, revoked_at); + stmt.bind_text(2, token_hash); + + stmt.execute(); +} \ No newline at end of file diff --git a/src/repo/session_repository.hpp b/src/repo/session_repository.hpp new file mode 100644 index 0000000..1d1ada1 --- /dev/null +++ b/src/repo/session_repository.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include "domain/Session.hpp" + +#include +#include +#include +#include + +class SessionRepository { +public: + explicit SessionRepository(SqlliteDb &db); + Session create(std::int64_t user_id, std::string_view token_hash, std::string_view created_at, + std::string_view expires_at) const; + std::optional find_by_token_hash(std::string_view token_hash) const; + void revoke_by_token_hash(std::string_view token_hash, std::string_view revoked_at) const; + +private: + SqlliteDb &db_; +}; \ No newline at end of file diff --git a/src/security/session_token_service.cpp b/src/security/session_token_service.cpp new file mode 100644 index 0000000..a8e899e --- /dev/null +++ b/src/security/session_token_service.cpp @@ -0,0 +1,44 @@ +#include "security/session_token_service.hpp" + +#include +#include +#include +#include +#include + +namespace { +constexpr int kTokenSizeBytes = 32; +std::string bytes_to_hex(const unsigned char *data, std::size_t size) { + std::ostringstream out; + out << std::hex << std::setfill('0'); + for(std::size_t i = 0; i < size; ++i) { + out << std::setw(2) << static_cast(data[i]); + } + + return out.str(); +} +} // namespace + +SessionTokenPair SessionTokenService::generate() const { + const auto raw_token = generate_random_token(); + const auto token_hash = sha256_hex(raw_token); + + return SessionTokenPair{ .raw_token = raw_token, .token_hash = token_hash }; +} + +std::string SessionTokenService::generate_random_token() { + unsigned char buffer[kTokenSizeBytes]; + if(RAND_bytes(buffer, sizeof(buffer)) != 1) { + throw std::runtime_error("Failed to generate secure random session token"); + } + + return bytes_to_hex(buffer, sizeof(buffer)); +} + +std::string SessionTokenService::sha256_hex(const std::string &input) { + unsigned char hash[SHA256_DIGEST_LENGTH]; + + SHA256(reinterpret_cast(input.data()), input.size(), hash); + + return bytes_to_hex(hash, sizeof(hash)); +} \ No newline at end of file diff --git a/src/security/session_token_service.hpp b/src/security/session_token_service.hpp new file mode 100644 index 0000000..84c76d1 --- /dev/null +++ b/src/security/session_token_service.hpp @@ -0,0 +1,15 @@ +#include + +struct SessionTokenPair { + std::string raw_token; + std::string token_hash; +}; + +class SessionTokenService { +public: + SessionTokenPair generate() const; + +private: + static std::string generate_random_token(); + static std::string sha256_hex(const std::string &input); +}; diff --git a/src/util/time_utils.cpp b/src/util/time_utils.cpp new file mode 100644 index 0000000..dc874ee --- /dev/null +++ b/src/util/time_utils.cpp @@ -0,0 +1,31 @@ +#include "util/time_utils.hpp" + +#include +#include +#include +#include + +namespace { +std::string to_iso8601_utc(std::time_t time_value) { + std::tm utc_tm{}; + if(gmtime_r(&time_value, &utc_tm) == nullptr) { + throw std::runtime_error("Failed to convert time to UTC"); + } + + std::ostringstream out; + out << std::put_time(&utc_tm, "%Y-%m-%dT%H:%M:%SZ"); + return out.str(); +} +} // namespace + +std::string now_utc_iso8601() { + const auto now = std::chrono::system_clock::now(); + const auto now_time_t = std::chrono::system_clock::to_time_t(now); + return to_iso8601_utc(now_time_t); +} + +std::string expires_at_from_now(std::chrono::seconds ttl) { + const auto expires_at = std::chrono::system_clock::now() + ttl; + const auto expires_time_t = std::chrono::system_clock::to_time_t(expires_at); + return to_iso8601_utc(expires_time_t); +} diff --git a/src/util/time_utils.hpp b/src/util/time_utils.hpp new file mode 100644 index 0000000..25a54ed --- /dev/null +++ b/src/util/time_utils.hpp @@ -0,0 +1,7 @@ +#pragma once + +#include +#include + +std::string now_utc_iso8601(); +std::string expires_at_from_now(std::chrono::seconds ttl); \ No newline at end of file