diff --git a/.fzgignore b/.fzgignore new file mode 100644 index 0000000..649da4d --- /dev/null +++ b/.fzgignore @@ -0,0 +1,5 @@ +submodules/ +node_modules/ +build/ +cmake-build-*/ +.cache/ \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index 86062ce..b57c96e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -12,6 +12,9 @@ enable_testing() find_package(SQLite3 REQUIRED) find_package(OpenSSL REQUIRED) +find_package(PkgConfig REQUIRED) + +pkg_check_modules(ARGON2 REQUIRED IMPORTED_TARGET libargon2) add_subdirectory(submodules/drogon) add_subdirectory(submodules/json) @@ -29,6 +32,10 @@ add_executable(auth_service src/repo/session_repository.cpp src/util/time_utils.cpp src/security/session_token_service.cpp + src/util/validation.cpp + src/security/password_hasher.cpp + src/service/auth_service.cpp + src/http/controllers/auth_controller.cpp ) target_include_directories(auth_service @@ -48,6 +55,7 @@ target_link_libraries(auth_service PRIVATE SQLite::SQLite3 nlohmann_json::nlohmann_json OpenSSL::Crypto + PkgConfig::ARGON2 ) add_executable(auth_service_tests diff --git a/auth.db b/auth.db index abf1d3a..ce4e2c2 100644 Binary files a/auth.db and b/auth.db differ diff --git a/src/application/application.cpp b/src/application/application.cpp index 00fbba8..0ba594b 100644 --- a/src/application/application.cpp +++ b/src/application/application.cpp @@ -2,27 +2,33 @@ // #include #include "db/migrations_runner.hpp" #include "db/sqllite_db.hpp" +#include "http/controllers/auth_controller.hpp" #include "http/controllers/health_controller.hpp" #include "repo/session_repository.hpp" #include "repo/user_repository.hpp" +#include "security/password_hasher.hpp" #include "security/session_token_service.hpp" +#include "service/auto_service.hpp" #include "util/time_utils.hpp" +#include "util/validation.hpp" #include #include #include #include + +#define TEST_FUNCTION 1 namespace { -void register_routes() { - static HealthController health_controller; - drogon::app().registerHandler( - "/health", - [](const drogon::HttpRequestPtr &req, - std::function &&callback) { - health_controller.handle(req, std::move(callback)); - }, - { drogon::Get }); -} +// void register_routes() { +// static HealthController health_controller; +// drogon::app().registerHandler( +// "/health", +// [](const drogon::HttpRequestPtr &req, +// std::function &&callback) { +// health_controller.handle(req, std::move(callback)); +// }, +// { drogon::Get }); +// } } // namespace Application::Application(Settings settings) @@ -35,58 +41,29 @@ int Application::run() const { MigrationsRunner runner(db); runner.run_file("migrations/001_init.sql"); - UserRepository user_repository(db); - SessionRepository session_repository(db); + UserRepository user_repository(db); + PasswordHasher password_hasher; + AuthService auth_service(user_repository, password_hasher); - SessionTokenService token_service; + HealthController health_controller; + AuthController auth_controller(auth_service); - const auto token_pair = token_service.generate(); + drogon::app().registerHandler( + "/register", + [&auth_controller](const drogon::HttpRequestPtr &req, + std::function &&callback) { + auth_controller.register_user(req, std::move(callback)); + }, + { drogon::Post }); - 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'; + drogon::app().registerHandler( + "/health", + [&health_controller](const drogon::HttpRequestPtr &req, + std::function &&callback) { + health_controller.handle(req, std::move(callback)); + }, + { drogon::Get }); - 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(); drogon::app().addListener(settings_.host, settings_.port); printInfo(); drogon::app().run(); diff --git a/src/http/controllers/auth_controller.cpp b/src/http/controllers/auth_controller.cpp new file mode 100644 index 0000000..04d2818 --- /dev/null +++ b/src/http/controllers/auth_controller.cpp @@ -0,0 +1,74 @@ +#include "http/controllers/auth_controller.hpp" + +#include "service/auto_service.hpp" + +#include +#include + +AuthController::AuthController(AuthService &auth_service) + : authService_(auth_service) {} + +void AuthController::register_user( + const drogon::HttpRequestPtr &req, + std::function &&callback) const { + const auto json = req->getJsonObject(); + if(!isValidAuthJson(json)) { + Json::Value error; + error["error"]["code"] = "invalid_request"; + error["error"]["message"] = "Expected JSON body with email and password"; + + auto responce = drogon::HttpResponse::newHttpJsonResponse(error); + responce->setStatusCode(drogon::k400BadRequest); + callback(responce); + return; + } + + try { + const auto result = authService_.register_user(RegisterCommand{ + .email = (*json)["email"].asString(), + .password = (*json)["password"].asString(), + }); + + Json::Value body; + body["id"] = static_cast(result.id); + body["email"] = result.email; + + auto responce = drogon::HttpResponse::newHttpJsonResponse(body); + responce->setStatusCode(drogon::k201Created); + + callback(responce); + } catch(const std::exception &ex) { + Json::Value error; + error["error"]["message"] = ex.what(); + + const std::string message = ex.what(); + + if(message == "Email already exists") { + error["error"]["code"] = "email_already_exists"; + + auto response = drogon::HttpResponse::newHttpJsonResponse(error); + response->setStatusCode(drogon::k409Conflict); + callback(response); + return; + } + + if(message == "Invalid email" || message == "Invalid password") { + error["error"]["code"] = "validation_error"; + + auto response = drogon::HttpResponse::newHttpJsonResponse(error); + callback(response); + return; + } + + error["error"]["code"] = "internal_error"; + + auto response = drogon::HttpResponse::newHttpJsonResponse(error); + response->setStatusCode(drogon::k500InternalServerError); + callback(response); + } +} + +bool AuthController::isValidAuthJson(const std::shared_ptr &req) const { + return req != nullptr || req->isMember("email") || req->isMember("password") || + (*req)["email"].isString() || (*req)["password"].isString(); +} \ No newline at end of file diff --git a/src/http/controllers/auth_controller.hpp b/src/http/controllers/auth_controller.hpp new file mode 100644 index 0000000..b0505e1 --- /dev/null +++ b/src/http/controllers/auth_controller.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include +#include + +class AuthService; + +class AuthController { +public: + explicit AuthController(AuthService &auth_service); + + void register_user(const drogon::HttpRequestPtr &req, + std::function &&callback) const; + +private: + bool isValidAuthJson(const std::shared_ptr &req) const; + +private: + AuthService &authService_; +}; \ No newline at end of file diff --git a/src/security/password_hasher.cpp b/src/security/password_hasher.cpp new file mode 100644 index 0000000..ff2e63d --- /dev/null +++ b/src/security/password_hasher.cpp @@ -0,0 +1,55 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr std::uint32_t timecost = 3; +constexpr std::uint32_t memoryCostKb = 64 * 1024; +constexpr std::uint32_t parallelism = 1; +constexpr std::uint32_t hashLength = 32; +constexpr std::size_t saltLength = 16; +constexpr std::size_t encodingLength = 128; + +std::array generate_salt() { + std::array salt{}; + if(RAND_bytes(salt.data(), static_cast(salt.size())) != 1) { + throw std::runtime_error("Failed to generate password salt"); + } + return salt; +} +} // namespace + +std::string PasswordHasher::hash(std::string_view password) const { + const auto salt = generate_salt(); + + std::vector encoded(encodingLength); + const int rc = + argon2id_hash_encoded(timecost, memoryCostKb, parallelism, password.data(), password.size(), + salt.data(), salt.size(), hashLength, encoded.data(), encoded.size()); + if(rc != ARGON2_OK) { + throw std::runtime_error(std::string{ "Argon2 password hashing failed: " } + + argon2_error_message(rc)); + } + + return std::string{ encoded.data() }; +} + +bool PasswordHasher::verify(std::string_view password, std::string_view encoded_hash) const { + const int rc = argon2id_verify(encoded_hash.data(), password.data(), password.size()); + + if(rc == ARGON2_OK) { + return true; + } + + if(rc == ARGON2_VERIFY_MISMATCH) { + return false; + } + + throw std::runtime_error(std::string{ "Argon2 password verify failed: " } + + argon2_error_message(rc)); +} diff --git a/src/security/password_hasher.hpp b/src/security/password_hasher.hpp new file mode 100644 index 0000000..c141420 --- /dev/null +++ b/src/security/password_hasher.hpp @@ -0,0 +1,11 @@ +#pragma once + +#include +#include + +class PasswordHasher { +public: + std::string hash(std::string_view password) const; + + bool verify(std::string_view password, std::string_view encoded_hash) const; +}; \ No newline at end of file diff --git a/src/service/auth_service.cpp b/src/service/auth_service.cpp new file mode 100644 index 0000000..40410e6 --- /dev/null +++ b/src/service/auth_service.cpp @@ -0,0 +1,37 @@ +#include "repo/user_repository.hpp" +#include "security/password_hasher.hpp" +#include "util/time_utils.hpp" +#include "util/validation.hpp" + +#include +#include + +AuthService::AuthService(UserRepository &users, PasswordHasher &password_hasher) + : users_(users) + , passwordHasher_(password_hasher) {} + +RegisterResult AuthService::register_user(const RegisterCommand &command) const { + const auto email = normalize_email(command.email); + + if(!is_valid_email(email)) { + throw std::runtime_error("Invalid email"); + } + + if(!is_valid_password(command.password)) { + throw std::runtime_error("Invalid password"); + } + + if(users_.find_by_email(email).has_value()) { + throw std::runtime_error("Email already exists"); + } + + const auto password_hash = passwordHasher_.hash(command.password); + const auto now = now_utc_iso8601(); + + const auto user = users_.create(email, password_hash, now, now); + + return RegisterResult{ + .id = user.id, + .email = user.email, + }; +} diff --git a/src/service/auto_service.hpp b/src/service/auto_service.hpp new file mode 100644 index 0000000..6b4bf86 --- /dev/null +++ b/src/service/auto_service.hpp @@ -0,0 +1,27 @@ +#pragma once +#include +#include + +class UserRepository; +class PasswordHasher; + +struct RegisterCommand { + std::string email; + std::string password; +}; + +struct RegisterResult { + std::int64_t id; + std::string email; +}; + +class AuthService { +public: + AuthService(UserRepository &users, PasswordHasher &password_hasher); + + RegisterResult register_user(const RegisterCommand &command) const; + +private: + UserRepository &users_; + PasswordHasher &passwordHasher_; +}; \ No newline at end of file diff --git a/src/util/validation.cpp b/src/util/validation.cpp new file mode 100644 index 0000000..80ef2f1 --- /dev/null +++ b/src/util/validation.cpp @@ -0,0 +1,68 @@ +#include +#include +#include + +std::string trim(std::string_view value) { + auto begin = value.begin(); + auto end = value.end(); + + while(begin != end && std::isspace(static_cast(*begin))) { + ++begin; + } + while(begin != end && std::isspace(static_cast(*(end - 1)))) { + --end; + } + + return std::string{ begin, end }; +} + +std::string normalize_email(std::string_view email) { + auto normalize = trim(email); + std::transform(normalize.begin(), normalize.end(), normalize.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + + return normalize; +} + +bool is_valid_email(std::string_view email) { + if(email.empty() && email.size() > 200) { + return false; + } + + const auto at_pos = email.find("@"); + if(at_pos == std::string_view::npos) { + return false; + } + + if(at_pos == 0 || at_pos == email.size() - 1) { + return false; + } + + if(email.find("@", at_pos + 1) != std::string_view::npos) { + return false; + } + + const auto domain = email.substr(at_pos + 1); + const auto dot_pos = email.find('.'); + + if(dot_pos == std::string_view::npos) { + return false; + } + + if(dot_pos == 0 || dot_pos == domain.size() - 1) { + return false; + } + + if(email.find(' ') != std::string_view::npos) { + return false; + } + + return true; +} + +bool is_valid_password(std::string_view password) { + constexpr std::size_t minPasswordLen = 8; + constexpr std::size_t maxPasswordLen = 1024; + + return password.size() <= maxPasswordLen && password.size() >= minPasswordLen; +} diff --git a/src/util/validation.hpp b/src/util/validation.hpp new file mode 100644 index 0000000..7c713c7 --- /dev/null +++ b/src/util/validation.hpp @@ -0,0 +1,12 @@ +#pragma once + +#include +#include + +std::string trim(std::string_view value); + +std::string normalize_email(std::string_view email); + +bool is_valid_email(std::string_view email); + +bool is_valid_password(std::string_view password);