Add new services
password_hasher auth_services
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
submodules/
|
||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
cmake-build-*/
|
||||||
|
.cache/
|
||||||
@@ -12,6 +12,9 @@ enable_testing()
|
|||||||
|
|
||||||
find_package(SQLite3 REQUIRED)
|
find_package(SQLite3 REQUIRED)
|
||||||
find_package(OpenSSL REQUIRED)
|
find_package(OpenSSL REQUIRED)
|
||||||
|
find_package(PkgConfig REQUIRED)
|
||||||
|
|
||||||
|
pkg_check_modules(ARGON2 REQUIRED IMPORTED_TARGET libargon2)
|
||||||
|
|
||||||
add_subdirectory(submodules/drogon)
|
add_subdirectory(submodules/drogon)
|
||||||
add_subdirectory(submodules/json)
|
add_subdirectory(submodules/json)
|
||||||
@@ -29,6 +32,10 @@ add_executable(auth_service
|
|||||||
src/repo/session_repository.cpp
|
src/repo/session_repository.cpp
|
||||||
src/util/time_utils.cpp
|
src/util/time_utils.cpp
|
||||||
src/security/session_token_service.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
|
target_include_directories(auth_service
|
||||||
@@ -48,6 +55,7 @@ target_link_libraries(auth_service PRIVATE
|
|||||||
SQLite::SQLite3
|
SQLite::SQLite3
|
||||||
nlohmann_json::nlohmann_json
|
nlohmann_json::nlohmann_json
|
||||||
OpenSSL::Crypto
|
OpenSSL::Crypto
|
||||||
|
PkgConfig::ARGON2
|
||||||
)
|
)
|
||||||
|
|
||||||
add_executable(auth_service_tests
|
add_executable(auth_service_tests
|
||||||
|
|||||||
@@ -2,27 +2,33 @@
|
|||||||
// #include <nlohmann/json.hpp>
|
// #include <nlohmann/json.hpp>
|
||||||
#include "db/migrations_runner.hpp"
|
#include "db/migrations_runner.hpp"
|
||||||
#include "db/sqllite_db.hpp"
|
#include "db/sqllite_db.hpp"
|
||||||
|
#include "http/controllers/auth_controller.hpp"
|
||||||
#include "http/controllers/health_controller.hpp"
|
#include "http/controllers/health_controller.hpp"
|
||||||
#include "repo/session_repository.hpp"
|
#include "repo/session_repository.hpp"
|
||||||
#include "repo/user_repository.hpp"
|
#include "repo/user_repository.hpp"
|
||||||
|
#include "security/password_hasher.hpp"
|
||||||
#include "security/session_token_service.hpp"
|
#include "security/session_token_service.hpp"
|
||||||
|
#include "service/auto_service.hpp"
|
||||||
#include "util/time_utils.hpp"
|
#include "util/time_utils.hpp"
|
||||||
|
#include "util/validation.hpp"
|
||||||
|
|
||||||
#include <drogon/drogon.h>
|
#include <drogon/drogon.h>
|
||||||
#include <format>
|
#include <format>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <nlohmann/json.hpp>
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
|
#define TEST_FUNCTION 1
|
||||||
namespace {
|
namespace {
|
||||||
void register_routes() {
|
// void register_routes() {
|
||||||
static HealthController health_controller;
|
// static HealthController health_controller;
|
||||||
drogon::app().registerHandler(
|
// drogon::app().registerHandler(
|
||||||
"/health",
|
// "/health",
|
||||||
[](const drogon::HttpRequestPtr &req,
|
// [](const drogon::HttpRequestPtr &req,
|
||||||
std::function<void(const drogon::HttpResponsePtr &)> &&callback) {
|
// std::function<void(const drogon::HttpResponsePtr &)> &&callback) {
|
||||||
health_controller.handle(req, std::move(callback));
|
// health_controller.handle(req, std::move(callback));
|
||||||
},
|
// },
|
||||||
{ drogon::Get });
|
// { drogon::Get });
|
||||||
}
|
// }
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
Application::Application(Settings settings)
|
Application::Application(Settings settings)
|
||||||
@@ -35,58 +41,29 @@ int Application::run() const {
|
|||||||
MigrationsRunner runner(db);
|
MigrationsRunner runner(db);
|
||||||
runner.run_file("migrations/001_init.sql");
|
runner.run_file("migrations/001_init.sql");
|
||||||
|
|
||||||
UserRepository user_repository(db);
|
UserRepository user_repository(db);
|
||||||
SessionRepository session_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<void(const drogon::HttpResponsePtr &)> &&callback) {
|
||||||
|
auth_controller.register_user(req, std::move(callback));
|
||||||
|
},
|
||||||
|
{ drogon::Post });
|
||||||
|
|
||||||
std::cout << "Now UTC: " << now_utc_iso8601() << '\n';
|
drogon::app().registerHandler(
|
||||||
std::cout << "Expires at: " << expires_at_from_now(settings_.session_ttl) << '\n';
|
"/health",
|
||||||
std::cout << "Generated raw token: " << token_pair.raw_token << '\n';
|
[&health_controller](const drogon::HttpRequestPtr &req,
|
||||||
std::cout << "Generated token hash: " << token_pair.token_hash << '\n';
|
std::function<void(const drogon::HttpResponsePtr &)> &&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);
|
drogon::app().addListener(settings_.host, settings_.port);
|
||||||
printInfo();
|
printInfo();
|
||||||
drogon::app().run();
|
drogon::app().run();
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
#include "http/controllers/auth_controller.hpp"
|
||||||
|
|
||||||
|
#include "service/auto_service.hpp"
|
||||||
|
|
||||||
|
#include <drogon/drogon.h>
|
||||||
|
#include <json/json.h>
|
||||||
|
|
||||||
|
AuthController::AuthController(AuthService &auth_service)
|
||||||
|
: authService_(auth_service) {}
|
||||||
|
|
||||||
|
void AuthController::register_user(
|
||||||
|
const drogon::HttpRequestPtr &req,
|
||||||
|
std::function<void(const drogon::HttpResponsePtr &)> &&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<Json::Int64>(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<Json::Value> &req) const {
|
||||||
|
return req != nullptr || req->isMember("email") || req->isMember("password") ||
|
||||||
|
(*req)["email"].isString() || (*req)["password"].isString();
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <drogon/HttpController.h>
|
||||||
|
#include <functional>
|
||||||
|
|
||||||
|
class AuthService;
|
||||||
|
|
||||||
|
class AuthController {
|
||||||
|
public:
|
||||||
|
explicit AuthController(AuthService &auth_service);
|
||||||
|
|
||||||
|
void register_user(const drogon::HttpRequestPtr &req,
|
||||||
|
std::function<void(const drogon::HttpResponsePtr &)> &&callback) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool isValidAuthJson(const std::shared_ptr<Json::Value> &req) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
AuthService &authService_;
|
||||||
|
};
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
#include <argon2.h>
|
||||||
|
#include <array>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <openssl/rand.h>
|
||||||
|
#include <security/password_hasher.hpp>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
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<std::uint8_t, saltLength> generate_salt() {
|
||||||
|
std::array<std::uint8_t, saltLength> salt{};
|
||||||
|
if(RAND_bytes(salt.data(), static_cast<int>(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<char> 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));
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
class PasswordHasher {
|
||||||
|
public:
|
||||||
|
std::string hash(std::string_view password) const;
|
||||||
|
|
||||||
|
bool verify(std::string_view password, std::string_view encoded_hash) const;
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
#include "repo/user_repository.hpp"
|
||||||
|
#include "security/password_hasher.hpp"
|
||||||
|
#include "util/time_utils.hpp"
|
||||||
|
#include "util/validation.hpp"
|
||||||
|
|
||||||
|
#include <service/auto_service.hpp>
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
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_;
|
||||||
|
};
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
|
#include <util/validation.hpp>
|
||||||
|
|
||||||
|
std::string trim(std::string_view value) {
|
||||||
|
auto begin = value.begin();
|
||||||
|
auto end = value.end();
|
||||||
|
|
||||||
|
while(begin != end && std::isspace(static_cast<unsigned char>(*begin))) {
|
||||||
|
++begin;
|
||||||
|
}
|
||||||
|
while(begin != end && std::isspace(static_cast<unsigned char>(*(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<char>(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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
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);
|
||||||
Reference in New Issue
Block a user