From a270728241f4cb3c79ad684f9853e74b33cfbd75 Mon Sep 17 00:00:00 2001 From: Gaspard Kirira Date: Mon, 8 Jun 2026 22:32:20 +0300 Subject: [PATCH 01/10] Init architecture --- .gitignore | 46 ++++++++--------------------- CMakeLists.txt | 0 examples/CMakeLists.txt | 0 examples/basic.cpp | 0 include/rix/auth/Auth.hpp | 0 include/rix/auth/AuthConfig.hpp | 0 include/rix/auth/AuthError.hpp | 0 include/rix/auth/AuthResult.hpp | 0 include/rix/auth/PasswordHasher.hpp | 0 include/rix/auth/Session.hpp | 0 include/rix/auth/SessionStore.hpp | 0 include/rix/auth/Token.hpp | 0 include/rix/auth/User.hpp | 0 include/rix/auth/UserStore.hpp | 0 include/rix/auth/Version.hpp | 0 src/Auth.cpp | 0 src/AuthConfig.cpp | 0 src/AuthError.cpp | 0 src/PasswordHasher.cpp | 0 src/Session.cpp | 0 src/SessionStore.cpp | 0 src/Token.cpp | 0 src/User.cpp | 0 src/UserStore.cpp | 0 src/Version.cpp | 0 tests/AuthTests.cpp | 0 tests/CMakeLists.txt | 0 tests/PasswordHasherTests.cpp | 0 tests/SessionTests.cpp | 0 tests/TokenTests.cpp | 0 tests/UserTests.cpp | 0 vix.json | 0 32 files changed, 12 insertions(+), 34 deletions(-) create mode 100644 CMakeLists.txt create mode 100644 examples/CMakeLists.txt create mode 100644 examples/basic.cpp create mode 100644 include/rix/auth/Auth.hpp create mode 100644 include/rix/auth/AuthConfig.hpp create mode 100644 include/rix/auth/AuthError.hpp create mode 100644 include/rix/auth/AuthResult.hpp create mode 100644 include/rix/auth/PasswordHasher.hpp create mode 100644 include/rix/auth/Session.hpp create mode 100644 include/rix/auth/SessionStore.hpp create mode 100644 include/rix/auth/Token.hpp create mode 100644 include/rix/auth/User.hpp create mode 100644 include/rix/auth/UserStore.hpp create mode 100644 include/rix/auth/Version.hpp create mode 100644 src/Auth.cpp create mode 100644 src/AuthConfig.cpp create mode 100644 src/AuthError.cpp create mode 100644 src/PasswordHasher.cpp create mode 100644 src/Session.cpp create mode 100644 src/SessionStore.cpp create mode 100644 src/Token.cpp create mode 100644 src/User.cpp create mode 100644 src/UserStore.cpp create mode 100644 src/Version.cpp create mode 100644 tests/AuthTests.cpp create mode 100644 tests/CMakeLists.txt create mode 100644 tests/PasswordHasherTests.cpp create mode 100644 tests/SessionTests.cpp create mode 100644 tests/TokenTests.cpp create mode 100644 tests/UserTests.cpp create mode 100644 vix.json diff --git a/.gitignore b/.gitignore index d4fb281..8b93af9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,41 +1,19 @@ -# Prerequisites -*.d - -# Compiled Object files -*.slo -*.lo +/build*/ +out/ +.vscode/ +.idea/ *.o *.obj - -# Precompiled Headers -*.gch -*.pch - -# Linker files -*.ilk - -# Debugger Files -*.pdb - -# Compiled Dynamic libraries +*.a +*.lib *.so *.dylib *.dll - -# Fortran module files -*.mod -*.smod - -# Compiled Static libraries -*.lai -*.la -*.a -*.lib - -# Executables *.exe -*.out -*.app +compile_commands.json +CMakeFiles/ +CMakeCache.txt +cmake-build-*/ +cmd.md +.vix/ -# debug information files -*.dwo diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..e69de29 diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt new file mode 100644 index 0000000..e69de29 diff --git a/examples/basic.cpp b/examples/basic.cpp new file mode 100644 index 0000000..e69de29 diff --git a/include/rix/auth/Auth.hpp b/include/rix/auth/Auth.hpp new file mode 100644 index 0000000..e69de29 diff --git a/include/rix/auth/AuthConfig.hpp b/include/rix/auth/AuthConfig.hpp new file mode 100644 index 0000000..e69de29 diff --git a/include/rix/auth/AuthError.hpp b/include/rix/auth/AuthError.hpp new file mode 100644 index 0000000..e69de29 diff --git a/include/rix/auth/AuthResult.hpp b/include/rix/auth/AuthResult.hpp new file mode 100644 index 0000000..e69de29 diff --git a/include/rix/auth/PasswordHasher.hpp b/include/rix/auth/PasswordHasher.hpp new file mode 100644 index 0000000..e69de29 diff --git a/include/rix/auth/Session.hpp b/include/rix/auth/Session.hpp new file mode 100644 index 0000000..e69de29 diff --git a/include/rix/auth/SessionStore.hpp b/include/rix/auth/SessionStore.hpp new file mode 100644 index 0000000..e69de29 diff --git a/include/rix/auth/Token.hpp b/include/rix/auth/Token.hpp new file mode 100644 index 0000000..e69de29 diff --git a/include/rix/auth/User.hpp b/include/rix/auth/User.hpp new file mode 100644 index 0000000..e69de29 diff --git a/include/rix/auth/UserStore.hpp b/include/rix/auth/UserStore.hpp new file mode 100644 index 0000000..e69de29 diff --git a/include/rix/auth/Version.hpp b/include/rix/auth/Version.hpp new file mode 100644 index 0000000..e69de29 diff --git a/src/Auth.cpp b/src/Auth.cpp new file mode 100644 index 0000000..e69de29 diff --git a/src/AuthConfig.cpp b/src/AuthConfig.cpp new file mode 100644 index 0000000..e69de29 diff --git a/src/AuthError.cpp b/src/AuthError.cpp new file mode 100644 index 0000000..e69de29 diff --git a/src/PasswordHasher.cpp b/src/PasswordHasher.cpp new file mode 100644 index 0000000..e69de29 diff --git a/src/Session.cpp b/src/Session.cpp new file mode 100644 index 0000000..e69de29 diff --git a/src/SessionStore.cpp b/src/SessionStore.cpp new file mode 100644 index 0000000..e69de29 diff --git a/src/Token.cpp b/src/Token.cpp new file mode 100644 index 0000000..e69de29 diff --git a/src/User.cpp b/src/User.cpp new file mode 100644 index 0000000..e69de29 diff --git a/src/UserStore.cpp b/src/UserStore.cpp new file mode 100644 index 0000000..e69de29 diff --git a/src/Version.cpp b/src/Version.cpp new file mode 100644 index 0000000..e69de29 diff --git a/tests/AuthTests.cpp b/tests/AuthTests.cpp new file mode 100644 index 0000000..e69de29 diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/PasswordHasherTests.cpp b/tests/PasswordHasherTests.cpp new file mode 100644 index 0000000..e69de29 diff --git a/tests/SessionTests.cpp b/tests/SessionTests.cpp new file mode 100644 index 0000000..e69de29 diff --git a/tests/TokenTests.cpp b/tests/TokenTests.cpp new file mode 100644 index 0000000..e69de29 diff --git a/tests/UserTests.cpp b/tests/UserTests.cpp new file mode 100644 index 0000000..e69de29 diff --git a/vix.json b/vix.json new file mode 100644 index 0000000..e69de29 From 94c63168c7b43e9697dd8c0640efd8a8217454eb Mon Sep 17 00:00:00 2001 From: Gaspard Kirira Date: Mon, 8 Jun 2026 22:53:28 +0300 Subject: [PATCH 02/10] feat: add initial rix auth package --- CMakeLists.txt | 47 +++ examples/CMakeLists.txt | 8 + examples/basic.cpp | 412 ++++++++++++++++++++++ include/rix/auth/Auth.hpp | 198 +++++++++++ include/rix/auth/AuthConfig.hpp | 150 ++++++++ include/rix/auth/AuthError.hpp | 187 ++++++++++ include/rix/auth/AuthResult.hpp | 196 +++++++++++ include/rix/auth/PasswordHasher.hpp | 94 +++++ include/rix/auth/Session.hpp | 186 ++++++++++ include/rix/auth/SessionStore.hpp | 130 +++++++ include/rix/auth/Token.hpp | 196 +++++++++++ include/rix/auth/User.hpp | 189 ++++++++++ include/rix/auth/UserStore.hpp | 124 +++++++ include/rix/auth/Version.hpp | 107 ++++++ src/Auth.cpp | 313 ++++++++++++++++ src/AuthConfig.cpp | 108 ++++++ src/AuthError.cpp | 94 +++++ src/PasswordHasher.cpp | 77 ++++ src/Session.cpp | 114 ++++++ src/SessionStore.cpp | 22 ++ src/Token.cpp | 118 +++++++ src/User.cpp | 114 ++++++ src/UserStore.cpp | 22 ++ src/Version.cpp | 27 ++ tests/AuthTests.cpp | 529 ++++++++++++++++++++++++++++ tests/CMakeLists.txt | 69 ++++ tests/PasswordHasherTests.cpp | 124 +++++++ tests/SessionTests.cpp | 155 ++++++++ tests/TokenTests.cpp | 166 +++++++++ tests/UserTests.cpp | 117 ++++++ vix.json | 31 ++ 31 files changed, 4424 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index e69de29..2514beb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -0,0 +1,47 @@ +cmake_minimum_required(VERSION 3.20) + +project(rix_auth + VERSION 0.1.0 + DESCRIPTION "Ready-to-use authentication package for Vix C++ applications" + LANGUAGES CXX +) + +option(RIX_AUTH_BUILD_EXAMPLES "Build rix/auth examples" ON) +option(RIX_AUTH_BUILD_TESTS "Build rix/auth tests" ON) + +add_library(rix_auth + src/Auth.cpp + src/AuthConfig.cpp + src/AuthError.cpp + src/PasswordHasher.cpp + src/User.cpp + src/Session.cpp + src/Token.cpp + src/UserStore.cpp + src/SessionStore.cpp + src/Version.cpp +) + +add_library(rix::auth ALIAS rix_auth) + +target_compile_features(rix_auth + PUBLIC + cxx_std_20 +) + +target_include_directories(rix_auth + PUBLIC + $ + $ +) + +if(CMAKE_CURRENT_SOURCE_DIR STREQUAL CMAKE_SOURCE_DIR) + if(RIX_AUTH_BUILD_EXAMPLES) + add_subdirectory(examples) + endif() + + if(RIX_AUTH_BUILD_TESTS) + enable_testing() + add_subdirectory(tests) + endif() +endif() diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index e69de29..78c2ac4 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -0,0 +1,8 @@ +add_executable(rix_auth_basic + basic.cpp +) + +target_link_libraries(rix_auth_basic + PRIVATE + rix::auth +) diff --git a/examples/basic.cpp b/examples/basic.cpp index e69de29..fdca674 100644 --- a/examples/basic.cpp +++ b/examples/basic.cpp @@ -0,0 +1,412 @@ +/** + * + * @file basic.cpp + * @author Gaspard Kirira + * + * Copyright 2026, Gaspard Kirira. + * All rights reserved. + * https://github.com/rixcpp/auth + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Rix + * + */ + +#include + +#include +#include +#include +#include +#include +#include + +namespace +{ + class MemoryUserStore final : public rixlib::auth::UserStore + { + public: + [[nodiscard]] rixlib::auth::AuthStatus create( + const rixlib::auth::User &user) override + { + if (!user.valid()) + { + return rixlib::auth::AuthStatus::failure( + rixlib::auth::make_auth_error( + rixlib::auth::AuthErrorCode::InvalidInput, + "User is invalid.")); + } + + const auto exists = std::any_of( + users_.begin(), + users_.end(), + [&](const rixlib::auth::User &stored) + { + return stored.id() == user.id() || + stored.email() == user.email(); + }); + + if (exists) + { + return rixlib::auth::AuthStatus::failure( + rixlib::auth::make_auth_error( + rixlib::auth::AuthErrorCode::UserAlreadyExists, + "User already exists.")); + } + + users_.push_back(user); + return rixlib::auth::AuthStatus::success(); + } + + [[nodiscard]] rixlib::auth::AuthStatus update( + const rixlib::auth::User &user) override + { + for (rixlib::auth::User &stored : users_) + { + if (stored.id() == user.id()) + { + stored = user; + return rixlib::auth::AuthStatus::success(); + } + } + + return rixlib::auth::AuthStatus::failure( + rixlib::auth::make_auth_error( + rixlib::auth::AuthErrorCode::UserNotFound, + "User not found.")); + } + + [[nodiscard]] rixlib::auth::AuthStatus remove_by_id( + std::string_view id) override + { + const auto old_size = users_.size(); + + users_.erase( + std::remove_if( + users_.begin(), + users_.end(), + [&](const rixlib::auth::User &user) + { + return user.id() == id; + }), + users_.end()); + + if (users_.size() == old_size) + { + return rixlib::auth::AuthStatus::failure( + rixlib::auth::make_auth_error( + rixlib::auth::AuthErrorCode::UserNotFound, + "User not found.")); + } + + return rixlib::auth::AuthStatus::success(); + } + + [[nodiscard]] rixlib::auth::AuthResult> + find_by_id(std::string_view id) const override + { + for (const rixlib::auth::User &user : users_) + { + if (user.id() == id) + { + return rixlib::auth::AuthResult>::success(user); + } + } + + return rixlib::auth::AuthResult>::success(std::nullopt); + } + + [[nodiscard]] rixlib::auth::AuthResult> + find_by_email(std::string_view email) const override + { + for (const rixlib::auth::User &user : users_) + { + if (user.email() == email) + { + return rixlib::auth::AuthResult>::success(user); + } + } + + return rixlib::auth::AuthResult>::success(std::nullopt); + } + + [[nodiscard]] rixlib::auth::AuthResult + exists_by_id(std::string_view id) const override + { + const auto exists = std::any_of( + users_.begin(), + users_.end(), + [&](const rixlib::auth::User &user) + { + return user.id() == id; + }); + + return rixlib::auth::AuthResult::success(exists); + } + + [[nodiscard]] rixlib::auth::AuthResult + exists_by_email(std::string_view email) const override + { + const auto exists = std::any_of( + users_.begin(), + users_.end(), + [&](const rixlib::auth::User &user) + { + return user.email() == email; + }); + + return rixlib::auth::AuthResult::success(exists); + } + + [[nodiscard]] rixlib::auth::AuthResult> + all() const override + { + return rixlib::auth::AuthResult>::success(users_); + } + + private: + std::vector users_; + }; + + class MemorySessionStore final : public rixlib::auth::SessionStore + { + public: + [[nodiscard]] rixlib::auth::AuthStatus create( + const rixlib::auth::Session &session) override + { + if (!session.valid()) + { + return rixlib::auth::AuthStatus::failure( + rixlib::auth::make_auth_error( + rixlib::auth::AuthErrorCode::InvalidInput, + "Session is invalid.")); + } + + const auto exists = std::any_of( + sessions_.begin(), + sessions_.end(), + [&](const rixlib::auth::Session &stored) + { + return stored.id() == session.id(); + }); + + if (exists) + { + return rixlib::auth::AuthStatus::failure( + rixlib::auth::make_auth_error( + rixlib::auth::AuthErrorCode::InvalidSession, + "Session already exists.")); + } + + sessions_.push_back(session); + return rixlib::auth::AuthStatus::success(); + } + + [[nodiscard]] rixlib::auth::AuthStatus update( + const rixlib::auth::Session &session) override + { + for (rixlib::auth::Session &stored : sessions_) + { + if (stored.id() == session.id()) + { + stored = session; + return rixlib::auth::AuthStatus::success(); + } + } + + return rixlib::auth::AuthStatus::failure( + rixlib::auth::make_auth_error( + rixlib::auth::AuthErrorCode::InvalidSession, + "Session not found.")); + } + + [[nodiscard]] rixlib::auth::AuthStatus remove_by_id( + std::string_view id) override + { + const auto old_size = sessions_.size(); + + sessions_.erase( + std::remove_if( + sessions_.begin(), + sessions_.end(), + [&](const rixlib::auth::Session &session) + { + return session.id() == id; + }), + sessions_.end()); + + if (sessions_.size() == old_size) + { + return rixlib::auth::AuthStatus::failure( + rixlib::auth::make_auth_error( + rixlib::auth::AuthErrorCode::InvalidSession, + "Session not found.")); + } + + return rixlib::auth::AuthStatus::success(); + } + + [[nodiscard]] rixlib::auth::AuthStatus revoke_by_id( + std::string_view id) override + { + for (rixlib::auth::Session &session : sessions_) + { + if (session.id() == id) + { + session.set_revoked(true); + return rixlib::auth::AuthStatus::success(); + } + } + + return rixlib::auth::AuthStatus::failure( + rixlib::auth::make_auth_error( + rixlib::auth::AuthErrorCode::InvalidSession, + "Session not found.")); + } + + [[nodiscard]] rixlib::auth::AuthStatus revoke_by_user_id( + std::string_view user_id) override + { + bool changed = false; + + for (rixlib::auth::Session &session : sessions_) + { + if (session.user_id() == user_id) + { + session.set_revoked(true); + changed = true; + } + } + + if (!changed) + { + return rixlib::auth::AuthStatus::failure( + rixlib::auth::make_auth_error( + rixlib::auth::AuthErrorCode::InvalidSession, + "Session not found.")); + } + + return rixlib::auth::AuthStatus::success(); + } + + [[nodiscard]] rixlib::auth::AuthResult> + find_by_id(std::string_view id) const override + { + for (const rixlib::auth::Session &session : sessions_) + { + if (session.id() == id) + { + return rixlib::auth::AuthResult>::success(session); + } + } + + return rixlib::auth::AuthResult>::success(std::nullopt); + } + + [[nodiscard]] rixlib::auth::AuthResult> + find_by_user_id(std::string_view user_id) const override + { + std::vector result; + + for (const rixlib::auth::Session &session : sessions_) + { + if (session.user_id() == user_id) + { + result.push_back(session); + } + } + + return rixlib::auth::AuthResult>::success(result); + } + + [[nodiscard]] rixlib::auth::AuthResult + exists_by_id(std::string_view id) const override + { + const auto exists = std::any_of( + sessions_.begin(), + sessions_.end(), + [&](const rixlib::auth::Session &session) + { + return session.id() == id; + }); + + return rixlib::auth::AuthResult::success(exists); + } + + [[nodiscard]] rixlib::auth::AuthResult> + all() const override + { + return rixlib::auth::AuthResult>::success(sessions_); + } + + private: + std::vector sessions_; + }; + + void print_error(const rixlib::auth::AuthError &error) + { + std::cout << "error: " << rixlib::auth::to_string(error.code()) + << " - " << error.message() << '\n'; + } +} // namespace + +int main() +{ + MemoryUserStore users; + MemorySessionStore sessions; + + rixlib::auth::Auth auth{users, sessions}; + + const auto registered = auth.register_user( + rixlib::auth::RegisterRequest{ + "ada@example.com", + "secret123"}); + + if (registered.failed()) + { + print_error(registered.error()); + return 1; + } + + std::cout << "registered user: " << registered.value().email() << '\n'; + + const auto logged_in = auth.login( + rixlib::auth::LoginRequest{ + "ada@example.com", + "secret123"}); + + if (logged_in.failed()) + { + print_error(logged_in.error()); + return 1; + } + + std::cout << "logged in user: " << logged_in.value().user.email() << '\n'; + std::cout << "session id: " << logged_in.value().session.id() << '\n'; + + const auto authenticated = auth.authenticate_session( + logged_in.value().session.id()); + + if (authenticated.failed()) + { + print_error(authenticated.error()); + return 1; + } + + std::cout << "session authenticated for user id: " + << authenticated.value().user_id() << '\n'; + + const auto logout = auth.logout(logged_in.value().session.id()); + + if (logout.failed()) + { + print_error(logout.error()); + return 1; + } + + std::cout << "logged out successfully\n"; + + return 0; +} diff --git a/include/rix/auth/Auth.hpp b/include/rix/auth/Auth.hpp index e69de29..90f4c72 100644 --- a/include/rix/auth/Auth.hpp +++ b/include/rix/auth/Auth.hpp @@ -0,0 +1,198 @@ +/** + * + * @file Auth.hpp + * @author Gaspard Kirira + * + * Copyright 2026, Gaspard Kirira. + * All rights reserved. + * https://github.com/rixcpp/auth + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Rix + * + * Main authentication facade for rix/auth. + * + */ + +#ifndef RIXCPP_AUTH_INCLUDE_RIX_AUTH_AUTH_HPP_INCLUDED +#define RIXCPP_AUTH_INCLUDE_RIX_AUTH_AUTH_HPP_INCLUDED + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace rixlib::auth +{ + /** + * @brief Request used to register a new user. + * + * RegisterRequest keeps the public API simple for developers while Auth + * handles validation, hashing, user creation, and store interaction. + */ + struct RegisterRequest + { + /** + * @brief User email address. + */ + std::string email; + + /** + * @brief Plain-text password. + * + * This value is only accepted as input. It must never be stored directly. + */ + std::string password; + }; + + /** + * @brief Request used to authenticate an existing user. + */ + struct LoginRequest + { + /** + * @brief User email address. + */ + std::string email; + + /** + * @brief Plain-text password. + */ + std::string password; + }; + + /** + * @brief Result returned after a successful login. + */ + struct LoginResult + { + /** + * @brief Authenticated user. + */ + User user; + + /** + * @brief Created authenticated session. + */ + Session session; + }; + + /** + * @brief Main rix/auth facade. + * + * Auth exposes a simple developer-facing API for common authentication + * operations such as register, login, session validation, and logout. + * + * The complexity stays behind this class: + * + * - input validation + * - password hashing + * - session creation + * - store access + * - error mapping + * + * The application only provides stores and calls clear methods. + */ + class Auth + { + public: + /** + * @brief Construct an authentication service. + * + * @param users User storage backend. + * @param sessions Session storage backend. + */ + Auth(UserStore &users, SessionStore &sessions); + + /** + * @brief Construct an authentication service with custom configuration. + * + * @param users User storage backend. + * @param sessions Session storage backend. + * @param config Authentication configuration. + */ + Auth(UserStore &users, SessionStore &sessions, AuthConfig config); + + /** + * @brief Register a new user. + * + * This method validates the email and password, checks if the user already + * exists, hashes the password, creates the user, and stores it. + * + * @param request Registration request. + * @return AuthResult containing the created user on success. + */ + [[nodiscard]] AuthResult register_user(const RegisterRequest &request); + + /** + * @brief Authenticate a user and create a session. + * + * This method validates credentials, verifies the password, creates a + * session, and stores it. + * + * @param request Login request. + * @return AuthResult containing the authenticated user and session. + */ + [[nodiscard]] AuthResult login(const LoginRequest &request); + + /** + * @brief Revoke a session. + * + * @param session_id Session identifier. + * @return AuthStatus indicating success or failure. + */ + [[nodiscard]] AuthStatus logout(std::string_view session_id); + + /** + * @brief Find and validate a session. + * + * @param session_id Session identifier. + * @return AuthResult containing the usable session when valid. + */ + [[nodiscard]] AuthResult authenticate_session(std::string_view session_id); + + /** + * @brief Return the current authentication configuration. + * + * @return Authentication configuration. + */ + [[nodiscard]] const AuthConfig &config() const noexcept; + + /** + * @brief Return the password hasher used by this auth service. + * + * @return Password hasher. + */ + [[nodiscard]] const PasswordHasher &password_hasher() const noexcept; + + private: + [[nodiscard]] AuthStatus validate_register_request( + const RegisterRequest &request) const; + + [[nodiscard]] AuthStatus validate_login_request( + const LoginRequest &request) const; + + [[nodiscard]] bool is_valid_email(std::string_view email) const noexcept; + + [[nodiscard]] std::string make_user_id() const; + + [[nodiscard]] std::string make_session_id() const; + + [[nodiscard]] std::int64_t now_seconds() const noexcept; + + UserStore *users_ = nullptr; + SessionStore *sessions_ = nullptr; + AuthConfig config_; + PasswordHasher password_hasher_; + }; +} // namespace rixlib::auth + +#endif // RIXCPP_AUTH_INCLUDE_RIX_AUTH_AUTH_HPP_INCLUDED diff --git a/include/rix/auth/AuthConfig.hpp b/include/rix/auth/AuthConfig.hpp index e69de29..9de5dad 100644 --- a/include/rix/auth/AuthConfig.hpp +++ b/include/rix/auth/AuthConfig.hpp @@ -0,0 +1,150 @@ +/** + * + * @file AuthConfig.hpp + * @author Gaspard Kirira + * + * Copyright 2026, Gaspard Kirira. + * All rights reserved. + * https://github.com/rixcpp/auth + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Rix + * + * Configuration for rix/auth. + * + */ + +#ifndef RIXCPP_AUTH_INCLUDE_RIX_AUTH_AUTHCONFIG_HPP_INCLUDED +#define RIXCPP_AUTH_INCLUDE_RIX_AUTH_AUTHCONFIG_HPP_INCLUDED + +#include +#include +#include + +namespace rixlib::auth +{ + /** + * @brief Configuration used by the authentication service. + * + * AuthConfig keeps the package configurable while preserving a simple default + * experience for application developers. + */ + class AuthConfig + { + public: + /** + * @brief Create a configuration with secure default values. + */ + AuthConfig(); + + /** + * @brief Create the default development configuration. + * + * @return AuthConfig with sensible defaults for local development. + */ + [[nodiscard]] static AuthConfig development(); + + /** + * @brief Create the default production configuration. + * + * @return AuthConfig with stricter defaults for production applications. + */ + [[nodiscard]] static AuthConfig production(); + + /** + * @brief Return the minimum accepted password length. + * + * @return Minimum password length. + */ + [[nodiscard]] std::size_t min_password_length() const noexcept; + + /** + * @brief Set the minimum accepted password length. + * + * @param value Minimum password length. + */ + void set_min_password_length(std::size_t value) noexcept; + + /** + * @brief Return the session lifetime in seconds. + * + * @return Session lifetime in seconds. + */ + [[nodiscard]] std::int64_t session_ttl_seconds() const noexcept; + + /** + * @brief Set the session lifetime in seconds. + * + * @param value Session lifetime in seconds. + */ + void set_session_ttl_seconds(std::int64_t value) noexcept; + + /** + * @brief Return the token lifetime in seconds. + * + * @return Token lifetime in seconds. + */ + [[nodiscard]] std::int64_t token_ttl_seconds() const noexcept; + + /** + * @brief Set the token lifetime in seconds. + * + * @param value Token lifetime in seconds. + */ + void set_token_ttl_seconds(std::int64_t value) noexcept; + + /** + * @brief Return the issuer name used for generated tokens. + * + * @return Token issuer. + */ + [[nodiscard]] const std::string &issuer() const noexcept; + + /** + * @brief Set the issuer name used for generated tokens. + * + * @param value Token issuer. + */ + void set_issuer(std::string value); + + /** + * @brief Return whether email verification is required. + * + * @return true if users must verify their email. + */ + [[nodiscard]] bool require_email_verification() const noexcept; + + /** + * @brief Enable or disable email verification. + * + * @param value true to require email verification. + */ + void set_require_email_verification(bool value) noexcept; + + /** + * @brief Return whether session rotation is enabled. + * + * @return true if session identifiers may be rotated after sensitive actions. + */ + [[nodiscard]] bool rotate_sessions() const noexcept; + + /** + * @brief Enable or disable session rotation. + * + * @param value true to enable session rotation. + */ + void set_rotate_sessions(bool value) noexcept; + + private: + std::size_t min_password_length_ = 8; + std::int64_t session_ttl_seconds_ = 60 * 60 * 24 * 7; + std::int64_t token_ttl_seconds_ = 60 * 15; + std::string issuer_ = "rix/auth"; + bool require_email_verification_ = false; + bool rotate_sessions_ = true; + }; +} // namespace rixlib::auth + +#endif // RIXCPP_AUTH_INCLUDE_RIX_AUTH_AUTHCONFIG_HPP_INCLUDED diff --git a/include/rix/auth/AuthError.hpp b/include/rix/auth/AuthError.hpp index e69de29..47f525e 100644 --- a/include/rix/auth/AuthError.hpp +++ b/include/rix/auth/AuthError.hpp @@ -0,0 +1,187 @@ +/** + * + * @file AuthError.hpp + * @author Gaspard Kirira + * + * Copyright 2026, Gaspard Kirira. + * All rights reserved. + * https://github.com/rixcpp/auth + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Rix + * + * Authentication error model for rix/auth. + * + */ + +#ifndef RIXCPP_AUTH_INCLUDE_RIX_AUTH_AUTHERROR_HPP_INCLUDED +#define RIXCPP_AUTH_INCLUDE_RIX_AUTH_AUTHERROR_HPP_INCLUDED + +#include +#include + +namespace rixlib::auth +{ + /** + * @brief Authentication error category. + * + * AuthErrorCode describes the reason why an authentication operation failed. + * It is intentionally small and stable so applications can safely switch on it. + */ + enum class AuthErrorCode + { + /** + * @brief No error occurred. + */ + None, + + /** + * @brief The input provided by the caller is invalid. + */ + InvalidInput, + + /** + * @brief The email address is invalid. + */ + InvalidEmail, + + /** + * @brief The password is invalid or too weak. + */ + InvalidPassword, + + /** + * @brief The requested user was not found. + */ + UserNotFound, + + /** + * @brief A user already exists for the given identity. + */ + UserAlreadyExists, + + /** + * @brief The provided credentials are invalid. + */ + InvalidCredentials, + + /** + * @brief The session is invalid. + */ + InvalidSession, + + /** + * @brief The session has expired. + */ + SessionExpired, + + /** + * @brief The provided token is invalid. + */ + InvalidToken, + + /** + * @brief The provided token has expired. + */ + TokenExpired, + + /** + * @brief The operation cannot be completed in the current state. + */ + InvalidState, + + /** + * @brief The storage layer failed. + */ + StoreError, + + /** + * @brief An unknown error occurred. + */ + Unknown + }; + + /** + * @brief Rich authentication error value. + * + * AuthError stores a stable error code and a human-readable message. + * The code is intended for programmatic decisions, while the message is + * intended for logs and debugging. + */ + class AuthError + { + public: + /** + * @brief Construct an empty error. + */ + AuthError() = default; + + /** + * @brief Construct an authentication error. + * + * @param code Stable error code. + * @param message Human-readable error message. + */ + AuthError(AuthErrorCode code, std::string message); + + /** + * @brief Return true when this object represents an error. + * + * @return true if the error code is not AuthErrorCode::None. + */ + [[nodiscard]] bool has_error() const noexcept; + + /** + * @brief Return true when this object does not represent an error. + * + * @return true if the error code is AuthErrorCode::None. + */ + [[nodiscard]] bool ok() const noexcept; + + /** + * @brief Return the stable error code. + * + * @return The error code. + */ + [[nodiscard]] AuthErrorCode code() const noexcept; + + /** + * @brief Return the human-readable error message. + * + * @return The error message. + */ + [[nodiscard]] const std::string &message() const noexcept; + + private: + AuthErrorCode code_ = AuthErrorCode::None; + std::string message_; + }; + + /** + * @brief Return a string name for an authentication error code. + * + * @param code Error code to convert. + * @return Stable string representation of the error code. + */ + [[nodiscard]] std::string_view to_string(AuthErrorCode code) noexcept; + + /** + * @brief Create an empty authentication error. + * + * @return An AuthError with AuthErrorCode::None. + */ + [[nodiscard]] AuthError make_auth_ok(); + + /** + * @brief Create an authentication error. + * + * @param code Stable error code. + * @param message Human-readable error message. + * @return AuthError containing the given code and message. + */ + [[nodiscard]] AuthError make_auth_error(AuthErrorCode code, std::string message); +} // namespace rixlib::auth + +#endif // RIXCPP_AUTH_INCLUDE_RIX_AUTH_AUTHERROR_HPP_INCLUDED diff --git a/include/rix/auth/AuthResult.hpp b/include/rix/auth/AuthResult.hpp index e69de29..8a5018e 100644 --- a/include/rix/auth/AuthResult.hpp +++ b/include/rix/auth/AuthResult.hpp @@ -0,0 +1,196 @@ +/** + * + * @file AuthResult.hpp + * @author Gaspard Kirira + * + * Copyright 2026, Gaspard Kirira. + * All rights reserved. + * https://github.com/rixcpp/auth + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Rix + * + * Result type used by rix/auth operations. + * + */ + +#ifndef RIXCPP_AUTH_INCLUDE_RIX_AUTH_AUTHRESULT_HPP_INCLUDED +#define RIXCPP_AUTH_INCLUDE_RIX_AUTH_AUTHRESULT_HPP_INCLUDED + +#include + +#include +#include + +namespace rixlib::auth +{ + /** + * @brief Result object returned by authentication operations. + * + * AuthResult stores either a value of type T or an AuthError. + * It keeps authentication APIs explicit and avoids throwing exceptions + * for normal business failures such as invalid credentials or expired tokens. + * + * @tparam T Value type returned on success. + */ + template + class AuthResult + { + public: + /** + * @brief Create a successful result. + * + * @param value Success value. + * @return AuthResult containing the value. + */ + [[nodiscard]] static AuthResult success(T value) + { + AuthResult result; + result.value_ = std::move(value); + return result; + } + + /** + * @brief Create a failed result. + * + * @param error Authentication error. + * @return AuthResult containing the error. + */ + [[nodiscard]] static AuthResult failure(AuthError error) + { + AuthResult result; + result.error_ = std::move(error); + return result; + } + + /** + * @brief Return true when the result contains a value. + * + * @return true if the operation succeeded. + */ + [[nodiscard]] bool ok() const noexcept + { + return value_.has_value(); + } + + /** + * @brief Return true when the result contains an error. + * + * @return true if the operation failed. + */ + [[nodiscard]] bool failed() const noexcept + { + return !ok(); + } + + /** + * @brief Return the success value. + * + * The caller must check ok() before calling this function. + * + * @return Const reference to the success value. + */ + [[nodiscard]] const T &value() const + { + return *value_; + } + + /** + * @brief Return the success value. + * + * The caller must check ok() before calling this function. + * + * @return Mutable reference to the success value. + */ + [[nodiscard]] T &value() + { + return *value_; + } + + /** + * @brief Return the authentication error. + * + * When the result is successful, this returns an empty AuthError. + * + * @return Const reference to the error. + */ + [[nodiscard]] const AuthError &error() const noexcept + { + return error_; + } + + private: + std::optional value_; + AuthError error_; + }; + + /** + * @brief Result object for operations that only return success or failure. + * + * AuthStatus is used when an operation does not need to return a value, + * for example logout, session deletion, or password update. + */ + class AuthStatus + { + public: + /** + * @brief Create a successful status. + * + * @return Successful AuthStatus. + */ + [[nodiscard]] static AuthStatus success() + { + return AuthStatus{}; + } + + /** + * @brief Create a failed status. + * + * @param error Authentication error. + * @return Failed AuthStatus. + */ + [[nodiscard]] static AuthStatus failure(AuthError error) + { + AuthStatus status; + status.error_ = std::move(error); + return status; + } + + /** + * @brief Return true when the operation succeeded. + * + * @return true if no error is stored. + */ + [[nodiscard]] bool ok() const noexcept + { + return error_.ok(); + } + + /** + * @brief Return true when the operation failed. + * + * @return true if an error is stored. + */ + [[nodiscard]] bool failed() const noexcept + { + return error_.has_error(); + } + + /** + * @brief Return the authentication error. + * + * @return Const reference to the error. + */ + [[nodiscard]] const AuthError &error() const noexcept + { + return error_; + } + + private: + AuthError error_; + }; +} // namespace rixlib::auth + +#endif // RIXCPP_AUTH_INCLUDE_RIX_AUTH_AUTHRESULT_HPP_INCLUDED diff --git a/include/rix/auth/PasswordHasher.hpp b/include/rix/auth/PasswordHasher.hpp index e69de29..2923c7f 100644 --- a/include/rix/auth/PasswordHasher.hpp +++ b/include/rix/auth/PasswordHasher.hpp @@ -0,0 +1,94 @@ +/** + * + * @file PasswordHasher.hpp + * @author Gaspard Kirira + * + * Copyright 2026, Gaspard Kirira. + * All rights reserved. + * https://github.com/rixcpp/auth + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Rix + * + * Password hashing helper for rix/auth. + * + */ + +#ifndef RIXCPP_AUTH_INCLUDE_RIX_AUTH_PASSWORDHASHER_HPP_INCLUDED +#define RIXCPP_AUTH_INCLUDE_RIX_AUTH_PASSWORDHASHER_HPP_INCLUDED + +#include + +#include +#include +#include + +namespace rixlib::auth +{ + /** + * @brief Password hashing component used by rix/auth. + * + * PasswordHasher hides password hashing details behind a simple API. + * Application developers should use Auth instead of calling this class + * directly in most cases. + * + * The current implementation is intentionally simple for the first version + * of rix/auth. The public API is designed so the internal algorithm can be + * upgraded later without changing the developer-facing Auth API. + */ + class PasswordHasher + { + public: + /** + * @brief Construct a password hasher with default settings. + */ + PasswordHasher(); + + /** + * @brief Hash a plain-text password. + * + * @param password Plain-text password. + * @return AuthResult containing the encoded password hash. + */ + [[nodiscard]] AuthResult hash(std::string_view password) const; + + /** + * @brief Verify a plain-text password against a stored hash. + * + * @param password Plain-text password. + * @param password_hash Stored password hash. + * @return true if the password matches the stored hash. + */ + [[nodiscard]] bool verify(std::string_view password, + std::string_view password_hash) const; + + /** + * @brief Return the minimum accepted password length. + * + * @return Minimum password length. + */ + [[nodiscard]] std::size_t min_password_length() const noexcept; + + /** + * @brief Set the minimum accepted password length. + * + * @param value Minimum password length. + */ + void set_min_password_length(std::size_t value) noexcept; + + /** + * @brief Return true when the password satisfies the minimum policy. + * + * @param password Plain-text password. + * @return true if the password is accepted. + */ + [[nodiscard]] bool accepts(std::string_view password) const noexcept; + + private: + std::size_t min_password_length_ = 8; + }; +} // namespace rixlib::auth + +#endif // RIXCPP_AUTH_INCLUDE_RIX_AUTH_PASSWORDHASHER_HPP_INCLUDED diff --git a/include/rix/auth/Session.hpp b/include/rix/auth/Session.hpp index e69de29..b6e5cab 100644 --- a/include/rix/auth/Session.hpp +++ b/include/rix/auth/Session.hpp @@ -0,0 +1,186 @@ +/** + * + * @file Session.hpp + * @author Gaspard Kirira + * + * Copyright 2026, Gaspard Kirira. + * All rights reserved. + * https://github.com/rixcpp/auth + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Rix + * + * Session model for rix/auth. + * + */ + +#ifndef RIXCPP_AUTH_INCLUDE_RIX_AUTH_SESSION_HPP_INCLUDED +#define RIXCPP_AUTH_INCLUDE_RIX_AUTH_SESSION_HPP_INCLUDED + +#include +#include +#include + +namespace rixlib::auth +{ + /** + * @brief Represents an authenticated user session. + * + * Session is a value object used to track authenticated users over time. + * It stores the session identifier, the related user identifier, creation + * time, expiration time, and revocation state. + * + * The session token or identifier must be treated as sensitive data by the + * application. It should never be logged in production. + */ + class Session + { + public: + /** + * @brief Construct an empty session. + */ + Session() = default; + + /** + * @brief Construct a session. + * + * @param id Stable session identifier. + * @param user_id Identifier of the user who owns the session. + * @param created_at Unix timestamp in seconds. + * @param expires_at Unix timestamp in seconds. + */ + Session(std::string id, + std::string user_id, + std::int64_t created_at, + std::int64_t expires_at); + + /** + * @brief Return the stable session identifier. + * + * @return Session identifier. + */ + [[nodiscard]] const std::string &id() const noexcept; + + /** + * @brief Set the stable session identifier. + * + * @param value Session identifier. + */ + void set_id(std::string value); + + /** + * @brief Return the user identifier attached to this session. + * + * @return User identifier. + */ + [[nodiscard]] const std::string &user_id() const noexcept; + + /** + * @brief Set the user identifier attached to this session. + * + * @param value User identifier. + */ + void set_user_id(std::string value); + + /** + * @brief Return the session creation timestamp. + * + * @return Unix timestamp in seconds. + */ + [[nodiscard]] std::int64_t created_at() const noexcept; + + /** + * @brief Set the session creation timestamp. + * + * @param value Unix timestamp in seconds. + */ + void set_created_at(std::int64_t value) noexcept; + + /** + * @brief Return the session expiration timestamp. + * + * @return Unix timestamp in seconds. + */ + [[nodiscard]] std::int64_t expires_at() const noexcept; + + /** + * @brief Set the session expiration timestamp. + * + * @param value Unix timestamp in seconds. + */ + void set_expires_at(std::int64_t value) noexcept; + + /** + * @brief Return the last time this session was used. + * + * @return Unix timestamp in seconds. + */ + [[nodiscard]] std::int64_t last_seen_at() const noexcept; + + /** + * @brief Set the last time this session was used. + * + * @param value Unix timestamp in seconds. + */ + void set_last_seen_at(std::int64_t value) noexcept; + + /** + * @brief Return whether the session has been revoked. + * + * @return true if the session is revoked. + */ + [[nodiscard]] bool revoked() const noexcept; + + /** + * @brief Set whether the session is revoked. + * + * @param value true when the session is revoked. + */ + void set_revoked(bool value) noexcept; + + /** + * @brief Return true when the session has a non-empty id and user id. + * + * @return true if the session has the minimum required fields. + */ + [[nodiscard]] bool valid() const noexcept; + + /** + * @brief Return true when this session belongs to the given user. + * + * @param value User identifier to compare. + * @return true if the user identifier matches. + */ + [[nodiscard]] bool belongs_to(std::string_view value) const noexcept; + + /** + * @brief Return true when the session is expired at the given time. + * + * @param now Unix timestamp in seconds. + * @return true if now is greater than or equal to expires_at. + */ + [[nodiscard]] bool expired(std::int64_t now) const noexcept; + + /** + * @brief Return true when the session can be used at the given time. + * + * A usable session must be valid, not revoked, and not expired. + * + * @param now Unix timestamp in seconds. + * @return true if the session can be used. + */ + [[nodiscard]] bool usable(std::int64_t now) const noexcept; + + private: + std::string id_; + std::string user_id_; + std::int64_t created_at_ = 0; + std::int64_t expires_at_ = 0; + std::int64_t last_seen_at_ = 0; + bool revoked_ = false; + }; +} // namespace rixlib::auth + +#endif // RIXCPP_AUTH_INCLUDE_RIX_AUTH_SESSION_HPP_INCLUDED diff --git a/include/rix/auth/SessionStore.hpp b/include/rix/auth/SessionStore.hpp index e69de29..e7c4c47 100644 --- a/include/rix/auth/SessionStore.hpp +++ b/include/rix/auth/SessionStore.hpp @@ -0,0 +1,130 @@ +/** + * + * @file SessionStore.hpp + * @author Gaspard Kirira + * + * Copyright 2026, Gaspard Kirira. + * All rights reserved. + * https://github.com/rixcpp/auth + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Rix + * + * Session storage interface for rix/auth. + * + */ + +#ifndef RIXCPP_AUTH_INCLUDE_RIX_AUTH_SESSIONSTORE_HPP_INCLUDED +#define RIXCPP_AUTH_INCLUDE_RIX_AUTH_SESSIONSTORE_HPP_INCLUDED + +#include +#include + +#include +#include +#include + +namespace rixlib::auth +{ + /** + * @brief Abstract storage interface for user sessions. + * + * SessionStore defines the persistence contract used by rix/auth to create, + * find, update, revoke, and delete authenticated sessions. + * + * This keeps the developer-facing Auth API simple while allowing different + * storage backends to be implemented later, such as memory, files, SQLite, + * MySQL, Redis, or Vix-based storage modules. + */ + class SessionStore + { + public: + /** + * @brief Destroy the session store. + */ + virtual ~SessionStore(); + + /** + * @brief Save a new session. + * + * @param session Session to create. + * @return AuthStatus indicating success or failure. + */ + [[nodiscard]] virtual AuthStatus create(const Session &session) = 0; + + /** + * @brief Update an existing session. + * + * @param session Session to update. + * @return AuthStatus indicating success or failure. + */ + [[nodiscard]] virtual AuthStatus update(const Session &session) = 0; + + /** + * @brief Delete a session by identifier. + * + * @param id Session identifier. + * @return AuthStatus indicating success or failure. + */ + [[nodiscard]] virtual AuthStatus remove_by_id(std::string_view id) = 0; + + /** + * @brief Revoke a session by identifier. + * + * A revoked session stays stored but can no longer be used. + * + * @param id Session identifier. + * @return AuthStatus indicating success or failure. + */ + [[nodiscard]] virtual AuthStatus revoke_by_id(std::string_view id) = 0; + + /** + * @brief Revoke all sessions attached to a user. + * + * @param user_id User identifier. + * @return AuthStatus indicating success or failure. + */ + [[nodiscard]] virtual AuthStatus revoke_by_user_id(std::string_view user_id) = 0; + + /** + * @brief Find a session by identifier. + * + * @param id Session identifier. + * @return Optional session when found, empty optional otherwise. + */ + [[nodiscard]] virtual AuthResult> + find_by_id(std::string_view id) const = 0; + + /** + * @brief Find all sessions attached to a user. + * + * @param user_id User identifier. + * @return AuthResult containing matching sessions. + */ + [[nodiscard]] virtual AuthResult> + find_by_user_id(std::string_view user_id) const = 0; + + /** + * @brief Return true when a session exists for the given identifier. + * + * @param id Session identifier. + * @return AuthResult containing true when a matching session exists. + */ + [[nodiscard]] virtual AuthResult + exists_by_id(std::string_view id) const = 0; + + /** + * @brief Return all stored sessions. + * + * This function is mainly useful for tests, small stores, admin tools, + * and future cleanup or migration utilities. + * + * @return AuthResult containing the list of sessions. + */ + [[nodiscard]] virtual AuthResult> all() const = 0; + }; +} // namespace rixlib::auth + +#endif // RIXCPP_AUTH_INCLUDE_RIX_AUTH_SESSIONSTORE_HPP_INCLUDED diff --git a/include/rix/auth/Token.hpp b/include/rix/auth/Token.hpp index e69de29..dcc6ec2 100644 --- a/include/rix/auth/Token.hpp +++ b/include/rix/auth/Token.hpp @@ -0,0 +1,196 @@ +/** + * + * @file Token.hpp + * @author Gaspard Kirira + * + * Copyright 2026, Gaspard Kirira. + * All rights reserved. + * https://github.com/rixcpp/auth + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Rix + * + * Token model for rix/auth. + * + */ + +#ifndef RIXCPP_AUTH_INCLUDE_RIX_AUTH_TOKEN_HPP_INCLUDED +#define RIXCPP_AUTH_INCLUDE_RIX_AUTH_TOKEN_HPP_INCLUDED + +#include +#include +#include + +namespace rixlib::auth +{ + /** + * @brief Represents an authentication token. + * + * Token is a value object used by rix/auth to represent short-lived or + * long-lived authentication tokens. + * + * It intentionally keeps the token payload simple at this level. Signing, + * verification, hashing, and transport concerns are handled by higher-level + * auth services or by Vix modules used internally. + */ + class Token + { + public: + /** + * @brief Construct an empty token. + */ + Token() = default; + + /** + * @brief Construct a token. + * + * @param value Raw token value. + * @param user_id Identifier of the user who owns the token. + * @param issued_at Unix timestamp in seconds. + * @param expires_at Unix timestamp in seconds. + */ + Token(std::string value, + std::string user_id, + std::int64_t issued_at, + std::int64_t expires_at); + + /** + * @brief Return the raw token value. + * + * The returned value is sensitive and must not be logged in production. + * + * @return Raw token value. + */ + [[nodiscard]] const std::string &value() const noexcept; + + /** + * @brief Set the raw token value. + * + * @param value Raw token value. + */ + void set_value(std::string value); + + /** + * @brief Return the user identifier attached to this token. + * + * @return User identifier. + */ + [[nodiscard]] const std::string &user_id() const noexcept; + + /** + * @brief Set the user identifier attached to this token. + * + * @param value User identifier. + */ + void set_user_id(std::string value); + + /** + * @brief Return the token issuer. + * + * @return Token issuer. + */ + [[nodiscard]] const std::string &issuer() const noexcept; + + /** + * @brief Set the token issuer. + * + * @param value Token issuer. + */ + void set_issuer(std::string value); + + /** + * @brief Return the token creation timestamp. + * + * @return Unix timestamp in seconds. + */ + [[nodiscard]] std::int64_t issued_at() const noexcept; + + /** + * @brief Set the token creation timestamp. + * + * @param value Unix timestamp in seconds. + */ + void set_issued_at(std::int64_t value) noexcept; + + /** + * @brief Return the token expiration timestamp. + * + * @return Unix timestamp in seconds. + */ + [[nodiscard]] std::int64_t expires_at() const noexcept; + + /** + * @brief Set the token expiration timestamp. + * + * @param value Unix timestamp in seconds. + */ + void set_expires_at(std::int64_t value) noexcept; + + /** + * @brief Return whether the token has been revoked. + * + * @return true if the token has been revoked. + */ + [[nodiscard]] bool revoked() const noexcept; + + /** + * @brief Set whether the token has been revoked. + * + * @param value true when the token is revoked. + */ + void set_revoked(bool value) noexcept; + + /** + * @brief Return true when the token has a non-empty value and user id. + * + * @return true if the token has the minimum required fields. + */ + [[nodiscard]] bool valid() const noexcept; + + /** + * @brief Return true when this token belongs to the given user. + * + * @param value User identifier to compare. + * @return true if the user identifier matches. + */ + [[nodiscard]] bool belongs_to(std::string_view value) const noexcept; + + /** + * @brief Return true when the token value matches the given value. + * + * @param value Token value to compare. + * @return true if the token value matches. + */ + [[nodiscard]] bool matches(std::string_view value) const noexcept; + + /** + * @brief Return true when the token is expired at the given time. + * + * @param now Unix timestamp in seconds. + * @return true if now is greater than or equal to expires_at. + */ + [[nodiscard]] bool expired(std::int64_t now) const noexcept; + + /** + * @brief Return true when the token can be used at the given time. + * + * A usable token must be valid, not revoked, and not expired. + * + * @param now Unix timestamp in seconds. + * @return true if the token can be used. + */ + [[nodiscard]] bool usable(std::int64_t now) const noexcept; + + private: + std::string value_; + std::string user_id_; + std::string issuer_; + std::int64_t issued_at_ = 0; + std::int64_t expires_at_ = 0; + bool revoked_ = false; + }; +} // namespace rixlib::auth + +#endif // RIXCPP_AUTH_INCLUDE_RIX_AUTH_TOKEN_HPP_INCLUDED diff --git a/include/rix/auth/User.hpp b/include/rix/auth/User.hpp index e69de29..52c1075 100644 --- a/include/rix/auth/User.hpp +++ b/include/rix/auth/User.hpp @@ -0,0 +1,189 @@ +/** + * + * @file User.hpp + * @author Gaspard Kirira + * + * Copyright 2026, Gaspard Kirira. + * All rights reserved. + * https://github.com/rixcpp/auth + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Rix + * + * User model for rix/auth. + * + */ + +#ifndef RIXCPP_AUTH_INCLUDE_RIX_AUTH_USER_HPP_INCLUDED +#define RIXCPP_AUTH_INCLUDE_RIX_AUTH_USER_HPP_INCLUDED + +#include +#include +#include + +namespace rixlib::auth +{ + /** + * @brief Represents an authenticated application user. + * + * User is a simple value object used by rix/auth to represent the identity + * stored in the user store. + * + * It intentionally does not expose password hashes through public setters + * used by application code. The authentication service is responsible for + * creating and verifying password hashes. + */ + class User + { + public: + /** + * @brief Construct an empty user. + */ + User() = default; + + /** + * @brief Construct a user. + * + * @param id Stable user identifier. + * @param email User email address. + * @param password_hash Stored password hash. + * @param created_at Unix timestamp in seconds. + */ + User(std::string id, + std::string email, + std::string password_hash, + std::int64_t created_at); + + /** + * @brief Return the stable user identifier. + * + * @return User identifier. + */ + [[nodiscard]] const std::string &id() const noexcept; + + /** + * @brief Set the stable user identifier. + * + * @param value User identifier. + */ + void set_id(std::string value); + + /** + * @brief Return the user email address. + * + * @return User email address. + */ + [[nodiscard]] const std::string &email() const noexcept; + + /** + * @brief Set the user email address. + * + * @param value User email address. + */ + void set_email(std::string value); + + /** + * @brief Return the stored password hash. + * + * This value must never be sent to clients or logs. + * + * @return Stored password hash. + */ + [[nodiscard]] const std::string &password_hash() const noexcept; + + /** + * @brief Set the stored password hash. + * + * This function is intended for internal auth/store code. + * + * @param value Stored password hash. + */ + void set_password_hash(std::string value); + + /** + * @brief Return whether the user email address has been verified. + * + * @return true if the email is verified. + */ + [[nodiscard]] bool email_verified() const noexcept; + + /** + * @brief Set whether the user email address has been verified. + * + * @param value true when the email is verified. + */ + void set_email_verified(bool value) noexcept; + + /** + * @brief Return whether the user is active. + * + * Inactive users cannot authenticate. + * + * @return true if the user is active. + */ + [[nodiscard]] bool active() const noexcept; + + /** + * @brief Set whether the user is active. + * + * @param value true when the user is active. + */ + void set_active(bool value) noexcept; + + /** + * @brief Return the user creation timestamp. + * + * @return Unix timestamp in seconds. + */ + [[nodiscard]] std::int64_t created_at() const noexcept; + + /** + * @brief Set the user creation timestamp. + * + * @param value Unix timestamp in seconds. + */ + void set_created_at(std::int64_t value) noexcept; + + /** + * @brief Return the last update timestamp. + * + * @return Unix timestamp in seconds. + */ + [[nodiscard]] std::int64_t updated_at() const noexcept; + + /** + * @brief Set the last update timestamp. + * + * @param value Unix timestamp in seconds. + */ + void set_updated_at(std::int64_t value) noexcept; + + /** + * @brief Return true when the user has a non-empty id and email. + * + * @return true if the user has the minimum required identity fields. + */ + [[nodiscard]] bool valid() const noexcept; + + /** + * @brief Return true when the given email matches this user. + * + * @param value Email address to compare. + * @return true if the email is identical. + */ + [[nodiscard]] bool has_email(std::string_view value) const noexcept; + + private: + std::string id_; + std::string email_; + std::string password_hash_; + bool email_verified_ = false; + bool active_ = true; + std::int64_t created_at_ = 0; + std::int64_t updated_at_ = 0; + }; +} // namespace rixlib::auth + +#endif // RIXCPP_AUTH_INCLUDE_RIX_AUTH_USER_HPP_INCLUDED diff --git a/include/rix/auth/UserStore.hpp b/include/rix/auth/UserStore.hpp index e69de29..bcff363 100644 --- a/include/rix/auth/UserStore.hpp +++ b/include/rix/auth/UserStore.hpp @@ -0,0 +1,124 @@ +/** + * + * @file UserStore.hpp + * @author Gaspard Kirira + * + * Copyright 2026, Gaspard Kirira. + * All rights reserved. + * https://github.com/rixcpp/auth + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Rix + * + * User storage interface for rix/auth. + * + */ + +#ifndef RIXCPP_AUTH_INCLUDE_RIX_AUTH_USERSTORE_HPP_INCLUDED +#define RIXCPP_AUTH_INCLUDE_RIX_AUTH_USERSTORE_HPP_INCLUDED + +#include +#include + +#include +#include +#include +#include + +namespace rixlib::auth +{ + /** + * @brief Abstract storage interface for users. + * + * UserStore defines the persistence contract used by rix/auth to create, + * find, update, and delete users. + * + * The goal is to keep Auth simple for developers while allowing the storage + * implementation to evolve independently. Applications can later provide + * stores backed by memory, files, SQLite, MySQL, PostgreSQL, Redis, or any + * Vix-based storage module without changing the public Auth API. + */ + class UserStore + { + public: + /** + * @brief Destroy the user store. + */ + virtual ~UserStore(); + + /** + * @brief Save a new user. + * + * @param user User to create. + * @return AuthStatus indicating success or failure. + */ + [[nodiscard]] virtual AuthStatus create(const User &user) = 0; + + /** + * @brief Update an existing user. + * + * @param user User to update. + * @return AuthStatus indicating success or failure. + */ + [[nodiscard]] virtual AuthStatus update(const User &user) = 0; + + /** + * @brief Delete a user by identifier. + * + * @param id User identifier. + * @return AuthStatus indicating success or failure. + */ + [[nodiscard]] virtual AuthStatus remove_by_id(std::string_view id) = 0; + + /** + * @brief Find a user by identifier. + * + * @param id User identifier. + * @return Optional user when found, empty optional otherwise. + */ + [[nodiscard]] virtual AuthResult> + find_by_id(std::string_view id) const = 0; + + /** + * @brief Find a user by email address. + * + * @param email User email address. + * @return Optional user when found, empty optional otherwise. + */ + [[nodiscard]] virtual AuthResult> + find_by_email(std::string_view email) const = 0; + + /** + * @brief Return true when a user exists for the given identifier. + * + * @param id User identifier. + * @return AuthResult containing true when a matching user exists. + */ + [[nodiscard]] virtual AuthResult + exists_by_id(std::string_view id) const = 0; + + /** + * @brief Return true when a user exists for the given email address. + * + * @param email User email address. + * @return AuthResult containing true when a matching user exists. + */ + [[nodiscard]] virtual AuthResult + exists_by_email(std::string_view email) const = 0; + + /** + * @brief Return all stored users. + * + * This function is mainly useful for small stores, tests, admin tools, + * and future migrations. Large production stores may implement pagination + * later through a dedicated store type. + * + * @return AuthResult containing the list of users. + */ + [[nodiscard]] virtual AuthResult> all() const = 0; + }; +} // namespace rixlib::auth + +#endif // RIXCPP_AUTH_INCLUDE_RIX_AUTH_USERSTORE_HPP_INCLUDED diff --git a/include/rix/auth/Version.hpp b/include/rix/auth/Version.hpp index e69de29..4ef0a6c 100644 --- a/include/rix/auth/Version.hpp +++ b/include/rix/auth/Version.hpp @@ -0,0 +1,107 @@ +/** + * + * @file Version.hpp + * @author Gaspard Kirira + * + * Copyright 2026, Gaspard Kirira. + * All rights reserved. + * https://github.com/rixcpp/auth + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Rix + * + * Version information for rix/auth. + * + */ + +#ifndef RIXCPP_AUTH_INCLUDE_RIX_AUTH_VERSION_HPP_INCLUDED +#define RIXCPP_AUTH_INCLUDE_RIX_AUTH_VERSION_HPP_INCLUDED + +#include + +/// Major version component of rix/auth. +#define RIXCPP_AUTH_VERSION_MAJOR 0 + +/// Minor version component of rix/auth. +#define RIXCPP_AUTH_VERSION_MINOR 1 + +/// Patch version component of rix/auth. +#define RIXCPP_AUTH_VERSION_PATCH 0 + +/// Encoded rix/auth version: major * 10000 + minor * 100 + patch. +#define RIXCPP_AUTH_VERSION \ + (RIXCPP_AUTH_VERSION_MAJOR * 10000 + RIXCPP_AUTH_VERSION_MINOR * 100 + RIXCPP_AUTH_VERSION_PATCH) + +namespace rixlib::auth +{ + /** + * @brief Return the rix/auth package version as a string. + * + * The returned value follows semantic versioning: + * + * @code + * major.minor.patch + * @endcode + * + * Example: + * + * @code + * std::string current = rixlib::auth::version(); + * @endcode + * + * @return The package version, for example "0.1.0". + */ + [[nodiscard]] std::string version(); + + /** + * @brief Return the major version component. + * + * @return The major version number. + */ + [[nodiscard]] constexpr int version_major() noexcept + { + return RIXCPP_AUTH_VERSION_MAJOR; + } + + /** + * @brief Return the minor version component. + * + * @return The minor version number. + */ + [[nodiscard]] constexpr int version_minor() noexcept + { + return RIXCPP_AUTH_VERSION_MINOR; + } + + /** + * @brief Return the patch version component. + * + * @return The patch version number. + */ + [[nodiscard]] constexpr int version_patch() noexcept + { + return RIXCPP_AUTH_VERSION_PATCH; + } + + /** + * @brief Return the encoded integer version. + * + * The encoded version uses the following format: + * + * @code + * major * 10000 + minor * 100 + patch + * @endcode + * + * For version 0.1.0, this returns 100. + * + * @return The encoded version integer. + */ + [[nodiscard]] constexpr int version_number() noexcept + { + return RIXCPP_AUTH_VERSION; + } +} // namespace rixlib::auth + +#endif // RIXCPP_AUTH_INCLUDE_RIX_AUTH_VERSION_HPP_INCLUDED diff --git a/src/Auth.cpp b/src/Auth.cpp index e69de29..c363003 100644 --- a/src/Auth.cpp +++ b/src/Auth.cpp @@ -0,0 +1,313 @@ +/** + * + * @file Auth.cpp + * @author Gaspard Kirira + * + * Copyright 2026, Gaspard Kirira. + * All rights reserved. + * https://github.com/rixcpp/auth + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Rix + * + */ + +#include + +#include +#include +#include +#include +#include + +namespace rixlib::auth +{ + namespace + { + [[nodiscard]] std::string make_random_id(std::string_view prefix) + { + static thread_local std::mt19937_64 engine{ + std::random_device{}()}; + + const auto now = std::chrono::system_clock::now() + .time_since_epoch() + .count(); + + const auto value = engine(); + + std::ostringstream stream; + stream << prefix << "_" << now << "_" << value; + + return stream.str(); + } + } // namespace + + Auth::Auth(UserStore &users, SessionStore &sessions) + : Auth(users, sessions, AuthConfig::development()) + { + } + + Auth::Auth(UserStore &users, SessionStore &sessions, AuthConfig config) + : users_(&users), + sessions_(&sessions), + config_(std::move(config)) + { + password_hasher_.set_min_password_length(config_.min_password_length()); + } + + AuthResult Auth::register_user(const RegisterRequest &request) + { + const auto validation = validate_register_request(request); + if (validation.failed()) + { + return AuthResult::failure(validation.error()); + } + + auto exists = users_->exists_by_email(request.email); + if (exists.failed()) + { + return AuthResult::failure(exists.error()); + } + + if (exists.value()) + { + return AuthResult::failure( + make_auth_error(AuthErrorCode::UserAlreadyExists, + "A user already exists with this email address.")); + } + + auto password_hash = password_hasher_.hash(request.password); + if (password_hash.failed()) + { + return AuthResult::failure(password_hash.error()); + } + + const auto created_at = now_seconds(); + + User user{ + make_user_id(), + request.email, + password_hash.value(), + created_at}; + + user.set_email_verified(!config_.require_email_verification()); + user.set_active(true); + + auto status = users_->create(user); + if (status.failed()) + { + return AuthResult::failure(status.error()); + } + + return AuthResult::success(std::move(user)); + } + + AuthResult Auth::login(const LoginRequest &request) + { + const auto validation = validate_login_request(request); + if (validation.failed()) + { + return AuthResult::failure(validation.error()); + } + + auto found = users_->find_by_email(request.email); + if (found.failed()) + { + return AuthResult::failure(found.error()); + } + + if (!found.value().has_value()) + { + return AuthResult::failure( + make_auth_error(AuthErrorCode::InvalidCredentials, + "Invalid email or password.")); + } + + User user = found.value().value(); + + if (!user.active()) + { + return AuthResult::failure( + make_auth_error(AuthErrorCode::InvalidCredentials, + "Invalid email or password.")); + } + + if (config_.require_email_verification() && !user.email_verified()) + { + return AuthResult::failure( + make_auth_error(AuthErrorCode::InvalidState, + "Email verification is required before login.")); + } + + if (!password_hasher_.verify(request.password, user.password_hash())) + { + return AuthResult::failure( + make_auth_error(AuthErrorCode::InvalidCredentials, + "Invalid email or password.")); + } + + const auto now = now_seconds(); + + Session session{ + make_session_id(), + user.id(), + now, + now + config_.session_ttl_seconds()}; + + auto status = sessions_->create(session); + if (status.failed()) + { + return AuthResult::failure(status.error()); + } + + LoginResult result{ + std::move(user), + std::move(session)}; + + return AuthResult::success(std::move(result)); + } + + AuthStatus Auth::logout(std::string_view session_id) + { + if (session_id.empty()) + { + return AuthStatus::failure( + make_auth_error(AuthErrorCode::InvalidSession, + "Session id cannot be empty.")); + } + + return sessions_->revoke_by_id(session_id); + } + + AuthResult Auth::authenticate_session(std::string_view session_id) + { + if (session_id.empty()) + { + return AuthResult::failure( + make_auth_error(AuthErrorCode::InvalidSession, + "Session id cannot be empty.")); + } + + auto found = sessions_->find_by_id(session_id); + if (found.failed()) + { + return AuthResult::failure(found.error()); + } + + if (!found.value().has_value()) + { + return AuthResult::failure( + make_auth_error(AuthErrorCode::InvalidSession, + "Session not found.")); + } + + Session session = found.value().value(); + + if (session.revoked()) + { + return AuthResult::failure( + make_auth_error(AuthErrorCode::InvalidSession, + "Session has been revoked.")); + } + + if (session.expired(now_seconds())) + { + return AuthResult::failure( + make_auth_error(AuthErrorCode::SessionExpired, + "Session has expired.")); + } + + session.set_last_seen_at(now_seconds()); + + auto status = sessions_->update(session); + if (status.failed()) + { + return AuthResult::failure(status.error()); + } + + return AuthResult::success(std::move(session)); + } + + const AuthConfig &Auth::config() const noexcept + { + return config_; + } + + const PasswordHasher &Auth::password_hasher() const noexcept + { + return password_hasher_; + } + + AuthStatus Auth::validate_register_request( + const RegisterRequest &request) const + { + if (!is_valid_email(request.email)) + { + return AuthStatus::failure( + make_auth_error(AuthErrorCode::InvalidEmail, + "Email address is invalid.")); + } + + if (!password_hasher_.accepts(request.password)) + { + return AuthStatus::failure( + make_auth_error(AuthErrorCode::InvalidPassword, + "Password does not satisfy the minimum length policy.")); + } + + return AuthStatus::success(); + } + + AuthStatus Auth::validate_login_request( + const LoginRequest &request) const + { + if (!is_valid_email(request.email)) + { + return AuthStatus::failure( + make_auth_error(AuthErrorCode::InvalidEmail, + "Email address is invalid.")); + } + + if (request.password.empty()) + { + return AuthStatus::failure( + make_auth_error(AuthErrorCode::InvalidPassword, + "Password cannot be empty.")); + } + + return AuthStatus::success(); + } + + bool Auth::is_valid_email(std::string_view email) const noexcept + { + const auto at = email.find('@'); + if (at == std::string_view::npos || at == 0 || at + 1 >= email.size()) + { + return false; + } + + const auto dot = email.find('.', at + 1); + return dot != std::string_view::npos && dot + 1 < email.size(); + } + + std::string Auth::make_user_id() const + { + return make_random_id("user"); + } + + std::string Auth::make_session_id() const + { + return make_random_id("session"); + } + + std::int64_t Auth::now_seconds() const noexcept + { + const auto now = std::chrono::system_clock::now(); + const auto seconds = std::chrono::duration_cast( + now.time_since_epoch()); + + return seconds.count(); + } +} // namespace rixlib::auth diff --git a/src/AuthConfig.cpp b/src/AuthConfig.cpp index e69de29..898a958 100644 --- a/src/AuthConfig.cpp +++ b/src/AuthConfig.cpp @@ -0,0 +1,108 @@ +/** + * + * @file AuthConfig.cpp + * @author Gaspard Kirira + * + * Copyright 2026, Gaspard Kirira. + * All rights reserved. + * https://github.com/rixcpp/auth + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Rix + * + */ + +#include + +#include + +namespace rixlib::auth +{ + AuthConfig::AuthConfig() = default; + + AuthConfig AuthConfig::development() + { + AuthConfig config; + config.set_min_password_length(8); + config.set_session_ttl_seconds(60 * 60 * 24 * 7); + config.set_token_ttl_seconds(60 * 15); + config.set_issuer("rix/auth"); + config.set_require_email_verification(false); + config.set_rotate_sessions(true); + return config; + } + + AuthConfig AuthConfig::production() + { + AuthConfig config; + config.set_min_password_length(12); + config.set_session_ttl_seconds(60 * 60 * 24 * 7); + config.set_token_ttl_seconds(60 * 10); + config.set_issuer("rix/auth"); + config.set_require_email_verification(true); + config.set_rotate_sessions(true); + return config; + } + + std::size_t AuthConfig::min_password_length() const noexcept + { + return min_password_length_; + } + + void AuthConfig::set_min_password_length(std::size_t value) noexcept + { + min_password_length_ = value; + } + + std::int64_t AuthConfig::session_ttl_seconds() const noexcept + { + return session_ttl_seconds_; + } + + void AuthConfig::set_session_ttl_seconds(std::int64_t value) noexcept + { + session_ttl_seconds_ = value; + } + + std::int64_t AuthConfig::token_ttl_seconds() const noexcept + { + return token_ttl_seconds_; + } + + void AuthConfig::set_token_ttl_seconds(std::int64_t value) noexcept + { + token_ttl_seconds_ = value; + } + + const std::string &AuthConfig::issuer() const noexcept + { + return issuer_; + } + + void AuthConfig::set_issuer(std::string value) + { + issuer_ = std::move(value); + } + + bool AuthConfig::require_email_verification() const noexcept + { + return require_email_verification_; + } + + void AuthConfig::set_require_email_verification(bool value) noexcept + { + require_email_verification_ = value; + } + + bool AuthConfig::rotate_sessions() const noexcept + { + return rotate_sessions_; + } + + void AuthConfig::set_rotate_sessions(bool value) noexcept + { + rotate_sessions_ = value; + } +} // namespace rixlib::auth diff --git a/src/AuthError.cpp b/src/AuthError.cpp index e69de29..4328d2b 100644 --- a/src/AuthError.cpp +++ b/src/AuthError.cpp @@ -0,0 +1,94 @@ +/** + * + * @file AuthError.cpp + * @author Gaspard Kirira + * + * Copyright 2026, Gaspard Kirira. + * All rights reserved. + * https://github.com/rixcpp/auth + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Rix + * + */ + +#include + +#include + +namespace rixlib::auth +{ + AuthError::AuthError(AuthErrorCode code, std::string message) + : code_(code), message_(std::move(message)) + { + } + + bool AuthError::has_error() const noexcept + { + return code_ != AuthErrorCode::None; + } + + bool AuthError::ok() const noexcept + { + return code_ == AuthErrorCode::None; + } + + AuthErrorCode AuthError::code() const noexcept + { + return code_; + } + + const std::string &AuthError::message() const noexcept + { + return message_; + } + + std::string_view to_string(AuthErrorCode code) noexcept + { + switch (code) + { + case AuthErrorCode::None: + return "None"; + case AuthErrorCode::InvalidInput: + return "InvalidInput"; + case AuthErrorCode::InvalidEmail: + return "InvalidEmail"; + case AuthErrorCode::InvalidPassword: + return "InvalidPassword"; + case AuthErrorCode::UserNotFound: + return "UserNotFound"; + case AuthErrorCode::UserAlreadyExists: + return "UserAlreadyExists"; + case AuthErrorCode::InvalidCredentials: + return "InvalidCredentials"; + case AuthErrorCode::InvalidSession: + return "InvalidSession"; + case AuthErrorCode::SessionExpired: + return "SessionExpired"; + case AuthErrorCode::InvalidToken: + return "InvalidToken"; + case AuthErrorCode::TokenExpired: + return "TokenExpired"; + case AuthErrorCode::InvalidState: + return "InvalidState"; + case AuthErrorCode::StoreError: + return "StoreError"; + case AuthErrorCode::Unknown: + return "Unknown"; + } + + return "Unknown"; + } + + AuthError make_auth_ok() + { + return AuthError{}; + } + + AuthError make_auth_error(AuthErrorCode code, std::string message) + { + return AuthError{code, std::move(message)}; + } +} // namespace rixlib::auth diff --git a/src/PasswordHasher.cpp b/src/PasswordHasher.cpp index e69de29..51cc859 100644 --- a/src/PasswordHasher.cpp +++ b/src/PasswordHasher.cpp @@ -0,0 +1,77 @@ +/** + * + * @file PasswordHasher.cpp + * @author Gaspard Kirira + * + * Copyright 2026, Gaspard Kirira. + * All rights reserved. + * https://github.com/rixcpp/auth + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Rix + * + */ + +#include + +#include +#include +#include + +namespace rixlib::auth +{ + namespace + { + [[nodiscard]] std::string make_basic_hash(std::string_view password) + { + const auto value = std::hash{}(password); + + std::ostringstream stream; + stream << "rix-auth-basic$" << value; + + return stream.str(); + } + } // namespace + + PasswordHasher::PasswordHasher() = default; + + AuthResult PasswordHasher::hash(std::string_view password) const + { + if (!accepts(password)) + { + return AuthResult::failure( + make_auth_error(AuthErrorCode::InvalidPassword, + "Password does not satisfy the minimum length policy.")); + } + + return AuthResult::success(make_basic_hash(password)); + } + + bool PasswordHasher::verify(std::string_view password, + std::string_view password_hash) const + { + if (!accepts(password) || password_hash.empty()) + { + return false; + } + + return make_basic_hash(password) == password_hash; + } + + std::size_t PasswordHasher::min_password_length() const noexcept + { + return min_password_length_; + } + + void PasswordHasher::set_min_password_length(std::size_t value) noexcept + { + min_password_length_ = value; + } + + bool PasswordHasher::accepts(std::string_view password) const noexcept + { + return password.size() >= min_password_length_; + } +} // namespace rixlib::auth diff --git a/src/Session.cpp b/src/Session.cpp index e69de29..ed2bb03 100644 --- a/src/Session.cpp +++ b/src/Session.cpp @@ -0,0 +1,114 @@ +/** + * + * @file Session.cpp + * @author Gaspard Kirira + * + * Copyright 2026, Gaspard Kirira. + * All rights reserved. + * https://github.com/rixcpp/auth + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Rix + * + */ + +#include + +#include + +namespace rixlib::auth +{ + Session::Session(std::string id, + std::string user_id, + std::int64_t created_at, + std::int64_t expires_at) + : id_(std::move(id)), + user_id_(std::move(user_id)), + created_at_(created_at), + expires_at_(expires_at), + last_seen_at_(created_at) + { + } + + const std::string &Session::id() const noexcept + { + return id_; + } + + void Session::set_id(std::string value) + { + id_ = std::move(value); + } + + const std::string &Session::user_id() const noexcept + { + return user_id_; + } + + void Session::set_user_id(std::string value) + { + user_id_ = std::move(value); + } + + std::int64_t Session::created_at() const noexcept + { + return created_at_; + } + + void Session::set_created_at(std::int64_t value) noexcept + { + created_at_ = value; + } + + std::int64_t Session::expires_at() const noexcept + { + return expires_at_; + } + + void Session::set_expires_at(std::int64_t value) noexcept + { + expires_at_ = value; + } + + std::int64_t Session::last_seen_at() const noexcept + { + return last_seen_at_; + } + + void Session::set_last_seen_at(std::int64_t value) noexcept + { + last_seen_at_ = value; + } + + bool Session::revoked() const noexcept + { + return revoked_; + } + + void Session::set_revoked(bool value) noexcept + { + revoked_ = value; + } + + bool Session::valid() const noexcept + { + return !id_.empty() && !user_id_.empty(); + } + + bool Session::belongs_to(std::string_view value) const noexcept + { + return user_id_ == value; + } + + bool Session::expired(std::int64_t now) const noexcept + { + return expires_at_ > 0 && now >= expires_at_; + } + + bool Session::usable(std::int64_t now) const noexcept + { + return valid() && !revoked_ && !expired(now); + } +} // namespace rixlib::auth diff --git a/src/SessionStore.cpp b/src/SessionStore.cpp index e69de29..4289211 100644 --- a/src/SessionStore.cpp +++ b/src/SessionStore.cpp @@ -0,0 +1,22 @@ +/** + * + * @file SessionStore.cpp + * @author Gaspard Kirira + * + * Copyright 2026, Gaspard Kirira. + * All rights reserved. + * https://github.com/rixcpp/auth + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Rix + * + */ + +#include + +namespace rixlib::auth +{ + SessionStore::~SessionStore() = default; +} // namespace rixlib::auth diff --git a/src/Token.cpp b/src/Token.cpp index e69de29..5c5edc9 100644 --- a/src/Token.cpp +++ b/src/Token.cpp @@ -0,0 +1,118 @@ +/** + * + * @file Token.cpp + * @author Gaspard Kirira + * + * Copyright 2026, Gaspard Kirira. + * All rights reserved. + * https://github.com/rixcpp/auth + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Rix + * + */ + +#include + +#include + +namespace rixlib::auth +{ + Token::Token(std::string value, + std::string user_id, + std::int64_t issued_at, + std::int64_t expires_at) + : value_(std::move(value)), + user_id_(std::move(user_id)), + issued_at_(issued_at), + expires_at_(expires_at) + { + } + + const std::string &Token::value() const noexcept + { + return value_; + } + + void Token::set_value(std::string value) + { + value_ = std::move(value); + } + + const std::string &Token::user_id() const noexcept + { + return user_id_; + } + + void Token::set_user_id(std::string value) + { + user_id_ = std::move(value); + } + + const std::string &Token::issuer() const noexcept + { + return issuer_; + } + + void Token::set_issuer(std::string value) + { + issuer_ = std::move(value); + } + + std::int64_t Token::issued_at() const noexcept + { + return issued_at_; + } + + void Token::set_issued_at(std::int64_t value) noexcept + { + issued_at_ = value; + } + + std::int64_t Token::expires_at() const noexcept + { + return expires_at_; + } + + void Token::set_expires_at(std::int64_t value) noexcept + { + expires_at_ = value; + } + + bool Token::revoked() const noexcept + { + return revoked_; + } + + void Token::set_revoked(bool value) noexcept + { + revoked_ = value; + } + + bool Token::valid() const noexcept + { + return !value_.empty() && !user_id_.empty(); + } + + bool Token::belongs_to(std::string_view value) const noexcept + { + return user_id_ == value; + } + + bool Token::matches(std::string_view value) const noexcept + { + return value_ == value; + } + + bool Token::expired(std::int64_t now) const noexcept + { + return expires_at_ > 0 && now >= expires_at_; + } + + bool Token::usable(std::int64_t now) const noexcept + { + return valid() && !revoked_ && !expired(now); + } +} // namespace rixlib::auth diff --git a/src/User.cpp b/src/User.cpp index e69de29..b6a601e 100644 --- a/src/User.cpp +++ b/src/User.cpp @@ -0,0 +1,114 @@ +/** + * + * @file User.cpp + * @author Gaspard Kirira + * + * Copyright 2026, Gaspard Kirira. + * All rights reserved. + * https://github.com/rixcpp/auth + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Rix + * + */ + +#include + +#include + +namespace rixlib::auth +{ + User::User(std::string id, + std::string email, + std::string password_hash, + std::int64_t created_at) + : id_(std::move(id)), + email_(std::move(email)), + password_hash_(std::move(password_hash)), + created_at_(created_at), + updated_at_(created_at) + { + } + + const std::string &User::id() const noexcept + { + return id_; + } + + void User::set_id(std::string value) + { + id_ = std::move(value); + } + + const std::string &User::email() const noexcept + { + return email_; + } + + void User::set_email(std::string value) + { + email_ = std::move(value); + } + + const std::string &User::password_hash() const noexcept + { + return password_hash_; + } + + void User::set_password_hash(std::string value) + { + password_hash_ = std::move(value); + } + + bool User::email_verified() const noexcept + { + return email_verified_; + } + + void User::set_email_verified(bool value) noexcept + { + email_verified_ = value; + } + + bool User::active() const noexcept + { + return active_; + } + + void User::set_active(bool value) noexcept + { + active_ = value; + } + + std::int64_t User::created_at() const noexcept + { + return created_at_; + } + + void User::set_created_at(std::int64_t value) noexcept + { + created_at_ = value; + } + + std::int64_t User::updated_at() const noexcept + { + return updated_at_; + } + + void User::set_updated_at(std::int64_t value) noexcept + { + updated_at_ = value; + } + + bool User::valid() const noexcept + { + return !id_.empty() && !email_.empty(); + } + + bool User::has_email(std::string_view value) const noexcept + { + return email_ == value; + } +} // namespace rixlib::auth diff --git a/src/UserStore.cpp b/src/UserStore.cpp index e69de29..653cad3 100644 --- a/src/UserStore.cpp +++ b/src/UserStore.cpp @@ -0,0 +1,22 @@ +/** + * + * @file UserStore.cpp + * @author Gaspard Kirira + * + * Copyright 2026, Gaspard Kirira. + * All rights reserved. + * https://github.com/rixcpp/auth + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Rix + * + */ + +#include + +namespace rixlib::auth +{ + UserStore::~UserStore() = default; +} // namespace rixlib::auth diff --git a/src/Version.cpp b/src/Version.cpp index e69de29..de572af 100644 --- a/src/Version.cpp +++ b/src/Version.cpp @@ -0,0 +1,27 @@ +/** + * + * @file Version.cpp + * @author Gaspard Kirira + * + * Copyright 2026, Gaspard Kirira. + * All rights reserved. + * https://github.com/rixcpp/auth + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Rix + * + */ + +#include + +namespace rixlib::auth +{ + std::string version() + { + return std::to_string(RIXCPP_AUTH_VERSION_MAJOR) + "." + + std::to_string(RIXCPP_AUTH_VERSION_MINOR) + "." + + std::to_string(RIXCPP_AUTH_VERSION_PATCH); + } +} // namespace rixlib::auth diff --git a/tests/AuthTests.cpp b/tests/AuthTests.cpp index e69de29..9b271d1 100644 --- a/tests/AuthTests.cpp +++ b/tests/AuthTests.cpp @@ -0,0 +1,529 @@ +/** + * + * @file AuthTests.cpp + * @author Gaspard Kirira + * + * Copyright 2026, Gaspard Kirira. + * All rights reserved. + * https://github.com/rixcpp/auth + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Rix + * + */ + +#include + +#include +#include +#include +#include +#include +#include + +namespace +{ + class MemoryUserStore final : public rixlib::auth::UserStore + { + public: + [[nodiscard]] rixlib::auth::AuthStatus create( + const rixlib::auth::User &user) override + { + if (!user.valid()) + { + return rixlib::auth::AuthStatus::failure( + rixlib::auth::make_auth_error( + rixlib::auth::AuthErrorCode::InvalidInput, + "User is invalid.")); + } + + const auto exists = std::any_of( + users_.begin(), + users_.end(), + [&](const rixlib::auth::User &stored) + { + return stored.id() == user.id() || stored.email() == user.email(); + }); + + if (exists) + { + return rixlib::auth::AuthStatus::failure( + rixlib::auth::make_auth_error( + rixlib::auth::AuthErrorCode::UserAlreadyExists, + "User already exists.")); + } + + users_.push_back(user); + return rixlib::auth::AuthStatus::success(); + } + + [[nodiscard]] rixlib::auth::AuthStatus update( + const rixlib::auth::User &user) override + { + for (rixlib::auth::User &stored : users_) + { + if (stored.id() == user.id()) + { + stored = user; + return rixlib::auth::AuthStatus::success(); + } + } + + return rixlib::auth::AuthStatus::failure( + rixlib::auth::make_auth_error( + rixlib::auth::AuthErrorCode::UserNotFound, + "User not found.")); + } + + [[nodiscard]] rixlib::auth::AuthStatus remove_by_id( + std::string_view id) override + { + const auto old_size = users_.size(); + + users_.erase( + std::remove_if( + users_.begin(), + users_.end(), + [&](const rixlib::auth::User &user) + { + return user.id() == id; + }), + users_.end()); + + if (users_.size() == old_size) + { + return rixlib::auth::AuthStatus::failure( + rixlib::auth::make_auth_error( + rixlib::auth::AuthErrorCode::UserNotFound, + "User not found.")); + } + + return rixlib::auth::AuthStatus::success(); + } + + [[nodiscard]] rixlib::auth::AuthResult> + find_by_id(std::string_view id) const override + { + for (const rixlib::auth::User &user : users_) + { + if (user.id() == id) + { + return rixlib::auth::AuthResult>::success(user); + } + } + + return rixlib::auth::AuthResult>::success(std::nullopt); + } + + [[nodiscard]] rixlib::auth::AuthResult> + find_by_email(std::string_view email) const override + { + for (const rixlib::auth::User &user : users_) + { + if (user.email() == email) + { + return rixlib::auth::AuthResult>::success(user); + } + } + + return rixlib::auth::AuthResult>::success(std::nullopt); + } + + [[nodiscard]] rixlib::auth::AuthResult + exists_by_id(std::string_view id) const override + { + const auto exists = std::any_of( + users_.begin(), + users_.end(), + [&](const rixlib::auth::User &user) + { + return user.id() == id; + }); + + return rixlib::auth::AuthResult::success(exists); + } + + [[nodiscard]] rixlib::auth::AuthResult + exists_by_email(std::string_view email) const override + { + const auto exists = std::any_of( + users_.begin(), + users_.end(), + [&](const rixlib::auth::User &user) + { + return user.email() == email; + }); + + return rixlib::auth::AuthResult::success(exists); + } + + [[nodiscard]] rixlib::auth::AuthResult> + all() const override + { + return rixlib::auth::AuthResult>::success(users_); + } + + private: + std::vector users_; + }; + + class MemorySessionStore final : public rixlib::auth::SessionStore + { + public: + [[nodiscard]] rixlib::auth::AuthStatus create( + const rixlib::auth::Session &session) override + { + if (!session.valid()) + { + return rixlib::auth::AuthStatus::failure( + rixlib::auth::make_auth_error( + rixlib::auth::AuthErrorCode::InvalidInput, + "Session is invalid.")); + } + + const auto exists = std::any_of( + sessions_.begin(), + sessions_.end(), + [&](const rixlib::auth::Session &stored) + { + return stored.id() == session.id(); + }); + + if (exists) + { + return rixlib::auth::AuthStatus::failure( + rixlib::auth::make_auth_error( + rixlib::auth::AuthErrorCode::InvalidSession, + "Session already exists.")); + } + + sessions_.push_back(session); + return rixlib::auth::AuthStatus::success(); + } + + [[nodiscard]] rixlib::auth::AuthStatus update( + const rixlib::auth::Session &session) override + { + for (rixlib::auth::Session &stored : sessions_) + { + if (stored.id() == session.id()) + { + stored = session; + return rixlib::auth::AuthStatus::success(); + } + } + + return rixlib::auth::AuthStatus::failure( + rixlib::auth::make_auth_error( + rixlib::auth::AuthErrorCode::InvalidSession, + "Session not found.")); + } + + [[nodiscard]] rixlib::auth::AuthStatus remove_by_id( + std::string_view id) override + { + const auto old_size = sessions_.size(); + + sessions_.erase( + std::remove_if( + sessions_.begin(), + sessions_.end(), + [&](const rixlib::auth::Session &session) + { + return session.id() == id; + }), + sessions_.end()); + + if (sessions_.size() == old_size) + { + return rixlib::auth::AuthStatus::failure( + rixlib::auth::make_auth_error( + rixlib::auth::AuthErrorCode::InvalidSession, + "Session not found.")); + } + + return rixlib::auth::AuthStatus::success(); + } + + [[nodiscard]] rixlib::auth::AuthStatus revoke_by_id( + std::string_view id) override + { + for (rixlib::auth::Session &session : sessions_) + { + if (session.id() == id) + { + session.set_revoked(true); + return rixlib::auth::AuthStatus::success(); + } + } + + return rixlib::auth::AuthStatus::failure( + rixlib::auth::make_auth_error( + rixlib::auth::AuthErrorCode::InvalidSession, + "Session not found.")); + } + + [[nodiscard]] rixlib::auth::AuthStatus revoke_by_user_id( + std::string_view user_id) override + { + bool changed = false; + + for (rixlib::auth::Session &session : sessions_) + { + if (session.user_id() == user_id) + { + session.set_revoked(true); + changed = true; + } + } + + if (!changed) + { + return rixlib::auth::AuthStatus::failure( + rixlib::auth::make_auth_error( + rixlib::auth::AuthErrorCode::InvalidSession, + "Session not found.")); + } + + return rixlib::auth::AuthStatus::success(); + } + + [[nodiscard]] rixlib::auth::AuthResult> + find_by_id(std::string_view id) const override + { + for (const rixlib::auth::Session &session : sessions_) + { + if (session.id() == id) + { + return rixlib::auth::AuthResult>::success(session); + } + } + + return rixlib::auth::AuthResult>::success(std::nullopt); + } + + [[nodiscard]] rixlib::auth::AuthResult> + find_by_user_id(std::string_view user_id) const override + { + std::vector result; + + for (const rixlib::auth::Session &session : sessions_) + { + if (session.user_id() == user_id) + { + result.push_back(session); + } + } + + return rixlib::auth::AuthResult>::success(result); + } + + [[nodiscard]] rixlib::auth::AuthResult + exists_by_id(std::string_view id) const override + { + const auto exists = std::any_of( + sessions_.begin(), + sessions_.end(), + [&](const rixlib::auth::Session &session) + { + return session.id() == id; + }); + + return rixlib::auth::AuthResult::success(exists); + } + + [[nodiscard]] rixlib::auth::AuthResult> + all() const override + { + return rixlib::auth::AuthResult>::success(sessions_); + } + + private: + std::vector sessions_; + }; + + void test_register_user_creates_user() + { + MemoryUserStore users; + MemorySessionStore sessions; + rixlib::auth::Auth auth{users, sessions}; + + const auto result = auth.register_user( + rixlib::auth::RegisterRequest{ + "ada@example.com", + "secret123"}); + + assert(result.ok()); + assert(result.value().valid()); + assert(result.value().email() == "ada@example.com"); + assert(!result.value().password_hash().empty()); + + const auto exists = users.exists_by_email("ada@example.com"); + + assert(exists.ok()); + assert(exists.value()); + } + + void test_register_user_rejects_invalid_email() + { + MemoryUserStore users; + MemorySessionStore sessions; + rixlib::auth::Auth auth{users, sessions}; + + const auto result = auth.register_user( + rixlib::auth::RegisterRequest{ + "invalid-email", + "secret123"}); + + assert(result.failed()); + assert(result.error().code() == rixlib::auth::AuthErrorCode::InvalidEmail); + } + + void test_register_user_rejects_short_password() + { + MemoryUserStore users; + MemorySessionStore sessions; + rixlib::auth::Auth auth{users, sessions}; + + const auto result = auth.register_user( + rixlib::auth::RegisterRequest{ + "ada@example.com", + "short"}); + + assert(result.failed()); + assert(result.error().code() == rixlib::auth::AuthErrorCode::InvalidPassword); + } + + void test_register_user_rejects_duplicate_email() + { + MemoryUserStore users; + MemorySessionStore sessions; + rixlib::auth::Auth auth{users, sessions}; + + const auto first = auth.register_user( + rixlib::auth::RegisterRequest{ + "ada@example.com", + "secret123"}); + + const auto second = auth.register_user( + rixlib::auth::RegisterRequest{ + "ada@example.com", + "secret123"}); + + assert(first.ok()); + assert(second.failed()); + assert(second.error().code() == rixlib::auth::AuthErrorCode::UserAlreadyExists); + } + + void test_login_creates_session() + { + MemoryUserStore users; + MemorySessionStore sessions; + rixlib::auth::Auth auth{users, sessions}; + + const auto registered = auth.register_user( + rixlib::auth::RegisterRequest{ + "ada@example.com", + "secret123"}); + + const auto logged_in = auth.login( + rixlib::auth::LoginRequest{ + "ada@example.com", + "secret123"}); + + assert(registered.ok()); + assert(logged_in.ok()); + assert(logged_in.value().user.email() == "ada@example.com"); + assert(logged_in.value().session.valid()); + assert(logged_in.value().session.user_id() == logged_in.value().user.id()); + } + + void test_login_rejects_wrong_password() + { + MemoryUserStore users; + MemorySessionStore sessions; + rixlib::auth::Auth auth{users, sessions}; + + const auto registered = auth.register_user( + rixlib::auth::RegisterRequest{ + "ada@example.com", + "secret123"}); + + const auto logged_in = auth.login( + rixlib::auth::LoginRequest{ + "ada@example.com", + "wrong-password"}); + + assert(registered.ok()); + assert(logged_in.failed()); + assert(logged_in.error().code() == rixlib::auth::AuthErrorCode::InvalidCredentials); + } + + void test_authenticate_session_returns_session() + { + MemoryUserStore users; + MemorySessionStore sessions; + rixlib::auth::Auth auth{users, sessions}; + + const auto registered = auth.register_user( + rixlib::auth::RegisterRequest{ + "ada@example.com", + "secret123"}); + + const auto logged_in = auth.login( + rixlib::auth::LoginRequest{ + "ada@example.com", + "secret123"}); + + const auto authenticated = auth.authenticate_session( + logged_in.value().session.id()); + + assert(registered.ok()); + assert(logged_in.ok()); + assert(authenticated.ok()); + assert(authenticated.value().id() == logged_in.value().session.id()); + } + + void test_logout_revokes_session() + { + MemoryUserStore users; + MemorySessionStore sessions; + rixlib::auth::Auth auth{users, sessions}; + + const auto registered = auth.register_user( + rixlib::auth::RegisterRequest{ + "ada@example.com", + "secret123"}); + + const auto logged_in = auth.login( + rixlib::auth::LoginRequest{ + "ada@example.com", + "secret123"}); + + const auto logout = auth.logout(logged_in.value().session.id()); + const auto authenticated = auth.authenticate_session( + logged_in.value().session.id()); + + assert(registered.ok()); + assert(logged_in.ok()); + assert(logout.ok()); + assert(authenticated.failed()); + assert(authenticated.error().code() == rixlib::auth::AuthErrorCode::InvalidSession); + } +} // namespace + +int main() +{ + test_register_user_creates_user(); + test_register_user_rejects_invalid_email(); + test_register_user_rejects_short_password(); + test_register_user_rejects_duplicate_email(); + test_login_creates_session(); + test_login_rejects_wrong_password(); + test_authenticate_session_returns_session(); + test_logout_revokes_session(); + + return 0; +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e69de29..433f32d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -0,0 +1,69 @@ +add_executable(rix_auth_user_tests + UserTests.cpp +) + +target_link_libraries(rix_auth_user_tests + PRIVATE + rix::auth +) + +add_test( + NAME rix_auth_user_tests + COMMAND rix_auth_user_tests +) + +add_executable(rix_auth_session_tests + SessionTests.cpp +) + +target_link_libraries(rix_auth_session_tests + PRIVATE + rix::auth +) + +add_test( + NAME rix_auth_session_tests + COMMAND rix_auth_session_tests +) + +add_executable(rix_auth_token_tests + TokenTests.cpp +) + +target_link_libraries(rix_auth_token_tests + PRIVATE + rix::auth +) + +add_test( + NAME rix_auth_token_tests + COMMAND rix_auth_token_tests +) + +add_executable(rix_auth_password_hasher_tests + PasswordHasherTests.cpp +) + +target_link_libraries(rix_auth_password_hasher_tests + PRIVATE + rix::auth +) + +add_test( + NAME rix_auth_password_hasher_tests + COMMAND rix_auth_password_hasher_tests +) + +add_executable(rix_auth_auth_tests + AuthTests.cpp +) + +target_link_libraries(rix_auth_auth_tests + PRIVATE + rix::auth +) + +add_test( + NAME rix_auth_auth_tests + COMMAND rix_auth_auth_tests +) diff --git a/tests/PasswordHasherTests.cpp b/tests/PasswordHasherTests.cpp index e69de29..7de06c2 100644 --- a/tests/PasswordHasherTests.cpp +++ b/tests/PasswordHasherTests.cpp @@ -0,0 +1,124 @@ +/** + * + * @file PasswordHasherTests.cpp + * @author Gaspard Kirira + * + * Copyright 2026, Gaspard Kirira. + * All rights reserved. + * https://github.com/rixcpp/auth + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Rix + * + */ + +#include + +#include +#include + +namespace +{ + void test_default_min_password_length_is_eight() + { + const rixlib::auth::PasswordHasher hasher; + + assert(hasher.min_password_length() == 8); + } + + void test_set_min_password_length_updates_policy() + { + rixlib::auth::PasswordHasher hasher; + + hasher.set_min_password_length(12); + + assert(hasher.min_password_length() == 12); + assert(!hasher.accepts("short123")); + assert(hasher.accepts("long-password")); + } + + void test_accepts_password_when_length_is_valid() + { + const rixlib::auth::PasswordHasher hasher; + + assert(hasher.accepts("secret123")); + assert(!hasher.accepts("short")); + assert(!hasher.accepts("")); + } + + void test_hash_fails_when_password_is_too_short() + { + const rixlib::auth::PasswordHasher hasher; + + const auto result = hasher.hash("short"); + + assert(result.failed()); + assert(result.error().code() == rixlib::auth::AuthErrorCode::InvalidPassword); + } + + void test_hash_succeeds_when_password_is_valid() + { + const rixlib::auth::PasswordHasher hasher; + + const auto result = hasher.hash("secret123"); + + assert(result.ok()); + assert(!result.value().empty()); + } + + void test_verify_returns_true_for_matching_password() + { + const rixlib::auth::PasswordHasher hasher; + + const auto result = hasher.hash("secret123"); + + assert(result.ok()); + assert(hasher.verify("secret123", result.value())); + } + + void test_verify_returns_false_for_wrong_password() + { + const rixlib::auth::PasswordHasher hasher; + + const auto result = hasher.hash("secret123"); + + assert(result.ok()); + assert(!hasher.verify("another-password", result.value())); + } + + void test_verify_returns_false_for_empty_hash() + { + const rixlib::auth::PasswordHasher hasher; + + assert(!hasher.verify("secret123", "")); + } + + void test_same_password_produces_same_hash_for_current_basic_hasher() + { + const rixlib::auth::PasswordHasher hasher; + + const auto first = hasher.hash("secret123"); + const auto second = hasher.hash("secret123"); + + assert(first.ok()); + assert(second.ok()); + assert(first.value() == second.value()); + } +} // namespace + +int main() +{ + test_default_min_password_length_is_eight(); + test_set_min_password_length_updates_policy(); + test_accepts_password_when_length_is_valid(); + test_hash_fails_when_password_is_too_short(); + test_hash_succeeds_when_password_is_valid(); + test_verify_returns_true_for_matching_password(); + test_verify_returns_false_for_wrong_password(); + test_verify_returns_false_for_empty_hash(); + test_same_password_produces_same_hash_for_current_basic_hasher(); + + return 0; +} diff --git a/tests/SessionTests.cpp b/tests/SessionTests.cpp index e69de29..84ed5e8 100644 --- a/tests/SessionTests.cpp +++ b/tests/SessionTests.cpp @@ -0,0 +1,155 @@ +/** + * + * @file SessionTests.cpp + * @author Gaspard Kirira + * + * Copyright 2026, Gaspard Kirira. + * All rights reserved. + * https://github.com/rixcpp/auth + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Rix + * + */ + +#include + +#include +#include + +namespace +{ + void test_default_session_is_invalid() + { + const rixlib::auth::Session session; + + assert(session.id().empty()); + assert(session.user_id().empty()); + assert(session.created_at() == 0); + assert(session.expires_at() == 0); + assert(session.last_seen_at() == 0); + assert(!session.revoked()); + assert(!session.valid()); + } + + void test_constructed_session_has_expected_values() + { + const std::int64_t created_at = 1000; + const std::int64_t expires_at = 2000; + + const rixlib::auth::Session session{ + "session_1", + "user_1", + created_at, + expires_at}; + + assert(session.id() == "session_1"); + assert(session.user_id() == "user_1"); + assert(session.created_at() == created_at); + assert(session.expires_at() == expires_at); + assert(session.last_seen_at() == created_at); + assert(!session.revoked()); + assert(session.valid()); + } + + void test_session_setters_update_values() + { + rixlib::auth::Session session; + + session.set_id("session_2"); + session.set_user_id("user_2"); + session.set_created_at(3000); + session.set_expires_at(4000); + session.set_last_seen_at(3500); + session.set_revoked(true); + + assert(session.id() == "session_2"); + assert(session.user_id() == "user_2"); + assert(session.created_at() == 3000); + assert(session.expires_at() == 4000); + assert(session.last_seen_at() == 3500); + assert(session.revoked()); + assert(session.valid()); + } + + void test_belongs_to_checks_user_id() + { + rixlib::auth::Session session; + session.set_user_id("user_3"); + + assert(session.belongs_to("user_3")); + assert(!session.belongs_to("user_4")); + assert(!session.belongs_to("")); + } + + void test_session_requires_id_and_user_id_to_be_valid() + { + rixlib::auth::Session session; + + assert(!session.valid()); + + session.set_id("session_3"); + assert(!session.valid()); + + session.set_user_id("user_3"); + assert(session.valid()); + + session.set_id(""); + assert(!session.valid()); + } + + void test_expired_returns_true_after_expiration() + { + rixlib::auth::Session session; + session.set_expires_at(1000); + + assert(!session.expired(999)); + assert(session.expired(1000)); + assert(session.expired(1001)); + } + + void test_zero_expiration_means_not_expired() + { + rixlib::auth::Session session; + session.set_expires_at(0); + + assert(!session.expired(0)); + assert(!session.expired(1000)); + } + + void test_usable_requires_valid_not_revoked_and_not_expired() + { + rixlib::auth::Session session{ + "session_4", + "user_4", + 1000, + 2000}; + + assert(session.usable(1500)); + + session.set_revoked(true); + assert(!session.usable(1500)); + + session.set_revoked(false); + assert(!session.usable(2000)); + + session.set_id(""); + assert(!session.usable(1500)); + } +} // namespace + +int main() +{ + test_default_session_is_invalid(); + test_constructed_session_has_expected_values(); + test_session_setters_update_values(); + test_belongs_to_checks_user_id(); + test_session_requires_id_and_user_id_to_be_valid(); + test_expired_returns_true_after_expiration(); + test_zero_expiration_means_not_expired(); + test_usable_requires_valid_not_revoked_and_not_expired(); + + return 0; +} diff --git a/tests/TokenTests.cpp b/tests/TokenTests.cpp index e69de29..e24a617 100644 --- a/tests/TokenTests.cpp +++ b/tests/TokenTests.cpp @@ -0,0 +1,166 @@ +/** + * + * @file TokenTests.cpp + * @author Gaspard Kirira + * + * Copyright 2026, Gaspard Kirira. + * All rights reserved. + * https://github.com/rixcpp/auth + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Rix + * + */ + +#include + +#include +#include + +namespace +{ + void test_default_token_is_invalid() + { + const rixlib::auth::Token token; + + assert(token.value().empty()); + assert(token.user_id().empty()); + assert(token.issuer().empty()); + assert(token.issued_at() == 0); + assert(token.expires_at() == 0); + assert(!token.revoked()); + assert(!token.valid()); + } + + void test_constructed_token_has_expected_values() + { + const std::int64_t issued_at = 1000; + const std::int64_t expires_at = 2000; + + const rixlib::auth::Token token{ + "token_value", + "user_1", + issued_at, + expires_at}; + + assert(token.value() == "token_value"); + assert(token.user_id() == "user_1"); + assert(token.issuer().empty()); + assert(token.issued_at() == issued_at); + assert(token.expires_at() == expires_at); + assert(!token.revoked()); + assert(token.valid()); + } + + void test_token_setters_update_values() + { + rixlib::auth::Token token; + + token.set_value("token_2"); + token.set_user_id("user_2"); + token.set_issuer("rix/auth"); + token.set_issued_at(3000); + token.set_expires_at(4000); + token.set_revoked(true); + + assert(token.value() == "token_2"); + assert(token.user_id() == "user_2"); + assert(token.issuer() == "rix/auth"); + assert(token.issued_at() == 3000); + assert(token.expires_at() == 4000); + assert(token.revoked()); + assert(token.valid()); + } + + void test_belongs_to_checks_user_id() + { + rixlib::auth::Token token; + token.set_user_id("user_3"); + + assert(token.belongs_to("user_3")); + assert(!token.belongs_to("user_4")); + assert(!token.belongs_to("")); + } + + void test_matches_checks_token_value() + { + rixlib::auth::Token token; + token.set_value("secret-token"); + + assert(token.matches("secret-token")); + assert(!token.matches("other-token")); + assert(!token.matches("")); + } + + void test_token_requires_value_and_user_id_to_be_valid() + { + rixlib::auth::Token token; + + assert(!token.valid()); + + token.set_value("token_3"); + assert(!token.valid()); + + token.set_user_id("user_3"); + assert(token.valid()); + + token.set_value(""); + assert(!token.valid()); + } + + void test_expired_returns_true_after_expiration() + { + rixlib::auth::Token token; + token.set_expires_at(1000); + + assert(!token.expired(999)); + assert(token.expired(1000)); + assert(token.expired(1001)); + } + + void test_zero_expiration_means_not_expired() + { + rixlib::auth::Token token; + token.set_expires_at(0); + + assert(!token.expired(0)); + assert(!token.expired(1000)); + } + + void test_usable_requires_valid_not_revoked_and_not_expired() + { + rixlib::auth::Token token{ + "token_4", + "user_4", + 1000, + 2000}; + + assert(token.usable(1500)); + + token.set_revoked(true); + assert(!token.usable(1500)); + + token.set_revoked(false); + assert(!token.usable(2000)); + + token.set_value(""); + assert(!token.usable(1500)); + } +} // namespace + +int main() +{ + test_default_token_is_invalid(); + test_constructed_token_has_expected_values(); + test_token_setters_update_values(); + test_belongs_to_checks_user_id(); + test_matches_checks_token_value(); + test_token_requires_value_and_user_id_to_be_valid(); + test_expired_returns_true_after_expiration(); + test_zero_expiration_means_not_expired(); + test_usable_requires_valid_not_revoked_and_not_expired(); + + return 0; +} diff --git a/tests/UserTests.cpp b/tests/UserTests.cpp index e69de29..3ea27b3 100644 --- a/tests/UserTests.cpp +++ b/tests/UserTests.cpp @@ -0,0 +1,117 @@ +/** + * + * @file UserTests.cpp + * @author Gaspard Kirira + * + * Copyright 2026, Gaspard Kirira. + * All rights reserved. + * https://github.com/rixcpp/auth + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Rix + * + */ + +#include + +#include +#include +#include + +namespace +{ + void test_default_user_is_invalid() + { + const rixlib::auth::User user; + + assert(user.id().empty()); + assert(user.email().empty()); + assert(user.password_hash().empty()); + assert(!user.email_verified()); + assert(user.active()); + assert(user.created_at() == 0); + assert(user.updated_at() == 0); + assert(!user.valid()); + } + + void test_constructed_user_has_expected_values() + { + const std::int64_t created_at = 1000; + + const rixlib::auth::User user{ + "user_1", + "ada@example.com", + "hashed-password", + created_at}; + + assert(user.id() == "user_1"); + assert(user.email() == "ada@example.com"); + assert(user.password_hash() == "hashed-password"); + assert(user.created_at() == created_at); + assert(user.updated_at() == created_at); + assert(user.active()); + assert(!user.email_verified()); + assert(user.valid()); + } + + void test_user_setters_update_values() + { + rixlib::auth::User user; + + user.set_id("user_2"); + user.set_email("grace@example.com"); + user.set_password_hash("new-hash"); + user.set_email_verified(true); + user.set_active(false); + user.set_created_at(2000); + user.set_updated_at(3000); + + assert(user.id() == "user_2"); + assert(user.email() == "grace@example.com"); + assert(user.password_hash() == "new-hash"); + assert(user.email_verified()); + assert(!user.active()); + assert(user.created_at() == 2000); + assert(user.updated_at() == 3000); + assert(user.valid()); + } + + void test_has_email_checks_exact_email() + { + rixlib::auth::User user; + user.set_email("user@example.com"); + + assert(user.has_email("user@example.com")); + assert(!user.has_email("other@example.com")); + assert(!user.has_email("")); + } + + void test_user_requires_id_and_email_to_be_valid() + { + rixlib::auth::User user; + + assert(!user.valid()); + + user.set_id("user_3"); + assert(!user.valid()); + + user.set_email("user3@example.com"); + assert(user.valid()); + + user.set_id(""); + assert(!user.valid()); + } +} // namespace + +int main() +{ + test_default_user_is_invalid(); + test_constructed_user_has_expected_values(); + test_user_setters_update_values(); + test_has_email_checks_exact_email(); + test_user_requires_id_and_email_to_be_valid(); + + return 0; +} diff --git a/vix.json b/vix.json index e69de29..15ddf1c 100644 --- a/vix.json +++ b/vix.json @@ -0,0 +1,31 @@ +{ + "name": "auth", + "namespace": "rix", + "version": "0.1.0", + "type": "library", + "include": "include", + "license": "MIT", + "description": "Ready-to-use authentication package for Vix C++ applications.", + "keywords": [ + "cpp", + "auth", + "authentication", + "users", + "sessions", + "tokens", + "password", + "rix", + "vix" + ], + "repository": "https://github.com/rixcpp/auth", + "authors": [ + { + "name": "Gaspard Kirira", + "github": "rixcpp" + } + ], + "cmake": { + "target": "rix::auth" + }, + "deps": [] +} From 573c28af0c808621ca559ace317414069576f672 Mon Sep 17 00:00:00 2001 From: Gaspard Kirira Date: Mon, 8 Jun 2026 22:53:37 +0300 Subject: [PATCH 03/10] feat: add initial rix auth package --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 44175d0..336b391 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,3 @@ -# rix-memory -Memory management utilities, smart pointers, and RAII helpers. +# Rix Auth + +Ready-to-use authentication package for Vix.cpp applications. From 2343e9b57028110d35ee2a3909a86c944bbb8fd5 Mon Sep 17 00:00:00 2001 From: Gaspard Kirira Date: Tue, 9 Jun 2026 00:11:35 +0300 Subject: [PATCH 04/10] feat(auth): make rix auth production ready --- CMakeLists.txt | 110 +- README.md | 622 +++++++++++- examples/CMakeLists.txt | 14 + examples/basic.cpp | 459 +++------ include/rix/auth/Auth.hpp | 110 +- include/rix/auth/AuthConfig.hpp | 122 ++- include/rix/auth/AuthError.hpp | 117 +-- include/rix/auth/AuthResult.hpp | 73 +- include/rix/auth/PasswordHasher.hpp | 103 +- include/rix/auth/Session.hpp | 85 +- include/rix/auth/SessionStore.hpp | 32 +- include/rix/auth/Token.hpp | 70 +- include/rix/auth/User.hpp | 79 +- include/rix/auth/UserStore.hpp | 31 +- include/rix/auth/Version.hpp | 46 +- include/rix/auth/stores/DbSessionStore.hpp | 158 +++ include/rix/auth/stores/DbUserStore.hpp | 149 +++ .../rix/auth/stores/MemorySessionStore.hpp | 160 +++ include/rix/auth/stores/MemoryUserStore.hpp | 150 +++ src/Auth.cpp | 354 +++++-- src/AuthConfig.cpp | 70 ++ src/AuthError.cpp | 43 +- src/PasswordHasher.cpp | 126 ++- src/Session.cpp | 31 + src/Token.cpp | 20 + src/User.cpp | 24 +- src/stores/DbSessionStore.cpp | 452 +++++++++ src/stores/DbUserStore.cpp | 425 ++++++++ src/stores/MemorySessionStore.cpp | 318 ++++++ src/stores/MemoryUserStore.cpp | 261 +++++ tests/AuthTests.cpp | 938 ++++++++++-------- tests/CMakeLists.txt | 83 +- tests/MemorySessionStoreTests.cpp | 337 +++++++ tests/MemoryUserStoreTests.cpp | 313 ++++++ tests/PasswordHasherTests.cpp | 143 +-- tests/SessionTests.cpp | 236 +++-- tests/TokenTests.cpp | 222 +++-- tests/UserTests.cpp | 150 +-- vix.json | 2 +- 39 files changed, 5700 insertions(+), 1538 deletions(-) create mode 100644 include/rix/auth/stores/DbSessionStore.hpp create mode 100644 include/rix/auth/stores/DbUserStore.hpp create mode 100644 include/rix/auth/stores/MemorySessionStore.hpp create mode 100644 include/rix/auth/stores/MemoryUserStore.hpp create mode 100644 src/stores/DbSessionStore.cpp create mode 100644 src/stores/DbUserStore.cpp create mode 100644 src/stores/MemorySessionStore.cpp create mode 100644 src/stores/MemoryUserStore.cpp create mode 100644 tests/MemorySessionStoreTests.cpp create mode 100644 tests/MemoryUserStoreTests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 2514beb..1bc80cb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2,14 +2,68 @@ cmake_minimum_required(VERSION 3.20) project(rix_auth VERSION 0.1.0 - DESCRIPTION "Ready-to-use authentication package for Vix C++ applications" + DESCRIPTION "Production-ready authentication package for Vix C++ applications" LANGUAGES CXX ) +include(GNUInstallDirs) + option(RIX_AUTH_BUILD_EXAMPLES "Build rix/auth examples" ON) option(RIX_AUTH_BUILD_TESTS "Build rix/auth tests" ON) -add_library(rix_auth +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_POSITION_INDEPENDENT_CODE ON) + +# ------------------------------------------------------------------------------ +# Dependencies +# ------------------------------------------------------------------------------ + +if(NOT TARGET vix::crypto) + if(EXISTS "${CMAKE_CURRENT_LIST_DIR}/../../vix/modules/crypto/CMakeLists.txt") + add_subdirectory("${CMAKE_CURRENT_LIST_DIR}/../../vix/modules/crypto" vix_crypto_build) + elseif(EXISTS "${CMAKE_CURRENT_LIST_DIR}/../crypto/CMakeLists.txt") + add_subdirectory("${CMAKE_CURRENT_LIST_DIR}/../crypto" vix_crypto_build) + else() + find_package(Vix REQUIRED COMPONENTS crypto) + endif() +endif() + +if(NOT TARGET vix::validation) + if(EXISTS "${CMAKE_CURRENT_LIST_DIR}/../../vix/modules/validation/CMakeLists.txt") + add_subdirectory("${CMAKE_CURRENT_LIST_DIR}/../../vix/modules/validation" vix_validation_build) + elseif(EXISTS "${CMAKE_CURRENT_LIST_DIR}/../validation/CMakeLists.txt") + add_subdirectory("${CMAKE_CURRENT_LIST_DIR}/../validation" vix_validation_build) + else() + find_package(Vix REQUIRED COMPONENTS validation) + endif() +endif() + +if(NOT TARGET vix::time) + if(EXISTS "${CMAKE_CURRENT_LIST_DIR}/../../vix/modules/time/CMakeLists.txt") + add_subdirectory("${CMAKE_CURRENT_LIST_DIR}/../../vix/modules/time" vix_time_build) + elseif(EXISTS "${CMAKE_CURRENT_LIST_DIR}/../time/CMakeLists.txt") + add_subdirectory("${CMAKE_CURRENT_LIST_DIR}/../time" vix_time_build) + else() + find_package(Vix REQUIRED COMPONENTS time) + endif() +endif() + +if(NOT TARGET vix::db) + if(EXISTS "${CMAKE_CURRENT_LIST_DIR}/../../vix/modules/db/CMakeLists.txt") + add_subdirectory("${CMAKE_CURRENT_LIST_DIR}/../../vix/modules/db" vix_db_build) + elseif(EXISTS "${CMAKE_CURRENT_LIST_DIR}/../db/CMakeLists.txt") + add_subdirectory("${CMAKE_CURRENT_LIST_DIR}/../db" vix_db_build) + else() + find_package(Vix REQUIRED COMPONENTS db) + endif() +endif() + +# ------------------------------------------------------------------------------ +# Library +# ------------------------------------------------------------------------------ + +add_library(rix_auth STATIC src/Auth.cpp src/AuthConfig.cpp src/AuthError.cpp @@ -20,6 +74,11 @@ add_library(rix_auth src/UserStore.cpp src/SessionStore.cpp src/Version.cpp + + src/stores/MemoryUserStore.cpp + src/stores/MemorySessionStore.cpp + src/stores/DbUserStore.cpp + src/stores/DbSessionStore.cpp ) add_library(rix::auth ALIAS rix_auth) @@ -32,9 +91,37 @@ target_compile_features(rix_auth target_include_directories(rix_auth PUBLIC $ - $ + $ +) + +target_link_libraries(rix_auth + PUBLIC + vix::crypto + vix::validation + vix::time + vix::db +) + +set_target_properties(rix_auth PROPERTIES + OUTPUT_NAME rix_auth + VERSION ${PROJECT_VERSION} + SOVERSION 0 + EXPORT_NAME auth ) +if(NOT MSVC) + target_compile_options(rix_auth + PRIVATE + -Wall + -Wextra + -Wpedantic + ) +endif() + +# ------------------------------------------------------------------------------ +# Examples and tests +# ------------------------------------------------------------------------------ + if(CMAKE_CURRENT_SOURCE_DIR STREQUAL CMAKE_SOURCE_DIR) if(RIX_AUTH_BUILD_EXAMPLES) add_subdirectory(examples) @@ -45,3 +132,20 @@ if(CMAKE_CURRENT_SOURCE_DIR STREQUAL CMAKE_SOURCE_DIR) add_subdirectory(tests) endif() endif() + +# ------------------------------------------------------------------------------ +# Install +# ------------------------------------------------------------------------------ + +install(TARGETS rix_auth + EXPORT RixAuthTargets + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} + INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR} +) + +install(DIRECTORY include/ + DESTINATION ${CMAKE_INSTALL_INCLUDEDIR} + FILES_MATCHING PATTERN "*.hpp" PATTERN "*.h" +) diff --git a/README.md b/README.md index 336b391..88edf89 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,621 @@ -# Rix Auth +# rix/auth -Ready-to-use authentication package for Vix.cpp applications. +`rix/auth` is the official authentication library for Rix and Vix.cpp applications. + +It provides a small production-oriented authentication layer for C++ apps: + +- user registration +- login +- password hashing +- server-side sessions +- short-lived auth tokens +- logout +- session validation +- memory stores for tests and small apps +- database stores for durable production usage +- explicit `AuthResult` and `AuthStatus` error handling + +`rix/auth` is built to use Vix modules instead of reinventing low-level foundations: + +- `vix::crypto` for secure random ids and password hashing helpers +- `vix::validation` for input validation +- `vix::time` for timestamps +- `vix::db` for persistent database-backed stores + +## Install + +Add the package to your Vix project: + +```bash +vix add rix/auth +``` + +Then include it: + +```cpp +#include +``` + +## Basic usage + +```cpp +#include +#include +#include +#include + +#include +#include + +int main() +{ + rixlib::auth::MemoryUserStore users; + rixlib::auth::MemorySessionStore sessions; + + auto config = rixlib::auth::AuthConfig::development(); + + rixlib::auth::Auth auth{ + users, + sessions, + config}; + + auto registered_user = auth.register_user( + rixlib::auth::RegisterRequest{ + "ada@example.com", + "correct-password"}); + + if (registered_user.failed()) + { + std::cerr << registered_user.error().message() << '\n'; + return 1; + } + + auto login = auth.login( + rixlib::auth::LoginRequest{ + "ada@example.com", + "correct-password"}); + + if (login.failed()) + { + std::cerr << login.error().message() << '\n'; + return 1; + } + + std::cout << "user: " << login.value().user.email() << '\n'; + std::cout << "session: " << login.value().session.id() << '\n'; + std::cout << "token issuer: " << login.value().token.issuer() << '\n'; + + return 0; +} +``` + +Run: + +```bash +vix run examples/basic.cpp +``` + +## Public API + +### Main facade + +```cpp +#include +``` + +The main class is: + +```cpp +rixlib::auth::Auth +``` + +It exposes: + +- `register_user(...)` +- `login(...)` +- `logout(...)` +- `logout_user(...)` +- `authenticate_session(...)` +- `refresh_session(...)` +- `issue_token(...)` +- `config()` +- `password_hasher()` + +## Configuration + +Use `AuthConfig` to configure authentication behavior. + +```cpp +auto config = rixlib::auth::AuthConfig::production(); + +config.set_min_password_length(12); +config.set_session_ttl_seconds(60 * 60 * 24 * 7); +config.set_token_ttl_seconds(60 * 10); +config.set_issuer("my-app"); +config.set_require_email_verification(true); +config.set_rotate_sessions(true); +``` + +### Development config + +```cpp +auto config = rixlib::auth::AuthConfig::development(); +``` + +Development defaults are useful for local apps, examples, and tests. + +### Production config + +```cpp +auto config = rixlib::auth::AuthConfig::production(); +``` + +Production defaults are stricter and should be preferred for real applications. + +## Register a user + +```cpp +auto result = auth.register_user( + rixlib::auth::RegisterRequest{ + "ada@example.com", + "correct-password"}); + +if (result.failed()) +{ + std::cerr << result.error().message() << '\n'; + return 1; +} + +const auto &user = result.value(); +``` + +Registration validates the email, validates the password policy, hashes the password, creates the user, and stores it through the configured `UserStore`. + +## Login + +```cpp +auto result = auth.login( + rixlib::auth::LoginRequest{ + "ada@example.com", + "correct-password"}); + +if (result.failed()) +{ + std::cerr << result.error().message() << '\n'; + return 1; +} + +const auto &login = result.value(); + +std::cout << login.user.email() << '\n'; +std::cout << login.session.id() << '\n'; +std::cout << login.token.value() << '\n'; +``` + +A successful login returns: + +```cpp +rixlib::auth::LoginResult +``` + +It contains: + +```cpp +User user; +Session session; +Token token; +``` + +## Authenticate a session + +```cpp +auto session = auth.authenticate_session(session_id); + +if (session.failed()) +{ + std::cerr << session.error().message() << '\n'; + return 1; +} + +std::cout << "session user: " + << session.value().user_id() + << '\n'; +``` + +A usable session must be: + +- valid +- not revoked +- not expired + +## Refresh a session + +```cpp +auto refreshed = auth.refresh_session(session_id); + +if (refreshed.failed()) +{ + std::cerr << refreshed.error().message() << '\n'; + return 1; +} +``` + +Refreshing updates the session expiration and `last_seen_at`. + +## Logout + +```cpp +auto status = auth.logout(session_id); + +if (status.failed()) +{ + std::cerr << status.error().message() << '\n'; + return 1; +} +``` + +Logout revokes the session instead of deleting it. + +## Logout all sessions for a user + +```cpp +auto status = auth.logout_user(user_id); + +if (status.failed()) +{ + std::cerr << status.error().message() << '\n'; + return 1; +} +``` + +This is useful after password changes, account compromise, or admin actions. + +## Issue a token + +```cpp +auto token = auth.issue_token(user_id); + +if (token.failed()) +{ + std::cerr << token.error().message() << '\n'; + return 1; +} +``` + +Tokens are short-lived values attached to a user id and issuer. + +## Error handling + +`rix/auth` does not throw for normal authentication failures. + +It uses: + +- `AuthResult` +- `AuthStatus` +- `AuthError` +- `AuthErrorCode` + +Example: + +```cpp +auto result = auth.login( + rixlib::auth::LoginRequest{ + "ada@example.com", + "wrong-password"}); + +if (result.failed()) +{ + if (result.error().code() == rixlib::auth::AuthErrorCode::InvalidCredentials) + { + std::cerr << "invalid credentials\n"; + } +} +``` + +### Error codes + +Common error codes include: + +- `None` +- `InvalidInput` +- `InvalidEmail` +- `InvalidPassword` +- `UserNotFound` +- `UserAlreadyExists` +- `InvalidCredentials` +- `InvalidSession` +- `SessionExpired` +- `InvalidToken` +- `TokenExpired` +- `InvalidState` +- `StoreError` +- `CryptoError` +- `Unknown` + +## Stores + +`rix/auth` separates authentication logic from storage. + +The main interfaces are: + +- `UserStore` +- `SessionStore` + +Available store implementations: + +- `MemoryUserStore` +- `MemorySessionStore` +- `DbUserStore` +- `DbSessionStore` + +## Memory stores + +Memory stores are useful for tests, examples, and small temporary apps. + +```cpp +#include +#include + +rixlib::auth::MemoryUserStore users; +rixlib::auth::MemorySessionStore sessions; + +rixlib::auth::Auth auth{ + users, + sessions, + rixlib::auth::AuthConfig::development()}; +``` + +Memory stores do not persist data after the process exits. + +## Database stores + +Database stores are intended for durable applications. + +```cpp +#include +#include +#include +#include + +int main() +{ + auto database = vix::db::Database::sqlite("data/auth.db"); + + rixlib::auth::DbUserStore users{database}; + rixlib::auth::DbSessionStore sessions{database}; + + rixlib::auth::Auth auth{ + users, + sessions, + rixlib::auth::AuthConfig::production()}; + + return 0; +} +``` + +By default, database stores create their required tables automatically. + +### Database tables + +`DbUserStore` uses: + +```text +rix_auth_users +``` + +`DbSessionStore` uses: + +```text +rix_auth_sessions +``` + +The stores create the schema automatically when constructed with `create_schema = true`. + +```cpp +rixlib::auth::DbUserStore users{database, true}; +rixlib::auth::DbSessionStore sessions{database, true}; +``` + +## Password hashing + +Use `Auth` for normal application code. + +```cpp +auto result = auth.register_user(...); +``` + +`PasswordHasher` is available for lower-level usage and tests: + +```cpp +rixlib::auth::PasswordHasher hasher; + +auto hash = hasher.hash("correct-password"); + +if (hash.ok()) +{ + bool valid = hasher.verify("correct-password", hash.value()); +} +``` + +Password hashes are encoded and must never be returned to clients or logged. + +## User model + +```cpp +rixlib::auth::User +``` + +Fields include: + +- id +- email +- password hash +- email verification state +- active state +- created timestamp +- updated timestamp + +Useful methods: + +- `valid()` +- `has_id(...)` +- `has_email(...)` +- `active()` +- `email_verified()` + +## Session model + +```cpp +rixlib::auth::Session +``` + +Fields include: + +- id +- user id +- created timestamp +- expiration timestamp +- last seen timestamp +- revoked state + +Useful methods: + +- `valid()` +- `belongs_to(...)` +- `expired(...)` +- `usable(...)` +- `refreshable(...)` +- `refresh(...)` +- `revoke()` + +## Token model + +```cpp +rixlib::auth::Token +``` + +Fields include: + +- value +- user id +- issuer +- issued timestamp +- expiration timestamp +- revoked state + +Useful methods: + +- `valid()` +- `belongs_to(...)` +- `matches(...)` +- `expired(...)` +- `usable(...)` +- `revoke()` + +## Testing + +Build and run tests with Vix: + +```bash +vix tests +``` + +The test suite covers: + +- users +- sessions +- tokens +- password hashing +- memory user store +- memory session store +- auth registration +- login +- logout +- session authentication +- token creation + +## Project structure + +```text +include/rix/auth/ +├── Auth.hpp +├── AuthConfig.hpp +├── AuthError.hpp +├── AuthResult.hpp +├── PasswordHasher.hpp +├── Session.hpp +├── SessionStore.hpp +├── Token.hpp +├── User.hpp +├── UserStore.hpp +├── Version.hpp +└── stores/ + ├── DbSessionStore.hpp + ├── DbUserStore.hpp + ├── MemorySessionStore.hpp + └── MemoryUserStore.hpp + +src/ +├── Auth.cpp +├── AuthConfig.cpp +├── AuthError.cpp +├── PasswordHasher.cpp +├── Session.cpp +├── SessionStore.cpp +├── Token.cpp +├── User.cpp +├── UserStore.cpp +├── Version.cpp +└── stores/ + ├── DbSessionStore.cpp + ├── DbUserStore.cpp + ├── MemorySessionStore.cpp + └── MemoryUserStore.cpp + +tests/ +├── AuthTests.cpp +├── MemorySessionStoreTests.cpp +├── MemoryUserStoreTests.cpp +├── PasswordHasherTests.cpp +├── SessionTests.cpp +├── TokenTests.cpp +└── UserTests.cpp + +examples/ +└── basic.cpp +``` + +## Security notes + +Use `AuthConfig::production()` for real applications. + +Do not log: + +- plain-text passwords +- password hashes +- session ids +- token values + +Use durable database stores when sessions must survive restart. + +Use HTTPS when sending sessions or tokens over the network. + +Keep password hashing and token generation inside `rix/auth` instead of implementing them in application code. + +## Status + +Current version: + +```cpp +rixlib::auth::version() +``` + +Current package version: + +```text +0.1.0 +``` + +## License + +MIT diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 78c2ac4..07afcc4 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -2,7 +2,21 @@ add_executable(rix_auth_basic basic.cpp ) +target_compile_features(rix_auth_basic + PRIVATE + cxx_std_20 +) + target_link_libraries(rix_auth_basic PRIVATE rix::auth ) + +if(NOT MSVC) + target_compile_options(rix_auth_basic + PRIVATE + -Wall + -Wextra + -Wpedantic + ) +endif() diff --git a/examples/basic.cpp b/examples/basic.cpp index fdca674..4095490 100644 --- a/examples/basic.cpp +++ b/examples/basic.cpp @@ -15,398 +15,165 @@ */ #include +#include +#include +#include +#include -#include #include -#include #include -#include -#include namespace { - class MemoryUserStore final : public rixlib::auth::UserStore + void print_error(const rixlib::auth::AuthError &error) { - public: - [[nodiscard]] rixlib::auth::AuthStatus create( - const rixlib::auth::User &user) override - { - if (!user.valid()) - { - return rixlib::auth::AuthStatus::failure( - rixlib::auth::make_auth_error( - rixlib::auth::AuthErrorCode::InvalidInput, - "User is invalid.")); - } - - const auto exists = std::any_of( - users_.begin(), - users_.end(), - [&](const rixlib::auth::User &stored) - { - return stored.id() == user.id() || - stored.email() == user.email(); - }); - - if (exists) - { - return rixlib::auth::AuthStatus::failure( - rixlib::auth::make_auth_error( - rixlib::auth::AuthErrorCode::UserAlreadyExists, - "User already exists.")); - } - - users_.push_back(user); - return rixlib::auth::AuthStatus::success(); - } + std::cerr << "auth error: " + << rixlib::auth::to_string(error.code()) + << ": " + << error.message() + << '\n'; + } - [[nodiscard]] rixlib::auth::AuthStatus update( - const rixlib::auth::User &user) override - { - for (rixlib::auth::User &stored : users_) - { - if (stored.id() == user.id()) - { - stored = user; - return rixlib::auth::AuthStatus::success(); - } - } - - return rixlib::auth::AuthStatus::failure( - rixlib::auth::make_auth_error( - rixlib::auth::AuthErrorCode::UserNotFound, - "User not found.")); - } + rixlib::auth::AuthConfig make_auth_config() + { + auto config = rixlib::auth::AuthConfig::development(); - [[nodiscard]] rixlib::auth::AuthStatus remove_by_id( - std::string_view id) override - { - const auto old_size = users_.size(); - - users_.erase( - std::remove_if( - users_.begin(), - users_.end(), - [&](const rixlib::auth::User &user) - { - return user.id() == id; - }), - users_.end()); - - if (users_.size() == old_size) - { - return rixlib::auth::AuthStatus::failure( - rixlib::auth::make_auth_error( - rixlib::auth::AuthErrorCode::UserNotFound, - "User not found.")); - } - - return rixlib::auth::AuthStatus::success(); - } + config.set_min_password_length(8); + config.set_session_ttl_seconds(60 * 60 * 24 * 7); + config.set_token_ttl_seconds(60 * 15); + config.set_issuer("rix/auth/example"); + config.set_require_email_verification(false); + config.set_rotate_sessions(true); - [[nodiscard]] rixlib::auth::AuthResult> - find_by_id(std::string_view id) const override - { - for (const rixlib::auth::User &user : users_) - { - if (user.id() == id) - { - return rixlib::auth::AuthResult>::success(user); - } - } - - return rixlib::auth::AuthResult>::success(std::nullopt); - } + return config; + } - [[nodiscard]] rixlib::auth::AuthResult> - find_by_email(std::string_view email) const override - { - for (const rixlib::auth::User &user : users_) - { - if (user.email() == email) - { - return rixlib::auth::AuthResult>::success(user); - } - } - - return rixlib::auth::AuthResult>::success(std::nullopt); - } + bool register_user(rixlib::auth::Auth &auth) + { + const auto result = auth.register_user( + rixlib::auth::RegisterRequest{ + "ada@example.com", + "correct-password"}); - [[nodiscard]] rixlib::auth::AuthResult - exists_by_id(std::string_view id) const override + if (result.failed()) { - const auto exists = std::any_of( - users_.begin(), - users_.end(), - [&](const rixlib::auth::User &user) - { - return user.id() == id; - }); - - return rixlib::auth::AuthResult::success(exists); + print_error(result.error()); + return false; } - [[nodiscard]] rixlib::auth::AuthResult - exists_by_email(std::string_view email) const override - { - const auto exists = std::any_of( - users_.begin(), - users_.end(), - [&](const rixlib::auth::User &user) - { - return user.email() == email; - }); - - return rixlib::auth::AuthResult::success(exists); - } + const auto &user = result.value(); - [[nodiscard]] rixlib::auth::AuthResult> - all() const override - { - return rixlib::auth::AuthResult>::success(users_); - } + std::cout << "registered user\n"; + std::cout << " id: " << user.id() << '\n'; + std::cout << " email: " << user.email() << '\n'; + std::cout << " verified: " + << (user.email_verified() ? "yes" : "no") + << '\n'; - private: - std::vector users_; - }; + return true; + } - class MemorySessionStore final : public rixlib::auth::SessionStore + bool login_user( + rixlib::auth::Auth &auth, + std::string &session_id) { - public: - [[nodiscard]] rixlib::auth::AuthStatus create( - const rixlib::auth::Session &session) override - { - if (!session.valid()) - { - return rixlib::auth::AuthStatus::failure( - rixlib::auth::make_auth_error( - rixlib::auth::AuthErrorCode::InvalidInput, - "Session is invalid.")); - } - - const auto exists = std::any_of( - sessions_.begin(), - sessions_.end(), - [&](const rixlib::auth::Session &stored) - { - return stored.id() == session.id(); - }); - - if (exists) - { - return rixlib::auth::AuthStatus::failure( - rixlib::auth::make_auth_error( - rixlib::auth::AuthErrorCode::InvalidSession, - "Session already exists.")); - } - - sessions_.push_back(session); - return rixlib::auth::AuthStatus::success(); - } + const auto result = auth.login( + rixlib::auth::LoginRequest{ + "ada@example.com", + "correct-password"}); - [[nodiscard]] rixlib::auth::AuthStatus update( - const rixlib::auth::Session &session) override + if (result.failed()) { - for (rixlib::auth::Session &stored : sessions_) - { - if (stored.id() == session.id()) - { - stored = session; - return rixlib::auth::AuthStatus::success(); - } - } - - return rixlib::auth::AuthStatus::failure( - rixlib::auth::make_auth_error( - rixlib::auth::AuthErrorCode::InvalidSession, - "Session not found.")); + print_error(result.error()); + return false; } - [[nodiscard]] rixlib::auth::AuthStatus remove_by_id( - std::string_view id) override - { - const auto old_size = sessions_.size(); - - sessions_.erase( - std::remove_if( - sessions_.begin(), - sessions_.end(), - [&](const rixlib::auth::Session &session) - { - return session.id() == id; - }), - sessions_.end()); - - if (sessions_.size() == old_size) - { - return rixlib::auth::AuthStatus::failure( - rixlib::auth::make_auth_error( - rixlib::auth::AuthErrorCode::InvalidSession, - "Session not found.")); - } - - return rixlib::auth::AuthStatus::success(); - } + const auto &login = result.value(); - [[nodiscard]] rixlib::auth::AuthStatus revoke_by_id( - std::string_view id) override - { - for (rixlib::auth::Session &session : sessions_) - { - if (session.id() == id) - { - session.set_revoked(true); - return rixlib::auth::AuthStatus::success(); - } - } - - return rixlib::auth::AuthStatus::failure( - rixlib::auth::make_auth_error( - rixlib::auth::AuthErrorCode::InvalidSession, - "Session not found.")); - } + session_id = login.session.id(); - [[nodiscard]] rixlib::auth::AuthStatus revoke_by_user_id( - std::string_view user_id) override - { - bool changed = false; - - for (rixlib::auth::Session &session : sessions_) - { - if (session.user_id() == user_id) - { - session.set_revoked(true); - changed = true; - } - } - - if (!changed) - { - return rixlib::auth::AuthStatus::failure( - rixlib::auth::make_auth_error( - rixlib::auth::AuthErrorCode::InvalidSession, - "Session not found.")); - } - - return rixlib::auth::AuthStatus::success(); - } - - [[nodiscard]] rixlib::auth::AuthResult> - find_by_id(std::string_view id) const override - { - for (const rixlib::auth::Session &session : sessions_) - { - if (session.id() == id) - { - return rixlib::auth::AuthResult>::success(session); - } - } - - return rixlib::auth::AuthResult>::success(std::nullopt); - } - - [[nodiscard]] rixlib::auth::AuthResult> - find_by_user_id(std::string_view user_id) const override - { - std::vector result; + std::cout << "login successful\n"; + std::cout << " user: " << login.user.email() << '\n'; + std::cout << " session: " << login.session.id() << '\n'; + std::cout << " token issuer: " << login.token.issuer() << '\n'; - for (const rixlib::auth::Session &session : sessions_) - { - if (session.user_id() == user_id) - { - result.push_back(session); - } - } + return true; + } - return rixlib::auth::AuthResult>::success(result); - } + bool authenticate_session( + rixlib::auth::Auth &auth, + const std::string &session_id) + { + const auto result = auth.authenticate_session(session_id); - [[nodiscard]] rixlib::auth::AuthResult - exists_by_id(std::string_view id) const override + if (result.failed()) { - const auto exists = std::any_of( - sessions_.begin(), - sessions_.end(), - [&](const rixlib::auth::Session &session) - { - return session.id() == id; - }); - - return rixlib::auth::AuthResult::success(exists); + print_error(result.error()); + return false; } - [[nodiscard]] rixlib::auth::AuthResult> - all() const override - { - return rixlib::auth::AuthResult>::success(sessions_); - } + const auto &session = result.value(); - private: - std::vector sessions_; - }; + std::cout << "session authenticated\n"; + std::cout << " session: " << session.id() << '\n'; + std::cout << " user: " << session.user_id() << '\n'; - void print_error(const rixlib::auth::AuthError &error) - { - std::cout << "error: " << rixlib::auth::to_string(error.code()) - << " - " << error.message() << '\n'; + return true; } -} // namespace -int main() -{ - MemoryUserStore users; - MemorySessionStore sessions; - - rixlib::auth::Auth auth{users, sessions}; - - const auto registered = auth.register_user( - rixlib::auth::RegisterRequest{ - "ada@example.com", - "secret123"}); - - if (registered.failed()) + bool logout_user( + rixlib::auth::Auth &auth, + const std::string &session_id) { - print_error(registered.error()); - return 1; - } + const auto status = auth.logout(session_id); - std::cout << "registered user: " << registered.value().email() << '\n'; + if (status.failed()) + { + print_error(status.error()); + return false; + } - const auto logged_in = auth.login( - rixlib::auth::LoginRequest{ - "ada@example.com", - "secret123"}); + std::cout << "logout successful\n"; + return true; + } - if (logged_in.failed()) + int run_basic_example() { - print_error(logged_in.error()); - return 1; - } + rixlib::auth::MemoryUserStore users; + rixlib::auth::MemorySessionStore sessions; - std::cout << "logged in user: " << logged_in.value().user.email() << '\n'; - std::cout << "session id: " << logged_in.value().session.id() << '\n'; + rixlib::auth::Auth auth{ + users, + sessions, + make_auth_config()}; - const auto authenticated = auth.authenticate_session( - logged_in.value().session.id()); + std::string session_id; - if (authenticated.failed()) - { - print_error(authenticated.error()); - return 1; - } + if (!register_user(auth)) + { + return 1; + } - std::cout << "session authenticated for user id: " - << authenticated.value().user_id() << '\n'; + if (!login_user(auth, session_id)) + { + return 1; + } + + if (!authenticate_session(auth, session_id)) + { + return 1; + } - const auto logout = auth.logout(logged_in.value().session.id()); + if (!logout_user(auth, session_id)) + { + return 1; + } - if (logout.failed()) - { - print_error(logout.error()); - return 1; + return 0; } +} // namespace - std::cout << "logged out successfully\n"; - - return 0; +int main() +{ + return run_basic_example(); } diff --git a/include/rix/auth/Auth.hpp b/include/rix/auth/Auth.hpp index 90f4c72..75f4b2c 100644 --- a/include/rix/auth/Auth.hpp +++ b/include/rix/auth/Auth.hpp @@ -12,8 +12,6 @@ * * Rix * - * Main authentication facade for rix/auth. - * */ #ifndef RIXCPP_AUTH_INCLUDE_RIX_AUTH_AUTH_HPP_INCLUDED @@ -24,9 +22,11 @@ #include #include #include +#include #include #include +#include #include #include @@ -34,9 +34,6 @@ namespace rixlib::auth { /** * @brief Request used to register a new user. - * - * RegisterRequest keeps the public API simple for developers while Auth - * handles validation, hashing, user creation, and store interaction. */ struct RegisterRequest { @@ -47,8 +44,6 @@ namespace rixlib::auth /** * @brief Plain-text password. - * - * This value is only accepted as input. It must never be stored directly. */ std::string password; }; @@ -80,32 +75,34 @@ namespace rixlib::auth User user; /** - * @brief Created authenticated session. + * @brief Created server-side session. */ Session session; + + /** + * @brief Short-lived token derived from the authenticated session. + */ + Token token; }; /** - * @brief Main rix/auth facade. - * - * Auth exposes a simple developer-facing API for common authentication - * operations such as register, login, session validation, and logout. - * - * The complexity stays behind this class: + * @brief Main authentication facade. * - * - input validation - * - password hashing - * - session creation - * - store access - * - error mapping + * Auth provides the developer-facing API for registration, login, + * session authentication, session refresh, and logout. * - * The application only provides stores and calls clear methods. + * It delegates: + * - validation to Vix validation-style checks + * - password hashing to PasswordHasher backed by vix::crypto + * - persistence to UserStore and SessionStore + * - time to Vix time-compatible epoch seconds + * - secure ids to vix::crypto random bytes */ class Auth { public: /** - * @brief Construct an authentication service. + * @brief Construct an auth service with development defaults. * * @param users User storage backend. * @param sessions Session storage backend. @@ -113,7 +110,7 @@ namespace rixlib::auth Auth(UserStore &users, SessionStore &sessions); /** - * @brief Construct an authentication service with custom configuration. + * @brief Construct an auth service with custom configuration. * * @param users User storage backend. * @param sessions Session storage backend. @@ -124,24 +121,20 @@ namespace rixlib::auth /** * @brief Register a new user. * - * This method validates the email and password, checks if the user already - * exists, hashes the password, creates the user, and stores it. - * * @param request Registration request. * @return AuthResult containing the created user on success. */ - [[nodiscard]] AuthResult register_user(const RegisterRequest &request); + [[nodiscard]] AuthResult + register_user(const RegisterRequest &request); /** * @brief Authenticate a user and create a session. * - * This method validates credentials, verifies the password, creates a - * session, and stores it. - * * @param request Login request. - * @return AuthResult containing the authenticated user and session. + * @return AuthResult containing user, session, and token on success. */ - [[nodiscard]] AuthResult login(const LoginRequest &request); + [[nodiscard]] AuthResult + login(const LoginRequest &request); /** * @brief Revoke a session. @@ -151,23 +144,50 @@ namespace rixlib::auth */ [[nodiscard]] AuthStatus logout(std::string_view session_id); + /** + * @brief Revoke all sessions for a user. + * + * @param user_id User identifier. + * @return AuthStatus indicating success or failure. + */ + [[nodiscard]] AuthStatus logout_user(std::string_view user_id); + /** * @brief Find and validate a session. * * @param session_id Session identifier. - * @return AuthResult containing the usable session when valid. + * @return AuthResult containing the usable session. */ - [[nodiscard]] AuthResult authenticate_session(std::string_view session_id); + [[nodiscard]] AuthResult + authenticate_session(std::string_view session_id); /** - * @brief Return the current authentication configuration. + * @brief Refresh a valid session. + * + * @param session_id Session identifier. + * @return AuthResult containing the refreshed session. + */ + [[nodiscard]] AuthResult + refresh_session(std::string_view session_id); + + /** + * @brief Create a short-lived token for a user. + * + * @param user_id User identifier. + * @return AuthResult containing the generated token. + */ + [[nodiscard]] AuthResult + issue_token(std::string_view user_id); + + /** + * @brief Return the current configuration. * * @return Authentication configuration. */ [[nodiscard]] const AuthConfig &config() const noexcept; /** - * @brief Return the password hasher used by this auth service. + * @brief Return the password hasher. * * @return Password hasher. */ @@ -180,16 +200,28 @@ namespace rixlib::auth [[nodiscard]] AuthStatus validate_login_request( const LoginRequest &request) const; - [[nodiscard]] bool is_valid_email(std::string_view email) const noexcept; + [[nodiscard]] AuthStatus validate_email(std::string_view email) const; - [[nodiscard]] std::string make_user_id() const; - - [[nodiscard]] std::string make_session_id() const; + [[nodiscard]] AuthStatus validate_password_for_register( + std::string_view password) const; [[nodiscard]] std::int64_t now_seconds() const noexcept; + [[nodiscard]] AuthResult make_user_id() const; + [[nodiscard]] AuthResult make_session_id() const; + [[nodiscard]] AuthResult make_token_value() const; + + [[nodiscard]] AuthResult + make_secure_id(std::string_view prefix) const; + + [[nodiscard]] Token make_token_for_user( + std::string user_id, + std::int64_t now, + std::string value) const; + UserStore *users_ = nullptr; SessionStore *sessions_ = nullptr; + AuthConfig config_; PasswordHasher password_hasher_; }; diff --git a/include/rix/auth/AuthConfig.hpp b/include/rix/auth/AuthConfig.hpp index 9de5dad..ab90ea6 100644 --- a/include/rix/auth/AuthConfig.hpp +++ b/include/rix/auth/AuthConfig.hpp @@ -12,8 +12,6 @@ * * Rix * - * Configuration for rix/auth. - * */ #ifndef RIXCPP_AUTH_INCLUDE_RIX_AUTH_AUTHCONFIG_HPP_INCLUDED @@ -26,30 +24,35 @@ namespace rixlib::auth { /** - * @brief Configuration used by the authentication service. + * @brief Authentication configuration. * - * AuthConfig keeps the package configurable while preserving a simple default - * experience for application developers. + * AuthConfig controls password policy, session lifetime, token lifetime, + * issuer name, and production security behavior. */ class AuthConfig { public: /** - * @brief Create a configuration with secure default values. + * @brief Construct the default authentication configuration. */ AuthConfig(); /** - * @brief Create the default development configuration. + * @brief Return a local development configuration. + * + * Development keeps authentication easy to test while still using the same + * production-grade crypto primitives. * - * @return AuthConfig with sensible defaults for local development. + * @return Development configuration. */ [[nodiscard]] static AuthConfig development(); /** - * @brief Create the default production configuration. + * @brief Return a stricter production configuration. + * + * Production enables safer defaults for real applications. * - * @return AuthConfig with stricter defaults for production applications. + * @return Production configuration. */ [[nodiscard]] static AuthConfig production(); @@ -67,6 +70,20 @@ namespace rixlib::auth */ void set_min_password_length(std::size_t value) noexcept; + /** + * @brief Return the maximum accepted password length. + * + * @return Maximum password length. + */ + [[nodiscard]] std::size_t max_password_length() const noexcept; + + /** + * @brief Set the maximum accepted password length. + * + * @param value Maximum password length. + */ + void set_max_password_length(std::size_t value) noexcept; + /** * @brief Return the session lifetime in seconds. * @@ -96,28 +113,70 @@ namespace rixlib::auth void set_token_ttl_seconds(std::int64_t value) noexcept; /** - * @brief Return the issuer name used for generated tokens. + * @brief Return the password hashing iteration count. + * + * @return PBKDF2 iteration count. + */ + [[nodiscard]] std::uint32_t password_hash_iterations() const noexcept; + + /** + * @brief Set the password hashing iteration count. + * + * @param value PBKDF2 iteration count. + */ + void set_password_hash_iterations(std::uint32_t value) noexcept; + + /** + * @brief Return the password hash salt size in bytes. + * + * @return Salt size in bytes. + */ + [[nodiscard]] std::size_t password_salt_size() const noexcept; + + /** + * @brief Set the password hash salt size in bytes. * - * @return Token issuer. + * @param value Salt size in bytes. + */ + void set_password_salt_size(std::size_t value) noexcept; + + /** + * @brief Return the password derived hash size in bytes. + * + * @return Derived hash size in bytes. + */ + [[nodiscard]] std::size_t password_hash_size() const noexcept; + + /** + * @brief Set the password derived hash size in bytes. + * + * @param value Derived hash size in bytes. + */ + void set_password_hash_size(std::size_t value) noexcept; + + /** + * @brief Return the issuer used for tokens. + * + * @return Issuer name. */ [[nodiscard]] const std::string &issuer() const noexcept; /** - * @brief Set the issuer name used for generated tokens. + * @brief Set the issuer used for tokens. * - * @param value Token issuer. + * @param value Issuer name. */ void set_issuer(std::string value); /** - * @brief Return whether email verification is required. + * @brief Return whether email verification is required before login. * - * @return true if users must verify their email. + * @return true if email verification is required. */ [[nodiscard]] bool require_email_verification() const noexcept; /** - * @brief Enable or disable email verification. + * @brief Enable or disable email verification before login. * * @param value true to require email verification. */ @@ -126,7 +185,7 @@ namespace rixlib::auth /** * @brief Return whether session rotation is enabled. * - * @return true if session identifiers may be rotated after sensitive actions. + * @return true if session rotation is enabled. */ [[nodiscard]] bool rotate_sessions() const noexcept; @@ -137,13 +196,36 @@ namespace rixlib::auth */ void set_rotate_sessions(bool value) noexcept; + /** + * @brief Return whether inactive users are blocked from login. + * + * @return true if inactive users cannot login. + */ + [[nodiscard]] bool reject_inactive_users() const noexcept; + + /** + * @brief Enable or disable inactive user rejection. + * + * @param value true to block inactive users from login. + */ + void set_reject_inactive_users(bool value) noexcept; + private: - std::size_t min_password_length_ = 8; + std::size_t min_password_length_ = 12; + std::size_t max_password_length_ = 1024; + std::int64_t session_ttl_seconds_ = 60 * 60 * 24 * 7; - std::int64_t token_ttl_seconds_ = 60 * 15; + std::int64_t token_ttl_seconds_ = 60 * 10; + + std::uint32_t password_hash_iterations_ = 310000; + std::size_t password_salt_size_ = 16; + std::size_t password_hash_size_ = 32; + std::string issuer_ = "rix/auth"; + bool require_email_verification_ = false; bool rotate_sessions_ = true; + bool reject_inactive_users_ = true; }; } // namespace rixlib::auth diff --git a/include/rix/auth/AuthError.hpp b/include/rix/auth/AuthError.hpp index 47f525e..cddf914 100644 --- a/include/rix/auth/AuthError.hpp +++ b/include/rix/auth/AuthError.hpp @@ -12,8 +12,6 @@ * * Rix * - * Authentication error model for rix/auth. - * */ #ifndef RIXCPP_AUTH_INCLUDE_RIX_AUTH_AUTHERROR_HPP_INCLUDED @@ -25,163 +23,132 @@ namespace rixlib::auth { /** - * @brief Authentication error category. + * @brief Stable authentication error codes. * - * AuthErrorCode describes the reason why an authentication operation failed. - * It is intentionally small and stable so applications can safely switch on it. + * These codes are part of the public rix/auth API. They describe + * authentication-domain failures, not low-level implementation details. */ enum class AuthErrorCode { - /** - * @brief No error occurred. - */ None, - /** - * @brief The input provided by the caller is invalid. - */ InvalidInput, - - /** - * @brief The email address is invalid. - */ InvalidEmail, - - /** - * @brief The password is invalid or too weak. - */ InvalidPassword, - /** - * @brief The requested user was not found. - */ UserNotFound, - - /** - * @brief A user already exists for the given identity. - */ UserAlreadyExists, - /** - * @brief The provided credentials are invalid. - */ InvalidCredentials, + EmailVerificationRequired, + UserDisabled, - /** - * @brief The session is invalid. - */ InvalidSession, - - /** - * @brief The session has expired. - */ SessionExpired, + SessionRevoked, - /** - * @brief The provided token is invalid. - */ InvalidToken, - - /** - * @brief The provided token has expired. - */ TokenExpired, + TokenRevoked, - /** - * @brief The operation cannot be completed in the current state. - */ - InvalidState, - - /** - * @brief The storage layer failed. - */ StoreError, + CryptoError, + ValidationError, + ConfigurationError, - /** - * @brief An unknown error occurred. - */ Unknown }; /** - * @brief Rich authentication error value. + * @brief Authentication error value. * * AuthError stores a stable error code and a human-readable message. - * The code is intended for programmatic decisions, while the message is - * intended for logs and debugging. + * The code is intended for programmatic decisions. The message is intended + * for logs, diagnostics, and developer feedback. */ class AuthError { public: /** - * @brief Construct an empty error. + * @brief Construct a success error value. */ AuthError() = default; /** * @brief Construct an authentication error. * - * @param code Stable error code. + * @param code Stable authentication error code. * @param message Human-readable error message. */ AuthError(AuthErrorCode code, std::string message); /** - * @brief Return true when this object represents an error. + * @brief Return true when this value represents success. * - * @return true if the error code is not AuthErrorCode::None. + * @return true if code is AuthErrorCode::None. */ - [[nodiscard]] bool has_error() const noexcept; + [[nodiscard]] bool ok() const noexcept; /** - * @brief Return true when this object does not represent an error. + * @brief Return true when this value represents failure. * - * @return true if the error code is AuthErrorCode::None. + * @return true if code is not AuthErrorCode::None. */ - [[nodiscard]] bool ok() const noexcept; + [[nodiscard]] bool has_error() const noexcept; /** * @brief Return the stable error code. * - * @return The error code. + * @return Authentication error code. */ [[nodiscard]] AuthErrorCode code() const noexcept; /** * @brief Return the human-readable error message. * - * @return The error message. + * @return Error message. */ [[nodiscard]] const std::string &message() const noexcept; + /** + * @brief Return true when the error code equals the given code. + * + * @param code Error code to compare. + * @return true if the stored code matches. + */ + [[nodiscard]] bool is(AuthErrorCode code) const noexcept; + private: AuthErrorCode code_ = AuthErrorCode::None; std::string message_; }; /** - * @brief Return a string name for an authentication error code. + * @brief Convert an authentication error code to a stable string. * - * @param code Error code to convert. - * @return Stable string representation of the error code. + * @param code Error code. + * @return Stable string representation. */ [[nodiscard]] std::string_view to_string(AuthErrorCode code) noexcept; /** - * @brief Create an empty authentication error. + * @brief Create a success authentication error value. * - * @return An AuthError with AuthErrorCode::None. + * @return AuthError with AuthErrorCode::None. */ [[nodiscard]] AuthError make_auth_ok(); /** * @brief Create an authentication error. * - * @param code Stable error code. + * @param code Stable authentication error code. * @param message Human-readable error message. - * @return AuthError containing the given code and message. + * @return AuthError value. */ - [[nodiscard]] AuthError make_auth_error(AuthErrorCode code, std::string message); + [[nodiscard]] AuthError make_auth_error( + AuthErrorCode code, + std::string message); + } // namespace rixlib::auth #endif // RIXCPP_AUTH_INCLUDE_RIX_AUTH_AUTHERROR_HPP_INCLUDED diff --git a/include/rix/auth/AuthResult.hpp b/include/rix/auth/AuthResult.hpp index 8a5018e..5553e3a 100644 --- a/include/rix/auth/AuthResult.hpp +++ b/include/rix/auth/AuthResult.hpp @@ -12,8 +12,6 @@ * * Rix * - * Result type used by rix/auth operations. - * */ #ifndef RIXCPP_AUTH_INCLUDE_RIX_AUTH_AUTHRESULT_HPP_INCLUDED @@ -30,20 +28,40 @@ namespace rixlib::auth * @brief Result object returned by authentication operations. * * AuthResult stores either a value of type T or an AuthError. - * It keeps authentication APIs explicit and avoids throwing exceptions - * for normal business failures such as invalid credentials or expired tokens. * - * @tparam T Value type returned on success. + * This type is used for operations that return data, for example: + * + * @code + * AuthResult + * AuthResult + * AuthResult + * @endcode + * + * Normal authentication failures such as invalid credentials, expired + * sessions, or validation errors are represented as explicit errors instead + * of exceptions. + * + * @tparam T Success value type. */ template class AuthResult { public: + /** + * @brief Success value type. + */ + using value_type = T; + + /** + * @brief Error value type. + */ + using error_type = AuthError; + /** * @brief Create a successful result. * * @param value Success value. - * @return AuthResult containing the value. + * @return AuthResult containing the success value. */ [[nodiscard]] static AuthResult success(T value) { @@ -85,6 +103,16 @@ namespace rixlib::auth return !ok(); } + /** + * @brief Boolean conversion. + * + * @return true if the operation succeeded. + */ + [[nodiscard]] explicit operator bool() const noexcept + { + return ok(); + } + /** * @brief Return the success value. * @@ -109,6 +137,18 @@ namespace rixlib::auth return *value_; } + /** + * @brief Move the success value out of the result. + * + * The caller must check ok() before calling this function. + * + * @return Moved success value. + */ + [[nodiscard]] T move_value() + { + return std::move(*value_); + } + /** * @brief Return the authentication error. * @@ -129,8 +169,14 @@ namespace rixlib::auth /** * @brief Result object for operations that only return success or failure. * - * AuthStatus is used when an operation does not need to return a value, - * for example logout, session deletion, or password update. + * AuthStatus is used when an operation does not return a value, for example: + * + * @code + * logout() + * remove_by_id() + * revoke_by_id() + * update() + * @endcode */ class AuthStatus { @@ -178,6 +224,16 @@ namespace rixlib::auth return error_.has_error(); } + /** + * @brief Boolean conversion. + * + * @return true if the operation succeeded. + */ + [[nodiscard]] explicit operator bool() const noexcept + { + return ok(); + } + /** * @brief Return the authentication error. * @@ -191,6 +247,7 @@ namespace rixlib::auth private: AuthError error_; }; + } // namespace rixlib::auth #endif // RIXCPP_AUTH_INCLUDE_RIX_AUTH_AUTHRESULT_HPP_INCLUDED diff --git a/include/rix/auth/PasswordHasher.hpp b/include/rix/auth/PasswordHasher.hpp index 2923c7f..0692f41 100644 --- a/include/rix/auth/PasswordHasher.hpp +++ b/include/rix/auth/PasswordHasher.hpp @@ -12,40 +12,45 @@ * * Rix * - * Password hashing helper for rix/auth. - * */ #ifndef RIXCPP_AUTH_INCLUDE_RIX_AUTH_PASSWORDHASHER_HPP_INCLUDED #define RIXCPP_AUTH_INCLUDE_RIX_AUTH_PASSWORDHASHER_HPP_INCLUDED +#include #include #include +#include #include #include namespace rixlib::auth { /** - * @brief Password hashing component used by rix/auth. + * @brief Production password hashing service. * - * PasswordHasher hides password hashing details behind a simple API. - * Application developers should use Auth instead of calling this class - * directly in most cases. + * PasswordHasher validates password policy and delegates hashing and + * verification to vix::crypto. * - * The current implementation is intentionally simple for the first version - * of rix/auth. The public API is designed so the internal algorithm can be - * upgraded later without changing the developer-facing Auth API. + * It does not store plain-text passwords and should only return encoded + * password hashes safe for database storage. */ class PasswordHasher { public: /** - * @brief Construct a password hasher with default settings. + * @brief Construct a password hasher with development defaults. */ PasswordHasher(); + /** + * @brief Construct a password hasher from auth configuration. + * + * @param config Authentication configuration. + */ + explicit PasswordHasher(const AuthConfig &config); + /** * @brief Hash a plain-text password. * @@ -58,11 +63,20 @@ namespace rixlib::auth * @brief Verify a plain-text password against a stored hash. * * @param password Plain-text password. - * @param password_hash Stored password hash. - * @return true if the password matches the stored hash. + * @param password_hash Stored encoded password hash. + * @return true if the password matches. + */ + [[nodiscard]] bool verify( + std::string_view password, + std::string_view password_hash) const; + + /** + * @brief Return true when the password satisfies the configured policy. + * + * @param password Plain-text password. + * @return true if the password is accepted. */ - [[nodiscard]] bool verify(std::string_view password, - std::string_view password_hash) const; + [[nodiscard]] bool accepts(std::string_view password) const noexcept; /** * @brief Return the minimum accepted password length. @@ -79,15 +93,68 @@ namespace rixlib::auth void set_min_password_length(std::size_t value) noexcept; /** - * @brief Return true when the password satisfies the minimum policy. + * @brief Return the maximum accepted password length. * - * @param password Plain-text password. - * @return true if the password is accepted. + * @return Maximum password length. */ - [[nodiscard]] bool accepts(std::string_view password) const noexcept; + [[nodiscard]] std::size_t max_password_length() const noexcept; + + /** + * @brief Set the maximum accepted password length. + * + * @param value Maximum password length. + */ + void set_max_password_length(std::size_t value) noexcept; + + /** + * @brief Return the password hash iteration count. + * + * @return PBKDF2 iteration count. + */ + [[nodiscard]] std::uint32_t iterations() const noexcept; + + /** + * @brief Set the password hash iteration count. + * + * @param value PBKDF2 iteration count. + */ + void set_iterations(std::uint32_t value) noexcept; + + /** + * @brief Return the password salt size in bytes. + * + * @return Salt size in bytes. + */ + [[nodiscard]] std::size_t salt_size() const noexcept; + + /** + * @brief Set the password salt size in bytes. + * + * @param value Salt size in bytes. + */ + void set_salt_size(std::size_t value) noexcept; + + /** + * @brief Return the derived password hash size in bytes. + * + * @return Hash size in bytes. + */ + [[nodiscard]] std::size_t hash_size() const noexcept; + + /** + * @brief Set the derived password hash size in bytes. + * + * @param value Hash size in bytes. + */ + void set_hash_size(std::size_t value) noexcept; private: std::size_t min_password_length_ = 8; + std::size_t max_password_length_ = 1024; + + std::uint32_t iterations_ = 310000; + std::size_t salt_size_ = 16; + std::size_t hash_size_ = 32; }; } // namespace rixlib::auth diff --git a/include/rix/auth/Session.hpp b/include/rix/auth/Session.hpp index b6e5cab..05d5105 100644 --- a/include/rix/auth/Session.hpp +++ b/include/rix/auth/Session.hpp @@ -12,8 +12,6 @@ * * Rix * - * Session model for rix/auth. - * */ #ifndef RIXCPP_AUTH_INCLUDE_RIX_AUTH_SESSION_HPP_INCLUDED @@ -26,14 +24,12 @@ namespace rixlib::auth { /** - * @brief Represents an authenticated user session. + * @brief Authenticated user session. * - * Session is a value object used to track authenticated users over time. - * It stores the session identifier, the related user identifier, creation - * time, expiration time, and revocation state. + * Session represents a server-side authenticated session. It stores the + * session id, related user id, timestamps, and revocation state. * - * The session token or identifier must be treated as sensitive data by the - * application. It should never be logged in production. + * The session id is sensitive and must not be logged in production. */ class Session { @@ -47,7 +43,7 @@ namespace rixlib::auth * @brief Construct a session. * * @param id Stable session identifier. - * @param user_id Identifier of the user who owns the session. + * @param user_id Identifier of the authenticated user. * @param created_at Unix timestamp in seconds. * @param expires_at Unix timestamp in seconds. */ @@ -85,73 +81,95 @@ namespace rixlib::auth void set_user_id(std::string value); /** - * @brief Return the session creation timestamp. + * @brief Return the creation timestamp. * * @return Unix timestamp in seconds. */ [[nodiscard]] std::int64_t created_at() const noexcept; /** - * @brief Set the session creation timestamp. + * @brief Set the creation timestamp. * * @param value Unix timestamp in seconds. */ void set_created_at(std::int64_t value) noexcept; /** - * @brief Return the session expiration timestamp. + * @brief Return the expiration timestamp. * * @return Unix timestamp in seconds. */ [[nodiscard]] std::int64_t expires_at() const noexcept; /** - * @brief Set the session expiration timestamp. + * @brief Set the expiration timestamp. * * @param value Unix timestamp in seconds. */ void set_expires_at(std::int64_t value) noexcept; /** - * @brief Return the last time this session was used. + * @brief Return the last seen timestamp. * * @return Unix timestamp in seconds. */ [[nodiscard]] std::int64_t last_seen_at() const noexcept; /** - * @brief Set the last time this session was used. + * @brief Set the last seen timestamp. * * @param value Unix timestamp in seconds. */ void set_last_seen_at(std::int64_t value) noexcept; /** - * @brief Return whether the session has been revoked. + * @brief Return whether this session is revoked. * * @return true if the session is revoked. */ [[nodiscard]] bool revoked() const noexcept; /** - * @brief Set whether the session is revoked. + * @brief Set whether this session is revoked. * * @param value true when the session is revoked. */ void set_revoked(bool value) noexcept; /** - * @brief Return true when the session has a non-empty id and user id. + * @brief Return true when the session has the required identity fields. * - * @return true if the session has the minimum required fields. + * @return true if id and user id are not empty. */ [[nodiscard]] bool valid() const noexcept; + /** + * @brief Return true when the session has an id. + * + * @return true if the id is not empty. + */ + [[nodiscard]] bool has_id() const noexcept; + + /** + * @brief Return true when the session has a user id. + * + * @return true if the user id is not empty. + */ + [[nodiscard]] bool has_user_id() const noexcept; + + /** + * @brief Return true when the session id matches the given value. + * + * @param value Session identifier to compare. + * @return true if the id matches. + */ + [[nodiscard]] bool has_id(std::string_view value) const noexcept; + /** * @brief Return true when this session belongs to the given user. * * @param value User identifier to compare. - * @return true if the user identifier matches. + * @return true if the user id matches. */ [[nodiscard]] bool belongs_to(std::string_view value) const noexcept; @@ -169,16 +187,41 @@ namespace rixlib::auth * A usable session must be valid, not revoked, and not expired. * * @param now Unix timestamp in seconds. - * @return true if the session can be used. + * @return true if the session is usable. */ [[nodiscard]] bool usable(std::int64_t now) const noexcept; + /** + * @brief Return true when the session can be refreshed at the given time. + * + * A refreshable session must be valid, not revoked, and not expired. + * + * @param now Unix timestamp in seconds. + * @return true if the session can be refreshed. + */ + [[nodiscard]] bool refreshable(std::int64_t now) const noexcept; + + /** + * @brief Refresh the session expiration and last seen timestamps. + * + * @param now Current Unix timestamp in seconds. + * @param ttl_seconds Lifetime to add from now. + */ + void refresh(std::int64_t now, std::int64_t ttl_seconds) noexcept; + + /** + * @brief Revoke the session. + */ + void revoke() noexcept; + private: std::string id_; std::string user_id_; + std::int64_t created_at_ = 0; std::int64_t expires_at_ = 0; std::int64_t last_seen_at_ = 0; + bool revoked_ = false; }; } // namespace rixlib::auth diff --git a/include/rix/auth/SessionStore.hpp b/include/rix/auth/SessionStore.hpp index e7c4c47..7dfdcff 100644 --- a/include/rix/auth/SessionStore.hpp +++ b/include/rix/auth/SessionStore.hpp @@ -12,8 +12,6 @@ * * Rix * - * Session storage interface for rix/auth. - * */ #ifndef RIXCPP_AUTH_INCLUDE_RIX_AUTH_SESSIONSTORE_HPP_INCLUDED @@ -29,14 +27,13 @@ namespace rixlib::auth { /** - * @brief Abstract storage interface for user sessions. + * @brief Abstract session storage interface. * - * SessionStore defines the persistence contract used by rix/auth to create, - * find, update, revoke, and delete authenticated sessions. + * SessionStore defines the persistence contract used by rix/auth for + * creating, updating, revoking, deleting, and finding authenticated sessions. * - * This keeps the developer-facing Auth API simple while allowing different - * storage backends to be implemented later, such as memory, files, SQLite, - * MySQL, Redis, or Vix-based storage modules. + * Concrete implementations can be backed by memory, database storage, + * local durable KV storage, or another session backend. */ class SessionStore { @@ -47,7 +44,7 @@ namespace rixlib::auth virtual ~SessionStore(); /** - * @brief Save a new session. + * @brief Create a new session. * * @param session Session to create. * @return AuthStatus indicating success or failure. @@ -63,7 +60,7 @@ namespace rixlib::auth [[nodiscard]] virtual AuthStatus update(const Session &session) = 0; /** - * @brief Delete a session by identifier. + * @brief Remove a session by identifier. * * @param id Session identifier. * @return AuthStatus indicating success or failure. @@ -73,7 +70,7 @@ namespace rixlib::auth /** * @brief Revoke a session by identifier. * - * A revoked session stays stored but can no longer be used. + * A revoked session remains stored but can no longer be used. * * @param id Session identifier. * @return AuthStatus indicating success or failure. @@ -81,7 +78,7 @@ namespace rixlib::auth [[nodiscard]] virtual AuthStatus revoke_by_id(std::string_view id) = 0; /** - * @brief Revoke all sessions attached to a user. + * @brief Revoke all sessions belonging to a user. * * @param user_id User identifier. * @return AuthStatus indicating success or failure. @@ -98,7 +95,7 @@ namespace rixlib::auth find_by_id(std::string_view id) const = 0; /** - * @brief Find all sessions attached to a user. + * @brief Find all sessions belonging to a user. * * @param user_id User identifier. * @return AuthResult containing matching sessions. @@ -107,7 +104,7 @@ namespace rixlib::auth find_by_user_id(std::string_view user_id) const = 0; /** - * @brief Return true when a session exists for the given identifier. + * @brief Return true when a session exists with the given identifier. * * @param id Session identifier. * @return AuthResult containing true when a matching session exists. @@ -118,10 +115,11 @@ namespace rixlib::auth /** * @brief Return all stored sessions. * - * This function is mainly useful for tests, small stores, admin tools, - * and future cleanup or migration utilities. + * This is mainly useful for tests, small stores, admin tools, cleanup jobs, + * and future migration utilities. Large production stores should prefer + * pagination in a dedicated higher-level API. * - * @return AuthResult containing the list of sessions. + * @return AuthResult containing all sessions. */ [[nodiscard]] virtual AuthResult> all() const = 0; }; diff --git a/include/rix/auth/Token.hpp b/include/rix/auth/Token.hpp index dcc6ec2..b025230 100644 --- a/include/rix/auth/Token.hpp +++ b/include/rix/auth/Token.hpp @@ -12,8 +12,6 @@ * * Rix * - * Token model for rix/auth. - * */ #ifndef RIXCPP_AUTH_INCLUDE_RIX_AUTH_TOKEN_HPP_INCLUDED @@ -26,14 +24,13 @@ namespace rixlib::auth { /** - * @brief Represents an authentication token. + * @brief Authentication token model. * - * Token is a value object used by rix/auth to represent short-lived or - * long-lived authentication tokens. + * Token represents a short-lived or long-lived authentication token. + * It stores the raw token value, related user id, issuer, timestamps, + * and revocation state. * - * It intentionally keeps the token payload simple at this level. Signing, - * verification, hashing, and transport concerns are handled by higher-level - * auth services or by Vix modules used internally. + * The token value is sensitive and must never be logged in production. */ class Token { @@ -59,8 +56,6 @@ namespace rixlib::auth /** * @brief Return the raw token value. * - * The returned value is sensitive and must not be logged in production. - * * @return Raw token value. */ [[nodiscard]] const std::string &value() const noexcept; @@ -89,26 +84,26 @@ namespace rixlib::auth /** * @brief Return the token issuer. * - * @return Token issuer. + * @return Issuer name. */ [[nodiscard]] const std::string &issuer() const noexcept; /** * @brief Set the token issuer. * - * @param value Token issuer. + * @param value Issuer name. */ void set_issuer(std::string value); /** - * @brief Return the token creation timestamp. + * @brief Return the token issue timestamp. * * @return Unix timestamp in seconds. */ [[nodiscard]] std::int64_t issued_at() const noexcept; /** - * @brief Set the token creation timestamp. + * @brief Set the token issue timestamp. * * @param value Unix timestamp in seconds. */ @@ -129,42 +124,64 @@ namespace rixlib::auth void set_expires_at(std::int64_t value) noexcept; /** - * @brief Return whether the token has been revoked. + * @brief Return whether the token is revoked. * - * @return true if the token has been revoked. + * @return true if the token is revoked. */ [[nodiscard]] bool revoked() const noexcept; /** - * @brief Set whether the token has been revoked. + * @brief Set whether the token is revoked. * * @param value true when the token is revoked. */ void set_revoked(bool value) noexcept; /** - * @brief Return true when the token has a non-empty value and user id. + * @brief Return true when the token has the required identity fields. * - * @return true if the token has the minimum required fields. + * @return true if value and user id are not empty. */ [[nodiscard]] bool valid() const noexcept; + /** + * @brief Return true when the token has a value. + * + * @return true if token value is not empty. + */ + [[nodiscard]] bool has_value() const noexcept; + + /** + * @brief Return true when the token has a user id. + * + * @return true if user id is not empty. + */ + [[nodiscard]] bool has_user_id() const noexcept; + /** * @brief Return true when this token belongs to the given user. * * @param value User identifier to compare. - * @return true if the user identifier matches. + * @return true if the user id matches. */ [[nodiscard]] bool belongs_to(std::string_view value) const noexcept; /** - * @brief Return true when the token value matches the given value. + * @brief Return true when the raw token value matches the given value. * * @param value Token value to compare. - * @return true if the token value matches. + * @return true if token value matches. */ [[nodiscard]] bool matches(std::string_view value) const noexcept; + /** + * @brief Return true when the issuer matches the given value. + * + * @param value Issuer to compare. + * @return true if issuer matches. + */ + [[nodiscard]] bool issued_by(std::string_view value) const noexcept; + /** * @brief Return true when the token is expired at the given time. * @@ -179,16 +196,23 @@ namespace rixlib::auth * A usable token must be valid, not revoked, and not expired. * * @param now Unix timestamp in seconds. - * @return true if the token can be used. + * @return true if the token is usable. */ [[nodiscard]] bool usable(std::int64_t now) const noexcept; + /** + * @brief Revoke the token. + */ + void revoke() noexcept; + private: std::string value_; std::string user_id_; std::string issuer_; + std::int64_t issued_at_ = 0; std::int64_t expires_at_ = 0; + bool revoked_ = false; }; } // namespace rixlib::auth diff --git a/include/rix/auth/User.hpp b/include/rix/auth/User.hpp index 52c1075..0cf1729 100644 --- a/include/rix/auth/User.hpp +++ b/include/rix/auth/User.hpp @@ -12,8 +12,6 @@ * * Rix * - * User model for rix/auth. - * */ #ifndef RIXCPP_AUTH_INCLUDE_RIX_AUTH_USER_HPP_INCLUDED @@ -26,14 +24,12 @@ namespace rixlib::auth { /** - * @brief Represents an authenticated application user. + * @brief Authentication user model. * - * User is a simple value object used by rix/auth to represent the identity - * stored in the user store. + * User is the persistent identity model used by rix/auth. + * It stores identity fields, password hash, account state, and timestamps. * - * It intentionally does not expose password hashes through public setters - * used by application code. The authentication service is responsible for - * creating and verifying password hashes. + * The password hash must never be sent to clients or written to logs. */ class User { @@ -87,8 +83,6 @@ namespace rixlib::auth /** * @brief Return the stored password hash. * - * This value must never be sent to clients or logs. - * * @return Stored password hash. */ [[nodiscard]] const std::string &password_hash() const noexcept; @@ -96,91 +90,122 @@ namespace rixlib::auth /** * @brief Set the stored password hash. * - * This function is intended for internal auth/store code. + * This should be used only by auth/store code. * * @param value Stored password hash. */ void set_password_hash(std::string value); /** - * @brief Return whether the user email address has been verified. + * @brief Return whether the user email has been verified. * * @return true if the email is verified. */ [[nodiscard]] bool email_verified() const noexcept; /** - * @brief Set whether the user email address has been verified. + * @brief Set whether the user email has been verified. * - * @param value true when the email is verified. + * @param value true when verified. */ void set_email_verified(bool value) noexcept; /** - * @brief Return whether the user is active. - * - * Inactive users cannot authenticate. + * @brief Return whether the user account is active. * * @return true if the user is active. */ [[nodiscard]] bool active() const noexcept; /** - * @brief Set whether the user is active. + * @brief Set whether the user account is active. * - * @param value true when the user is active. + * @param value true when active. */ void set_active(bool value) noexcept; /** - * @brief Return the user creation timestamp. + * @brief Return user creation timestamp. * * @return Unix timestamp in seconds. */ [[nodiscard]] std::int64_t created_at() const noexcept; /** - * @brief Set the user creation timestamp. + * @brief Set user creation timestamp. * * @param value Unix timestamp in seconds. */ void set_created_at(std::int64_t value) noexcept; /** - * @brief Return the last update timestamp. + * @brief Return last update timestamp. * * @return Unix timestamp in seconds. */ [[nodiscard]] std::int64_t updated_at() const noexcept; /** - * @brief Set the last update timestamp. + * @brief Set last update timestamp. * * @param value Unix timestamp in seconds. */ void set_updated_at(std::int64_t value) noexcept; /** - * @brief Return true when the user has a non-empty id and email. + * @brief Return true when the user has the minimum identity fields. * - * @return true if the user has the minimum required identity fields. + * @return true if id, email, and password hash are not empty. */ [[nodiscard]] bool valid() const noexcept; /** - * @brief Return true when the given email matches this user. + * @brief Return true when the user has an id. + * + * @return true if id is not empty. + */ + [[nodiscard]] bool has_id() const noexcept; + + /** + * @brief Return true when the user has an email. + * + * @return true if email is not empty. + */ + [[nodiscard]] bool has_email() const noexcept; + + /** + * @brief Return true when the user email matches the given value. * * @param value Email address to compare. - * @return true if the email is identical. + * @return true if email matches. */ [[nodiscard]] bool has_email(std::string_view value) const noexcept; + /** + * @brief Return true when the user id matches the given value. + * + * @param value User id to compare. + * @return true if id matches. + */ + [[nodiscard]] bool has_id(std::string_view value) const noexcept; + + /** + * @brief Return true when the user can authenticate. + * + * A user can authenticate when the model is valid and active. + * + * @return true if the user can authenticate. + */ + [[nodiscard]] bool can_authenticate() const noexcept; + private: std::string id_; std::string email_; std::string password_hash_; + bool email_verified_ = false; bool active_ = true; + std::int64_t created_at_ = 0; std::int64_t updated_at_ = 0; }; diff --git a/include/rix/auth/UserStore.hpp b/include/rix/auth/UserStore.hpp index bcff363..b6803e0 100644 --- a/include/rix/auth/UserStore.hpp +++ b/include/rix/auth/UserStore.hpp @@ -12,8 +12,6 @@ * * Rix * - * User storage interface for rix/auth. - * */ #ifndef RIXCPP_AUTH_INCLUDE_RIX_AUTH_USERSTORE_HPP_INCLUDED @@ -23,22 +21,19 @@ #include #include -#include #include #include namespace rixlib::auth { /** - * @brief Abstract storage interface for users. + * @brief Abstract user storage interface. * - * UserStore defines the persistence contract used by rix/auth to create, - * find, update, and delete users. + * UserStore defines the persistence contract used by rix/auth for user + * creation, lookup, update, deletion, and existence checks. * - * The goal is to keep Auth simple for developers while allowing the storage - * implementation to evolve independently. Applications can later provide - * stores backed by memory, files, SQLite, MySQL, PostgreSQL, Redis, or any - * Vix-based storage module without changing the public Auth API. + * Concrete implementations can be backed by memory, SQLite, MySQL, or any + * other storage system without changing the public Auth API. */ class UserStore { @@ -49,7 +44,7 @@ namespace rixlib::auth virtual ~UserStore(); /** - * @brief Save a new user. + * @brief Create a new user. * * @param user User to create. * @return AuthStatus indicating success or failure. @@ -65,7 +60,7 @@ namespace rixlib::auth [[nodiscard]] virtual AuthStatus update(const User &user) = 0; /** - * @brief Delete a user by identifier. + * @brief Remove a user by identifier. * * @param id User identifier. * @return AuthStatus indicating success or failure. @@ -91,7 +86,7 @@ namespace rixlib::auth find_by_email(std::string_view email) const = 0; /** - * @brief Return true when a user exists for the given identifier. + * @brief Return true when a user exists with the given identifier. * * @param id User identifier. * @return AuthResult containing true when a matching user exists. @@ -100,7 +95,7 @@ namespace rixlib::auth exists_by_id(std::string_view id) const = 0; /** - * @brief Return true when a user exists for the given email address. + * @brief Return true when a user exists with the given email address. * * @param email User email address. * @return AuthResult containing true when a matching user exists. @@ -111,11 +106,11 @@ namespace rixlib::auth /** * @brief Return all stored users. * - * This function is mainly useful for small stores, tests, admin tools, - * and future migrations. Large production stores may implement pagination - * later through a dedicated store type. + * This is mainly useful for tests, small stores, admin tools, and future + * migration utilities. Large production stores should prefer pagination in + * a dedicated higher-level API. * - * @return AuthResult containing the list of users. + * @return AuthResult containing all users. */ [[nodiscard]] virtual AuthResult> all() const = 0; }; diff --git a/include/rix/auth/Version.hpp b/include/rix/auth/Version.hpp index 4ef0a6c..c3ed9fe 100644 --- a/include/rix/auth/Version.hpp +++ b/include/rix/auth/Version.hpp @@ -12,8 +12,6 @@ * * Rix * - * Version information for rix/auth. - * */ #ifndef RIXCPP_AUTH_INCLUDE_RIX_AUTH_VERSION_HPP_INCLUDED @@ -21,44 +19,28 @@ #include -/// Major version component of rix/auth. #define RIXCPP_AUTH_VERSION_MAJOR 0 - -/// Minor version component of rix/auth. -#define RIXCPP_AUTH_VERSION_MINOR 1 - -/// Patch version component of rix/auth. +#define RIXCPP_AUTH_VERSION_MINOR 2 #define RIXCPP_AUTH_VERSION_PATCH 0 -/// Encoded rix/auth version: major * 10000 + minor * 100 + patch. -#define RIXCPP_AUTH_VERSION \ - (RIXCPP_AUTH_VERSION_MAJOR * 10000 + RIXCPP_AUTH_VERSION_MINOR * 100 + RIXCPP_AUTH_VERSION_PATCH) +#define RIXCPP_AUTH_VERSION_NUMBER \ + ((RIXCPP_AUTH_VERSION_MAJOR * 10000) + \ + (RIXCPP_AUTH_VERSION_MINOR * 100) + \ + RIXCPP_AUTH_VERSION_PATCH) namespace rixlib::auth { /** * @brief Return the rix/auth package version as a string. * - * The returned value follows semantic versioning: - * - * @code - * major.minor.patch - * @endcode - * - * Example: - * - * @code - * std::string current = rixlib::auth::version(); - * @endcode - * - * @return The package version, for example "0.1.0". + * @return Version string using semantic versioning. */ [[nodiscard]] std::string version(); /** * @brief Return the major version component. * - * @return The major version number. + * @return Major version number. */ [[nodiscard]] constexpr int version_major() noexcept { @@ -68,7 +50,7 @@ namespace rixlib::auth /** * @brief Return the minor version component. * - * @return The minor version number. + * @return Minor version number. */ [[nodiscard]] constexpr int version_minor() noexcept { @@ -78,7 +60,7 @@ namespace rixlib::auth /** * @brief Return the patch version component. * - * @return The patch version number. + * @return Patch version number. */ [[nodiscard]] constexpr int version_patch() noexcept { @@ -88,19 +70,17 @@ namespace rixlib::auth /** * @brief Return the encoded integer version. * - * The encoded version uses the following format: + * Format: * * @code - * major * 10000 + minor * 100 + patch + * major * 10000 + minor * 100 + patch * @endcode * - * For version 0.1.0, this returns 100. - * - * @return The encoded version integer. + * @return Encoded version number. */ [[nodiscard]] constexpr int version_number() noexcept { - return RIXCPP_AUTH_VERSION; + return RIXCPP_AUTH_VERSION_NUMBER; } } // namespace rixlib::auth diff --git a/include/rix/auth/stores/DbSessionStore.hpp b/include/rix/auth/stores/DbSessionStore.hpp new file mode 100644 index 0000000..31730c7 --- /dev/null +++ b/include/rix/auth/stores/DbSessionStore.hpp @@ -0,0 +1,158 @@ +/** + * + * @file DbSessionStore.hpp + * @author Gaspard Kirira + * + * Copyright 2026, Gaspard Kirira. + * All rights reserved. + * https://github.com/rixcpp/auth + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Rix + * + */ + +#ifndef RIXCPP_AUTH_INCLUDE_RIX_AUTH_STORES_DBSESSIONSTORE_HPP_INCLUDED +#define RIXCPP_AUTH_INCLUDE_RIX_AUTH_STORES_DBSESSIONSTORE_HPP_INCLUDED + +#include + +#include +#include +#include + +namespace vix::db +{ + class Database; + struct ResultRow; +} + +namespace rixlib::auth +{ + /** + * @brief Database-backed session store. + * + * DbSessionStore persists authenticated sessions through vix::db. + * It is intended for real applications where sessions must survive + * process restarts. + * + * The store expects a table named `rix_auth_sessions`. The constructor can + * create the table automatically when requested. + */ + class DbSessionStore final : public SessionStore + { + public: + /** + * @brief Construct a database-backed session store. + * + * @param database Vix database facade. + * @param create_schema true to create the sessions table automatically. + */ + explicit DbSessionStore(vix::db::Database &database, bool create_schema = true); + + /** + * @brief Destroy the database-backed session store. + */ + ~DbSessionStore() override = default; + + DbSessionStore(const DbSessionStore &) = delete; + DbSessionStore &operator=(const DbSessionStore &) = delete; + + /** + * @brief Create a new session. + * + * @param session Session to create. + * @return AuthStatus indicating success or failure. + */ + [[nodiscard]] AuthStatus create(const Session &session) override; + + /** + * @brief Update an existing session. + * + * @param session Session to update. + * @return AuthStatus indicating success or failure. + */ + [[nodiscard]] AuthStatus update(const Session &session) override; + + /** + * @brief Remove a session by identifier. + * + * @param id Session identifier. + * @return AuthStatus indicating success or failure. + */ + [[nodiscard]] AuthStatus remove_by_id(std::string_view id) override; + + /** + * @brief Revoke a session by identifier. + * + * @param id Session identifier. + * @return AuthStatus indicating success or failure. + */ + [[nodiscard]] AuthStatus revoke_by_id(std::string_view id) override; + + /** + * @brief Revoke all sessions belonging to a user. + * + * @param user_id User identifier. + * @return AuthStatus indicating success or failure. + */ + [[nodiscard]] AuthStatus revoke_by_user_id(std::string_view user_id) override; + + /** + * @brief Find a session by identifier. + * + * @param id Session identifier. + * @return Optional session when found. + */ + [[nodiscard]] AuthResult> + find_by_id(std::string_view id) const override; + + /** + * @brief Find all sessions belonging to a user. + * + * @param user_id User identifier. + * @return AuthResult containing matching sessions. + */ + [[nodiscard]] AuthResult> + find_by_user_id(std::string_view user_id) const override; + + /** + * @brief Return true when a session exists by identifier. + * + * @param id Session identifier. + * @return AuthResult containing the existence state. + */ + [[nodiscard]] AuthResult + exists_by_id(std::string_view id) const override; + + /** + * @brief Return all stored sessions. + * + * @return AuthResult containing all sessions. + */ + [[nodiscard]] AuthResult> all() const override; + + /** + * @brief Create the required sessions table if it does not exist. + * + * @return AuthStatus indicating success or failure. + */ + [[nodiscard]] AuthStatus ensure_schema(); + + private: + [[nodiscard]] static Session row_to_session(const vix::db::ResultRow &row); + + [[nodiscard]] static AuthError store_error(std::string message); + [[nodiscard]] static AuthError invalid_session_error(); + [[nodiscard]] static AuthError empty_session_id_error(); + [[nodiscard]] static AuthError empty_user_id_error(); + [[nodiscard]] static AuthError session_not_found_error(); + [[nodiscard]] static AuthError session_already_exists_error(); + + vix::db::Database *database_ = nullptr; + }; +} // namespace rixlib::auth + +#endif // RIXCPP_AUTH_INCLUDE_RIX_AUTH_STORES_DBSESSIONSTORE_HPP_INCLUDED diff --git a/include/rix/auth/stores/DbUserStore.hpp b/include/rix/auth/stores/DbUserStore.hpp new file mode 100644 index 0000000..d77d983 --- /dev/null +++ b/include/rix/auth/stores/DbUserStore.hpp @@ -0,0 +1,149 @@ +/** + * + * @file DbUserStore.hpp + * @author Gaspard Kirira + * + * Copyright 2026, Gaspard Kirira. + * All rights reserved. + * https://github.com/rixcpp/auth + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Rix + * + */ + +#ifndef RIXCPP_AUTH_INCLUDE_RIX_AUTH_STORES_DBUSERSTORE_HPP_INCLUDED +#define RIXCPP_AUTH_INCLUDE_RIX_AUTH_STORES_DBUSERSTORE_HPP_INCLUDED + +#include + +#include +#include + +namespace vix::db +{ + class Database; + struct ResultRow; +} + +namespace rixlib::auth +{ + /** + * @brief Database-backed user store. + * + * DbUserStore persists users through vix::db. It is intended for real + * applications where users must survive process restarts. + * + * The store expects a table named `rix_auth_users`. The constructor can + * create the table automatically when requested. + */ + class DbUserStore final : public UserStore + { + public: + /** + * @brief Construct a database-backed user store. + * + * @param database Vix database facade. + * @param create_schema true to create the users table automatically. + */ + explicit DbUserStore(vix::db::Database &database, bool create_schema = true); + + /** + * @brief Destroy the database-backed user store. + */ + ~DbUserStore() override = default; + + DbUserStore(const DbUserStore &) = delete; + DbUserStore &operator=(const DbUserStore &) = delete; + + /** + * @brief Create a new user. + * + * @param user User to create. + * @return AuthStatus indicating success or failure. + */ + [[nodiscard]] AuthStatus create(const User &user) override; + + /** + * @brief Update an existing user. + * + * @param user User to update. + * @return AuthStatus indicating success or failure. + */ + [[nodiscard]] AuthStatus update(const User &user) override; + + /** + * @brief Remove a user by identifier. + * + * @param id User identifier. + * @return AuthStatus indicating success or failure. + */ + [[nodiscard]] AuthStatus remove_by_id(std::string_view id) override; + + /** + * @brief Find a user by identifier. + * + * @param id User identifier. + * @return Optional user when found. + */ + [[nodiscard]] AuthResult> + find_by_id(std::string_view id) const override; + + /** + * @brief Find a user by email address. + * + * @param email User email address. + * @return Optional user when found. + */ + [[nodiscard]] AuthResult> + find_by_email(std::string_view email) const override; + + /** + * @brief Return true when a user exists by identifier. + * + * @param id User identifier. + * @return AuthResult containing the existence state. + */ + [[nodiscard]] AuthResult + exists_by_id(std::string_view id) const override; + + /** + * @brief Return true when a user exists by email. + * + * @param email User email address. + * @return AuthResult containing the existence state. + */ + [[nodiscard]] AuthResult + exists_by_email(std::string_view email) const override; + + /** + * @brief Return all stored users. + * + * @return AuthResult containing all users. + */ + [[nodiscard]] AuthResult> all() const override; + + /** + * @brief Create the required users table if it does not exist. + * + * @return AuthStatus indicating success or failure. + */ + [[nodiscard]] AuthStatus ensure_schema(); + + private: + [[nodiscard]] static User row_to_user(const vix::db::ResultRow &row); + + [[nodiscard]] static AuthError store_error(std::string message); + [[nodiscard]] static AuthError invalid_user_error(); + [[nodiscard]] static AuthError empty_id_error(); + [[nodiscard]] static AuthError empty_email_error(); + [[nodiscard]] static AuthError user_not_found_error(); + [[nodiscard]] static AuthError user_already_exists_error(); + + vix::db::Database *database_ = nullptr; + }; +} // namespace rixlib::auth + +#endif // RIXCPP_AUTH_INCLUDE_RIX_AUTH_STORES_DBUSERSTORE_HPP_INCLUDED diff --git a/include/rix/auth/stores/MemorySessionStore.hpp b/include/rix/auth/stores/MemorySessionStore.hpp new file mode 100644 index 0000000..d2ad6ed --- /dev/null +++ b/include/rix/auth/stores/MemorySessionStore.hpp @@ -0,0 +1,160 @@ +/** + * + * @file MemorySessionStore.hpp + * @author Gaspard Kirira + * + * Copyright 2026, Gaspard Kirira. + * All rights reserved. + * https://github.com/rixcpp/auth + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Rix + * + */ + +#ifndef RIXCPP_AUTH_INCLUDE_RIX_AUTH_STORES_MEMORYSESSIONSTORE_HPP_INCLUDED +#define RIXCPP_AUTH_INCLUDE_RIX_AUTH_STORES_MEMORYSESSIONSTORE_HPP_INCLUDED + +#include + +#include +#include +#include +#include +#include +#include + +namespace rixlib::auth +{ + /** + * @brief In-memory session store. + * + * MemorySessionStore is useful for tests, examples, local development, + * and small applications that do not need durable session persistence. + * + * It is thread-safe for individual store operations. + */ + class MemorySessionStore final : public SessionStore + { + public: + /** + * @brief Construct an empty in-memory session store. + */ + MemorySessionStore() = default; + + /** + * @brief Destroy the in-memory session store. + */ + ~MemorySessionStore() override = default; + + MemorySessionStore(const MemorySessionStore &) = delete; + MemorySessionStore &operator=(const MemorySessionStore &) = delete; + + /** + * @brief Create a new session. + * + * @param session Session to create. + * @return AuthStatus indicating success or failure. + */ + [[nodiscard]] AuthStatus create(const Session &session) override; + + /** + * @brief Update an existing session. + * + * @param session Session to update. + * @return AuthStatus indicating success or failure. + */ + [[nodiscard]] AuthStatus update(const Session &session) override; + + /** + * @brief Remove a session by identifier. + * + * @param id Session identifier. + * @return AuthStatus indicating success or failure. + */ + [[nodiscard]] AuthStatus remove_by_id(std::string_view id) override; + + /** + * @brief Revoke a session by identifier. + * + * @param id Session identifier. + * @return AuthStatus indicating success or failure. + */ + [[nodiscard]] AuthStatus revoke_by_id(std::string_view id) override; + + /** + * @brief Revoke all sessions belonging to a user. + * + * @param user_id User identifier. + * @return AuthStatus indicating success or failure. + */ + [[nodiscard]] AuthStatus revoke_by_user_id(std::string_view user_id) override; + + /** + * @brief Find a session by identifier. + * + * @param id Session identifier. + * @return Optional session when found. + */ + [[nodiscard]] AuthResult> + find_by_id(std::string_view id) const override; + + /** + * @brief Find all sessions belonging to a user. + * + * @param user_id User identifier. + * @return AuthResult containing matching sessions. + */ + [[nodiscard]] AuthResult> + find_by_user_id(std::string_view user_id) const override; + + /** + * @brief Return true when a session exists by identifier. + * + * @param id Session identifier. + * @return AuthResult containing the existence state. + */ + [[nodiscard]] AuthResult + exists_by_id(std::string_view id) const override; + + /** + * @brief Return all stored sessions. + * + * @return AuthResult containing all sessions. + */ + [[nodiscard]] AuthResult> all() const override; + + /** + * @brief Remove all sessions from memory. + */ + void clear(); + + /** + * @brief Return the number of stored sessions. + * + * @return Session count. + */ + [[nodiscard]] std::size_t size() const; + + /** + * @brief Return true when the store is empty. + * + * @return true if no sessions are stored. + */ + [[nodiscard]] bool empty() const; + + private: + [[nodiscard]] static std::string key(std::string_view value); + + void index_session_locked(const Session &session); + void unindex_session_locked(const Session &session); + + mutable std::mutex mutex_; + std::unordered_map sessions_by_id_; + std::unordered_map> ids_by_user_id_; + }; +} // namespace rixlib::auth + +#endif // RIXCPP_AUTH_INCLUDE_RIX_AUTH_STORES_MEMORYSESSIONSTORE_HPP_INCLUDED diff --git a/include/rix/auth/stores/MemoryUserStore.hpp b/include/rix/auth/stores/MemoryUserStore.hpp new file mode 100644 index 0000000..1b96add --- /dev/null +++ b/include/rix/auth/stores/MemoryUserStore.hpp @@ -0,0 +1,150 @@ +/** + * + * @file MemoryUserStore.hpp + * @author Gaspard Kirira + * + * Copyright 2026, Gaspard Kirira. + * All rights reserved. + * https://github.com/rixcpp/auth + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Rix + * + */ + +#ifndef RIXCPP_AUTH_INCLUDE_RIX_AUTH_STORES_MEMORYUSERSTORE_HPP_INCLUDED +#define RIXCPP_AUTH_INCLUDE_RIX_AUTH_STORES_MEMORYUSERSTORE_HPP_INCLUDED + +#include + +#include +#include +#include +#include +#include +#include + +namespace rixlib::auth +{ + /** + * @brief In-memory user store. + * + * MemoryUserStore is useful for tests, examples, local development, + * and small applications that do not need durable user persistence. + * + * It is thread-safe for individual store operations. + */ + class MemoryUserStore final : public UserStore + { + public: + /** + * @brief Construct an empty in-memory user store. + */ + MemoryUserStore() = default; + + /** + * @brief Destroy the in-memory user store. + */ + ~MemoryUserStore() override = default; + + MemoryUserStore(const MemoryUserStore &) = delete; + MemoryUserStore &operator=(const MemoryUserStore &) = delete; + + /** + * @brief Create a new user. + * + * @param user User to create. + * @return AuthStatus indicating success or failure. + */ + [[nodiscard]] AuthStatus create(const User &user) override; + + /** + * @brief Update an existing user. + * + * @param user User to update. + * @return AuthStatus indicating success or failure. + */ + [[nodiscard]] AuthStatus update(const User &user) override; + + /** + * @brief Remove a user by identifier. + * + * @param id User identifier. + * @return AuthStatus indicating success or failure. + */ + [[nodiscard]] AuthStatus remove_by_id(std::string_view id) override; + + /** + * @brief Find a user by identifier. + * + * @param id User identifier. + * @return Optional user when found. + */ + [[nodiscard]] AuthResult> + find_by_id(std::string_view id) const override; + + /** + * @brief Find a user by email address. + * + * @param email User email address. + * @return Optional user when found. + */ + [[nodiscard]] AuthResult> + find_by_email(std::string_view email) const override; + + /** + * @brief Return true when a user exists by identifier. + * + * @param id User identifier. + * @return AuthResult containing the existence state. + */ + [[nodiscard]] AuthResult + exists_by_id(std::string_view id) const override; + + /** + * @brief Return true when a user exists by email. + * + * @param email User email address. + * @return AuthResult containing the existence state. + */ + [[nodiscard]] AuthResult + exists_by_email(std::string_view email) const override; + + /** + * @brief Return all stored users. + * + * @return AuthResult containing all users. + */ + [[nodiscard]] AuthResult> all() const override; + + /** + * @brief Remove all users from memory. + */ + void clear(); + + /** + * @brief Return the number of stored users. + * + * @return User count. + */ + [[nodiscard]] std::size_t size() const; + + /** + * @brief Return true when the store is empty. + * + * @return true if no users are stored. + */ + [[nodiscard]] bool empty() const; + + private: + [[nodiscard]] static std::string key(std::string_view value); + + mutable std::mutex mutex_; + std::unordered_map users_by_id_; + std::unordered_map id_by_email_; + }; +} // namespace rixlib::auth + +#endif // RIXCPP_AUTH_INCLUDE_RIX_AUTH_STORES_MEMORYUSERSTORE_HPP_INCLUDED diff --git a/src/Auth.cpp b/src/Auth.cpp index c363003..c4e9a82 100644 --- a/src/Auth.cpp +++ b/src/Auth.cpp @@ -16,31 +16,60 @@ #include -#include -#include -#include -#include +#include +#include +#include +#include + +#include +#include +#include #include namespace rixlib::auth { namespace { - [[nodiscard]] std::string make_random_id(std::string_view prefix) + [[nodiscard]] AuthError invalid_email_error() { - static thread_local std::mt19937_64 engine{ - std::random_device{}()}; + return make_auth_error( + AuthErrorCode::InvalidEmail, + "Email address is invalid."); + } - const auto now = std::chrono::system_clock::now() - .time_since_epoch() - .count(); + [[nodiscard]] AuthError invalid_password_error(std::string message) + { + return make_auth_error( + AuthErrorCode::InvalidPassword, + std::move(message)); + } - const auto value = engine(); + [[nodiscard]] AuthError invalid_credentials_error() + { + return make_auth_error( + AuthErrorCode::InvalidCredentials, + "Invalid email or password."); + } - std::ostringstream stream; - stream << prefix << "_" << now << "_" << value; + [[nodiscard]] AuthError invalid_session_error(std::string message) + { + return make_auth_error( + AuthErrorCode::InvalidSession, + std::move(message)); + } - return stream.str(); + [[nodiscard]] AuthError invalid_state_error(std::string message) + { + return make_auth_error( + AuthErrorCode::InvalidState, + std::move(message)); + } + + [[nodiscard]] AuthError crypto_error(std::string message) + { + return make_auth_error( + AuthErrorCode::CryptoError, + std::move(message)); } } // namespace @@ -52,20 +81,22 @@ namespace rixlib::auth Auth::Auth(UserStore &users, SessionStore &sessions, AuthConfig config) : users_(&users), sessions_(&sessions), - config_(std::move(config)) + config_(std::move(config)), + password_hasher_(config_) { - password_hasher_.set_min_password_length(config_.min_password_length()); } AuthResult Auth::register_user(const RegisterRequest &request) { const auto validation = validate_register_request(request); + if (validation.failed()) { return AuthResult::failure(validation.error()); } auto exists = users_->exists_by_email(request.email); + if (exists.failed()) { return AuthResult::failure(exists.error()); @@ -74,31 +105,42 @@ namespace rixlib::auth if (exists.value()) { return AuthResult::failure( - make_auth_error(AuthErrorCode::UserAlreadyExists, - "A user already exists with this email address.")); + make_auth_error( + AuthErrorCode::UserAlreadyExists, + "A user already exists with this email address.")); + } + + auto user_id = make_user_id(); + + if (user_id.failed()) + { + return AuthResult::failure(user_id.error()); } auto password_hash = password_hasher_.hash(request.password); + if (password_hash.failed()) { return AuthResult::failure(password_hash.error()); } - const auto created_at = now_seconds(); + const auto now = now_seconds(); User user{ - make_user_id(), + user_id.value(), request.email, password_hash.value(), - created_at}; + now}; - user.set_email_verified(!config_.require_email_verification()); + user.set_updated_at(now); user.set_active(true); + user.set_email_verified(!config_.require_email_verification()); - auto status = users_->create(user); - if (status.failed()) + auto created = users_->create(user); + + if (created.failed()) { - return AuthResult::failure(status.error()); + return AuthResult::failure(created.error()); } return AuthResult::success(std::move(user)); @@ -107,12 +149,14 @@ namespace rixlib::auth AuthResult Auth::login(const LoginRequest &request) { const auto validation = validate_login_request(request); + if (validation.failed()) { return AuthResult::failure(validation.error()); } auto found = users_->find_by_email(request.email); + if (found.failed()) { return AuthResult::failure(found.error()); @@ -120,51 +164,60 @@ namespace rixlib::auth if (!found.value().has_value()) { - return AuthResult::failure( - make_auth_error(AuthErrorCode::InvalidCredentials, - "Invalid email or password.")); + return AuthResult::failure(invalid_credentials_error()); } User user = found.value().value(); if (!user.active()) { - return AuthResult::failure( - make_auth_error(AuthErrorCode::InvalidCredentials, - "Invalid email or password.")); + return AuthResult::failure(invalid_credentials_error()); } if (config_.require_email_verification() && !user.email_verified()) { return AuthResult::failure( - make_auth_error(AuthErrorCode::InvalidState, - "Email verification is required before login.")); + invalid_state_error("Email verification is required before login.")); } if (!password_hasher_.verify(request.password, user.password_hash())) { - return AuthResult::failure( - make_auth_error(AuthErrorCode::InvalidCredentials, - "Invalid email or password.")); + return AuthResult::failure(invalid_credentials_error()); + } + + auto session_id = make_session_id(); + + if (session_id.failed()) + { + return AuthResult::failure(session_id.error()); } const auto now = now_seconds(); Session session{ - make_session_id(), + session_id.value(), user.id(), now, now + config_.session_ttl_seconds()}; - auto status = sessions_->create(session); - if (status.failed()) + auto created = sessions_->create(session); + + if (created.failed()) + { + return AuthResult::failure(created.error()); + } + + auto token = issue_token(user.id()); + + if (token.failed()) { - return AuthResult::failure(status.error()); + return AuthResult::failure(token.error()); } LoginResult result{ std::move(user), - std::move(session)}; + std::move(session), + token.value()}; return AuthResult::success(std::move(result)); } @@ -174,23 +227,35 @@ namespace rixlib::auth if (session_id.empty()) { return AuthStatus::failure( - make_auth_error(AuthErrorCode::InvalidSession, - "Session id cannot be empty.")); + invalid_session_error("Session id cannot be empty.")); } return sessions_->revoke_by_id(session_id); } + AuthStatus Auth::logout_user(std::string_view user_id) + { + if (user_id.empty()) + { + return AuthStatus::failure( + make_auth_error( + AuthErrorCode::InvalidInput, + "User id cannot be empty.")); + } + + return sessions_->revoke_by_user_id(user_id); + } + AuthResult Auth::authenticate_session(std::string_view session_id) { if (session_id.empty()) { return AuthResult::failure( - make_auth_error(AuthErrorCode::InvalidSession, - "Session id cannot be empty.")); + invalid_session_error("Session id cannot be empty.")); } auto found = sessions_->find_by_id(session_id); + if (found.failed()) { return AuthResult::failure(found.error()); @@ -199,37 +264,96 @@ namespace rixlib::auth if (!found.value().has_value()) { return AuthResult::failure( - make_auth_error(AuthErrorCode::InvalidSession, - "Session not found.")); + invalid_session_error("Session not found.")); } Session session = found.value().value(); + const auto now = now_seconds(); + if (session.revoked()) { return AuthResult::failure( - make_auth_error(AuthErrorCode::InvalidSession, - "Session has been revoked.")); + invalid_session_error("Session has been revoked.")); } - if (session.expired(now_seconds())) + if (session.expired(now)) { return AuthResult::failure( - make_auth_error(AuthErrorCode::SessionExpired, - "Session has expired.")); + make_auth_error( + AuthErrorCode::SessionExpired, + "Session has expired.")); } - session.set_last_seen_at(now_seconds()); + session.set_last_seen_at(now); + + auto updated = sessions_->update(session); - auto status = sessions_->update(session); - if (status.failed()) + if (updated.failed()) { - return AuthResult::failure(status.error()); + return AuthResult::failure(updated.error()); } return AuthResult::success(std::move(session)); } + AuthResult Auth::refresh_session(std::string_view session_id) + { + auto authenticated = authenticate_session(session_id); + + if (authenticated.failed()) + { + return authenticated; + } + + Session session = authenticated.value(); + + const auto now = now_seconds(); + + if (!session.refreshable(now)) + { + return AuthResult::failure( + invalid_session_error("Session cannot be refreshed.")); + } + + session.refresh(now, config_.session_ttl_seconds()); + + auto updated = sessions_->update(session); + + if (updated.failed()) + { + return AuthResult::failure(updated.error()); + } + + return AuthResult::success(std::move(session)); + } + + AuthResult Auth::issue_token(std::string_view user_id) + { + if (user_id.empty()) + { + return AuthResult::failure( + make_auth_error( + AuthErrorCode::InvalidInput, + "User id cannot be empty.")); + } + + auto token_value = make_token_value(); + + if (token_value.failed()) + { + return AuthResult::failure(token_value.error()); + } + + const auto now = now_seconds(); + + return AuthResult::success( + make_token_for_user( + std::string(user_id), + now, + token_value.value())); + } + const AuthConfig &Auth::config() const noexcept { return config_; @@ -243,18 +367,18 @@ namespace rixlib::auth AuthStatus Auth::validate_register_request( const RegisterRequest &request) const { - if (!is_valid_email(request.email)) + const auto email = validate_email(request.email); + + if (email.failed()) { - return AuthStatus::failure( - make_auth_error(AuthErrorCode::InvalidEmail, - "Email address is invalid.")); + return email; } - if (!password_hasher_.accepts(request.password)) + const auto password = validate_password_for_register(request.password); + + if (password.failed()) { - return AuthStatus::failure( - make_auth_error(AuthErrorCode::InvalidPassword, - "Password does not satisfy the minimum length policy.")); + return password; } return AuthStatus::success(); @@ -263,51 +387,111 @@ namespace rixlib::auth AuthStatus Auth::validate_login_request( const LoginRequest &request) const { - if (!is_valid_email(request.email)) + const auto email = validate_email(request.email); + + if (email.failed()) { - return AuthStatus::failure( - make_auth_error(AuthErrorCode::InvalidEmail, - "Email address is invalid.")); + return email; } if (request.password.empty()) { return AuthStatus::failure( - make_auth_error(AuthErrorCode::InvalidPassword, - "Password cannot be empty.")); + invalid_password_error("Password cannot be empty.")); } return AuthStatus::success(); } - bool Auth::is_valid_email(std::string_view email) const noexcept + AuthStatus Auth::validate_email(std::string_view email) const { - const auto at = email.find('@'); - if (at == std::string_view::npos || at == 0 || at + 1 >= email.size()) + const std::string value(email); + + const auto result = vix::validation::validate("email", value) + .required("Email address is required.") + .email("Email address is invalid.") + .length_max(320, "Email address is too long.") + .result(); + + if (!result.ok()) { - return false; + return AuthStatus::failure(invalid_email_error()); } - const auto dot = email.find('.', at + 1); - return dot != std::string_view::npos && dot + 1 < email.size(); + return AuthStatus::success(); } - std::string Auth::make_user_id() const + AuthStatus Auth::validate_password_for_register( + std::string_view password) const { - return make_random_id("user"); + if (password.empty()) + { + return AuthStatus::failure( + invalid_password_error("Password cannot be empty.")); + } + + if (!password_hasher_.accepts(password)) + { + return AuthStatus::failure( + invalid_password_error( + "Password does not satisfy the configured password policy.")); + } + + return AuthStatus::success(); } - std::string Auth::make_session_id() const + std::int64_t Auth::now_seconds() const noexcept { - return make_random_id("session"); + return vix::time::SystemClock::now().seconds_since_epoch(); } - std::int64_t Auth::now_seconds() const noexcept + AuthResult Auth::make_user_id() const + { + return make_secure_id("user"); + } + + AuthResult Auth::make_session_id() const + { + return make_secure_id("session"); + } + + AuthResult Auth::make_token_value() const { - const auto now = std::chrono::system_clock::now(); - const auto seconds = std::chrono::duration_cast( - now.time_since_epoch()); + return make_secure_id("token"); + } + + AuthResult Auth::make_secure_id(std::string_view prefix) const + { + std::array bytes{}; + + auto random = vix::crypto::random_bytes(bytes); + + if (!random.ok()) + { + return AuthResult::failure( + crypto_error(std::string(random.error().message))); + } + + std::string out(prefix); + out.push_back('_'); + out += vix::crypto::hex_lower(bytes); + + return AuthResult::success(std::move(out)); + } + + Token Auth::make_token_for_user( + std::string user_id, + std::int64_t now, + std::string value) const + { + Token token{ + std::move(value), + std::move(user_id), + now, + now + config_.token_ttl_seconds()}; + + token.set_issuer(config_.issuer()); - return seconds.count(); + return token; } } // namespace rixlib::auth diff --git a/src/AuthConfig.cpp b/src/AuthConfig.cpp index 898a958..f91bd80 100644 --- a/src/AuthConfig.cpp +++ b/src/AuthConfig.cpp @@ -25,24 +25,44 @@ namespace rixlib::auth AuthConfig AuthConfig::development() { AuthConfig config; + config.set_min_password_length(8); + config.set_max_password_length(1024); + config.set_session_ttl_seconds(60 * 60 * 24 * 7); config.set_token_ttl_seconds(60 * 15); + + config.set_password_hash_iterations(310000); + config.set_password_salt_size(16); + config.set_password_hash_size(32); + config.set_issuer("rix/auth"); config.set_require_email_verification(false); config.set_rotate_sessions(true); + config.set_reject_inactive_users(true); + return config; } AuthConfig AuthConfig::production() { AuthConfig config; + config.set_min_password_length(12); + config.set_max_password_length(1024); + config.set_session_ttl_seconds(60 * 60 * 24 * 7); config.set_token_ttl_seconds(60 * 10); + + config.set_password_hash_iterations(310000); + config.set_password_salt_size(16); + config.set_password_hash_size(32); + config.set_issuer("rix/auth"); config.set_require_email_verification(true); config.set_rotate_sessions(true); + config.set_reject_inactive_users(true); + return config; } @@ -56,6 +76,16 @@ namespace rixlib::auth min_password_length_ = value; } + std::size_t AuthConfig::max_password_length() const noexcept + { + return max_password_length_; + } + + void AuthConfig::set_max_password_length(std::size_t value) noexcept + { + max_password_length_ = value; + } + std::int64_t AuthConfig::session_ttl_seconds() const noexcept { return session_ttl_seconds_; @@ -76,6 +106,36 @@ namespace rixlib::auth token_ttl_seconds_ = value; } + std::uint32_t AuthConfig::password_hash_iterations() const noexcept + { + return password_hash_iterations_; + } + + void AuthConfig::set_password_hash_iterations(std::uint32_t value) noexcept + { + password_hash_iterations_ = value; + } + + std::size_t AuthConfig::password_salt_size() const noexcept + { + return password_salt_size_; + } + + void AuthConfig::set_password_salt_size(std::size_t value) noexcept + { + password_salt_size_ = value; + } + + std::size_t AuthConfig::password_hash_size() const noexcept + { + return password_hash_size_; + } + + void AuthConfig::set_password_hash_size(std::size_t value) noexcept + { + password_hash_size_ = value; + } + const std::string &AuthConfig::issuer() const noexcept { return issuer_; @@ -105,4 +165,14 @@ namespace rixlib::auth { rotate_sessions_ = value; } + + bool AuthConfig::reject_inactive_users() const noexcept + { + return reject_inactive_users_; + } + + void AuthConfig::set_reject_inactive_users(bool value) noexcept + { + reject_inactive_users_ = value; + } } // namespace rixlib::auth diff --git a/src/AuthError.cpp b/src/AuthError.cpp index 4328d2b..b1dc530 100644 --- a/src/AuthError.cpp +++ b/src/AuthError.cpp @@ -21,18 +21,19 @@ namespace rixlib::auth { AuthError::AuthError(AuthErrorCode code, std::string message) - : code_(code), message_(std::move(message)) + : code_(code), + message_(std::move(message)) { } - bool AuthError::has_error() const noexcept + bool AuthError::ok() const noexcept { - return code_ != AuthErrorCode::None; + return code_ == AuthErrorCode::None; } - bool AuthError::ok() const noexcept + bool AuthError::has_error() const noexcept { - return code_ == AuthErrorCode::None; + return !ok(); } AuthErrorCode AuthError::code() const noexcept @@ -45,36 +46,60 @@ namespace rixlib::auth return message_; } + bool AuthError::is(AuthErrorCode code) const noexcept + { + return code_ == code; + } + std::string_view to_string(AuthErrorCode code) noexcept { switch (code) { case AuthErrorCode::None: return "None"; + case AuthErrorCode::InvalidInput: return "InvalidInput"; case AuthErrorCode::InvalidEmail: return "InvalidEmail"; case AuthErrorCode::InvalidPassword: return "InvalidPassword"; + case AuthErrorCode::UserNotFound: return "UserNotFound"; case AuthErrorCode::UserAlreadyExists: return "UserAlreadyExists"; + case AuthErrorCode::InvalidCredentials: return "InvalidCredentials"; + case AuthErrorCode::EmailVerificationRequired: + return "EmailVerificationRequired"; + case AuthErrorCode::UserDisabled: + return "UserDisabled"; + case AuthErrorCode::InvalidSession: return "InvalidSession"; case AuthErrorCode::SessionExpired: return "SessionExpired"; + case AuthErrorCode::SessionRevoked: + return "SessionRevoked"; + case AuthErrorCode::InvalidToken: return "InvalidToken"; case AuthErrorCode::TokenExpired: return "TokenExpired"; - case AuthErrorCode::InvalidState: - return "InvalidState"; + case AuthErrorCode::TokenRevoked: + return "TokenRevoked"; + case AuthErrorCode::StoreError: return "StoreError"; + case AuthErrorCode::CryptoError: + return "CryptoError"; + case AuthErrorCode::ValidationError: + return "ValidationError"; + case AuthErrorCode::ConfigurationError: + return "ConfigurationError"; + case AuthErrorCode::Unknown: return "Unknown"; } @@ -87,7 +112,9 @@ namespace rixlib::auth return AuthError{}; } - AuthError make_auth_error(AuthErrorCode code, std::string message) + AuthError make_auth_error( + AuthErrorCode code, + std::string message) { return AuthError{code, std::move(message)}; } diff --git a/src/PasswordHasher.cpp b/src/PasswordHasher.cpp index 51cc859..8a65fb0 100644 --- a/src/PasswordHasher.cpp +++ b/src/PasswordHasher.cpp @@ -15,49 +15,100 @@ */ #include - -#include -#include -#include +#include namespace rixlib::auth { namespace { - [[nodiscard]] std::string make_basic_hash(std::string_view password) + [[nodiscard]] AuthError make_password_error(std::string message) { - const auto value = std::hash{}(password); - - std::ostringstream stream; - stream << "rix-auth-basic$" << value; + return make_auth_error( + AuthErrorCode::InvalidPassword, + std::move(message)); + } - return stream.str(); + [[nodiscard]] AuthError make_crypto_error(std::string message) + { + return make_auth_error( + AuthErrorCode::CryptoError, + std::move(message)); } } // namespace - PasswordHasher::PasswordHasher() = default; + PasswordHasher::PasswordHasher() + : PasswordHasher(AuthConfig::development()) + { + } + + PasswordHasher::PasswordHasher(const AuthConfig &config) + : min_password_length_(config.min_password_length()), + max_password_length_(config.max_password_length()), + iterations_(config.password_hash_iterations()), + salt_size_(config.password_salt_size()), + hash_size_(config.password_hash_size()) + { + } AuthResult PasswordHasher::hash(std::string_view password) const { - if (!accepts(password)) + if (password.empty()) { return AuthResult::failure( - make_auth_error(AuthErrorCode::InvalidPassword, - "Password does not satisfy the minimum length policy.")); + make_password_error("Password cannot be empty.")); } - return AuthResult::success(make_basic_hash(password)); + if (password.size() < min_password_length_) + { + return AuthResult::failure( + make_password_error("Password does not satisfy the minimum length policy.")); + } + + if (password.size() > max_password_length_) + { + return AuthResult::failure( + make_password_error("Password exceeds the maximum length policy.")); + } + + vix::crypto::PasswordHashOptions options; + options.iterations = iterations_; + options.salt_size = salt_size_; + options.hash_size = hash_size_; + + auto hashed = vix::crypto::password_hash(password, options); + + if (!hashed.ok()) + { + return AuthResult::failure( + make_crypto_error(std::string(hashed.error().message))); + } + + return AuthResult::success(hashed.value()); } - bool PasswordHasher::verify(std::string_view password, - std::string_view password_hash) const + bool PasswordHasher::verify( + std::string_view password, + std::string_view password_hash) const { if (!accepts(password) || password_hash.empty()) { return false; } - return make_basic_hash(password) == password_hash; + auto verified = vix::crypto::password_verify(password, password_hash); + + if (!verified.ok()) + { + return false; + } + + return verified.value(); + } + + bool PasswordHasher::accepts(std::string_view password) const noexcept + { + return password.size() >= min_password_length_ && + password.size() <= max_password_length_; } std::size_t PasswordHasher::min_password_length() const noexcept @@ -70,8 +121,43 @@ namespace rixlib::auth min_password_length_ = value; } - bool PasswordHasher::accepts(std::string_view password) const noexcept + std::size_t PasswordHasher::max_password_length() const noexcept + { + return max_password_length_; + } + + void PasswordHasher::set_max_password_length(std::size_t value) noexcept + { + max_password_length_ = value; + } + + std::uint32_t PasswordHasher::iterations() const noexcept + { + return iterations_; + } + + void PasswordHasher::set_iterations(std::uint32_t value) noexcept + { + iterations_ = value; + } + + std::size_t PasswordHasher::salt_size() const noexcept + { + return salt_size_; + } + + void PasswordHasher::set_salt_size(std::size_t value) noexcept + { + salt_size_ = value; + } + + std::size_t PasswordHasher::hash_size() const noexcept + { + return hash_size_; + } + + void PasswordHasher::set_hash_size(std::size_t value) noexcept { - return password.size() >= min_password_length_; + hash_size_ = value; } } // namespace rixlib::auth diff --git a/src/Session.cpp b/src/Session.cpp index ed2bb03..047801e 100644 --- a/src/Session.cpp +++ b/src/Session.cpp @@ -97,6 +97,21 @@ namespace rixlib::auth return !id_.empty() && !user_id_.empty(); } + bool Session::has_id() const noexcept + { + return !id_.empty(); + } + + bool Session::has_user_id() const noexcept + { + return !user_id_.empty(); + } + + bool Session::has_id(std::string_view value) const noexcept + { + return id_ == value; + } + bool Session::belongs_to(std::string_view value) const noexcept { return user_id_ == value; @@ -111,4 +126,20 @@ namespace rixlib::auth { return valid() && !revoked_ && !expired(now); } + + bool Session::refreshable(std::int64_t now) const noexcept + { + return usable(now); + } + + void Session::refresh(std::int64_t now, std::int64_t ttl_seconds) noexcept + { + last_seen_at_ = now; + expires_at_ = now + ttl_seconds; + } + + void Session::revoke() noexcept + { + revoked_ = true; + } } // namespace rixlib::auth diff --git a/src/Token.cpp b/src/Token.cpp index 5c5edc9..bca2884 100644 --- a/src/Token.cpp +++ b/src/Token.cpp @@ -96,6 +96,16 @@ namespace rixlib::auth return !value_.empty() && !user_id_.empty(); } + bool Token::has_value() const noexcept + { + return !value_.empty(); + } + + bool Token::has_user_id() const noexcept + { + return !user_id_.empty(); + } + bool Token::belongs_to(std::string_view value) const noexcept { return user_id_ == value; @@ -106,6 +116,11 @@ namespace rixlib::auth return value_ == value; } + bool Token::issued_by(std::string_view value) const noexcept + { + return issuer_ == value; + } + bool Token::expired(std::int64_t now) const noexcept { return expires_at_ > 0 && now >= expires_at_; @@ -115,4 +130,9 @@ namespace rixlib::auth { return valid() && !revoked_ && !expired(now); } + + void Token::revoke() noexcept + { + revoked_ = true; + } } // namespace rixlib::auth diff --git a/src/User.cpp b/src/User.cpp index b6a601e..d0962c2 100644 --- a/src/User.cpp +++ b/src/User.cpp @@ -104,11 +104,33 @@ namespace rixlib::auth bool User::valid() const noexcept { - return !id_.empty() && !email_.empty(); + return !id_.empty() && + !email_.empty() && + !password_hash_.empty(); + } + + bool User::has_id() const noexcept + { + return !id_.empty(); + } + + bool User::has_email() const noexcept + { + return !email_.empty(); } bool User::has_email(std::string_view value) const noexcept { return email_ == value; } + + bool User::has_id(std::string_view value) const noexcept + { + return id_ == value; + } + + bool User::can_authenticate() const noexcept + { + return valid() && active_; + } } // namespace rixlib::auth diff --git a/src/stores/DbSessionStore.cpp b/src/stores/DbSessionStore.cpp new file mode 100644 index 0000000..6dbc663 --- /dev/null +++ b/src/stores/DbSessionStore.cpp @@ -0,0 +1,452 @@ +/** + * + * @file DbSessionStore.cpp + * @author Gaspard Kirira + * + * Copyright 2026, Gaspard Kirira. + * All rights reserved. + * https://github.com/rixcpp/auth + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Rix + * + */ + +#include + +#include +#include + +#include +#include +#include +#include + +namespace rixlib::auth +{ + DbSessionStore::DbSessionStore(vix::db::Database &database, bool create_schema) + : database_(&database) + { + if (create_schema) + { + (void)ensure_schema(); + } + } + + AuthStatus DbSessionStore::create(const Session &session) + { + if (!session.valid()) + { + return AuthStatus::failure(invalid_session_error()); + } + + try + { + auto exists = exists_by_id(session.id()); + + if (exists.failed()) + { + return AuthStatus::failure(exists.error()); + } + + if (exists.value()) + { + return AuthStatus::failure(session_already_exists_error()); + } + + database_->exec( + "INSERT INTO rix_auth_sessions " + "(id, user_id, created_at, expires_at, last_seen_at, revoked) " + "VALUES (?, ?, ?, ?, ?, ?)", + session.id(), + session.user_id(), + session.created_at(), + session.expires_at(), + session.last_seen_at(), + session.revoked()); + + return AuthStatus::success(); + } + catch (const std::exception &e) + { + return AuthStatus::failure(store_error(e.what())); + } + catch (...) + { + return AuthStatus::failure(store_error("Unknown database error.")); + } + } + + AuthStatus DbSessionStore::update(const Session &session) + { + if (!session.valid()) + { + return AuthStatus::failure(invalid_session_error()); + } + + try + { + auto exists = exists_by_id(session.id()); + + if (exists.failed()) + { + return AuthStatus::failure(exists.error()); + } + + if (!exists.value()) + { + return AuthStatus::failure(session_not_found_error()); + } + + database_->exec( + "UPDATE rix_auth_sessions " + "SET user_id = ?, " + "created_at = ?, " + "expires_at = ?, " + "last_seen_at = ?, " + "revoked = ? " + "WHERE id = ?", + session.user_id(), + session.created_at(), + session.expires_at(), + session.last_seen_at(), + session.revoked(), + session.id()); + + return AuthStatus::success(); + } + catch (const std::exception &e) + { + return AuthStatus::failure(store_error(e.what())); + } + catch (...) + { + return AuthStatus::failure(store_error("Unknown database error.")); + } + } + + AuthStatus DbSessionStore::remove_by_id(std::string_view id) + { + if (id.empty()) + { + return AuthStatus::failure(empty_session_id_error()); + } + + try + { + auto exists = exists_by_id(id); + + if (exists.failed()) + { + return AuthStatus::failure(exists.error()); + } + + if (!exists.value()) + { + return AuthStatus::failure(session_not_found_error()); + } + + database_->exec( + "DELETE FROM rix_auth_sessions WHERE id = ?", + std::string(id)); + + return AuthStatus::success(); + } + catch (const std::exception &e) + { + return AuthStatus::failure(store_error(e.what())); + } + catch (...) + { + return AuthStatus::failure(store_error("Unknown database error.")); + } + } + + AuthStatus DbSessionStore::revoke_by_id(std::string_view id) + { + if (id.empty()) + { + return AuthStatus::failure(empty_session_id_error()); + } + + try + { + auto exists = exists_by_id(id); + + if (exists.failed()) + { + return AuthStatus::failure(exists.error()); + } + + if (!exists.value()) + { + return AuthStatus::failure(session_not_found_error()); + } + + database_->exec( + "UPDATE rix_auth_sessions " + "SET revoked = 1 " + "WHERE id = ?", + std::string(id)); + + return AuthStatus::success(); + } + catch (const std::exception &e) + { + return AuthStatus::failure(store_error(e.what())); + } + catch (...) + { + return AuthStatus::failure(store_error("Unknown database error.")); + } + } + + AuthStatus DbSessionStore::revoke_by_user_id(std::string_view user_id) + { + if (user_id.empty()) + { + return AuthStatus::failure(empty_user_id_error()); + } + + try + { + database_->exec( + "UPDATE rix_auth_sessions " + "SET revoked = 1 " + "WHERE user_id = ?", + std::string(user_id)); + + return AuthStatus::success(); + } + catch (const std::exception &e) + { + return AuthStatus::failure(store_error(e.what())); + } + catch (...) + { + return AuthStatus::failure(store_error("Unknown database error.")); + } + } + + AuthResult> + DbSessionStore::find_by_id(std::string_view id) const + { + if (id.empty()) + { + return AuthResult>::failure( + empty_session_id_error()); + } + + try + { + auto result = database_->query( + "SELECT id, user_id, created_at, expires_at, last_seen_at, revoked " + "FROM rix_auth_sessions " + "WHERE id = ? " + "LIMIT 1", + std::string(id)); + + if (!result->next()) + { + return AuthResult>::success(std::nullopt); + } + + return AuthResult>::success( + row_to_session(result->row())); + } + catch (const std::exception &e) + { + return AuthResult>::failure(store_error(e.what())); + } + catch (...) + { + return AuthResult>::failure( + store_error("Unknown database error.")); + } + } + + AuthResult> + DbSessionStore::find_by_user_id(std::string_view user_id) const + { + if (user_id.empty()) + { + return AuthResult>::failure( + empty_user_id_error()); + } + + try + { + auto result = database_->query( + "SELECT id, user_id, created_at, expires_at, last_seen_at, revoked " + "FROM rix_auth_sessions " + "WHERE user_id = ? " + "ORDER BY created_at DESC", + std::string(user_id)); + + std::vector sessions; + + while (result->next()) + { + sessions.push_back(row_to_session(result->row())); + } + + return AuthResult>::success(std::move(sessions)); + } + catch (const std::exception &e) + { + return AuthResult>::failure(store_error(e.what())); + } + catch (...) + { + return AuthResult>::failure( + store_error("Unknown database error.")); + } + } + + AuthResult + DbSessionStore::exists_by_id(std::string_view id) const + { + if (id.empty()) + { + return AuthResult::failure(empty_session_id_error()); + } + + try + { + auto result = database_->query( + "SELECT id FROM rix_auth_sessions WHERE id = ? LIMIT 1", + std::string(id)); + + return AuthResult::success(result->next()); + } + catch (const std::exception &e) + { + return AuthResult::failure(store_error(e.what())); + } + catch (...) + { + return AuthResult::failure(store_error("Unknown database error.")); + } + } + + AuthResult> DbSessionStore::all() const + { + try + { + auto result = database_->query( + "SELECT id, user_id, created_at, expires_at, last_seen_at, revoked " + "FROM rix_auth_sessions " + "ORDER BY created_at DESC"); + + std::vector sessions; + + while (result->next()) + { + sessions.push_back(row_to_session(result->row())); + } + + return AuthResult>::success(std::move(sessions)); + } + catch (const std::exception &e) + { + return AuthResult>::failure(store_error(e.what())); + } + catch (...) + { + return AuthResult>::failure( + store_error("Unknown database error.")); + } + } + + AuthStatus DbSessionStore::ensure_schema() + { + try + { + database_->exec( + "CREATE TABLE IF NOT EXISTS rix_auth_sessions (" + "id VARCHAR(128) NOT NULL PRIMARY KEY," + "user_id VARCHAR(128) NOT NULL," + "created_at BIGINT NOT NULL," + "expires_at BIGINT NOT NULL," + "last_seen_at BIGINT NOT NULL," + "revoked INTEGER NOT NULL DEFAULT 0" + ")"); + + database_->exec( + "CREATE INDEX IF NOT EXISTS idx_rix_auth_sessions_user_id " + "ON rix_auth_sessions (user_id)"); + + database_->exec( + "CREATE INDEX IF NOT EXISTS idx_rix_auth_sessions_expires_at " + "ON rix_auth_sessions (expires_at)"); + + return AuthStatus::success(); + } + catch (const std::exception &e) + { + return AuthStatus::failure(store_error(e.what())); + } + catch (...) + { + return AuthStatus::failure(store_error("Unknown database error.")); + } + } + + Session DbSessionStore::row_to_session(const vix::db::ResultRow &row) + { + Session session{ + row.getString(0), + row.getString(1), + row.getInt64(2), + row.getInt64(3)}; + + session.set_last_seen_at(row.getInt64(4)); + session.set_revoked(row.getInt64(5) != 0); + + return session; + } + + AuthError DbSessionStore::store_error(std::string message) + { + return make_auth_error( + AuthErrorCode::StoreError, + std::move(message)); + } + + AuthError DbSessionStore::invalid_session_error() + { + return make_auth_error( + AuthErrorCode::InvalidSession, + "Session is invalid."); + } + + AuthError DbSessionStore::empty_session_id_error() + { + return make_auth_error( + AuthErrorCode::InvalidSession, + "Session id cannot be empty."); + } + + AuthError DbSessionStore::empty_user_id_error() + { + return make_auth_error( + AuthErrorCode::InvalidInput, + "User id cannot be empty."); + } + + AuthError DbSessionStore::session_not_found_error() + { + return make_auth_error( + AuthErrorCode::InvalidSession, + "Session not found."); + } + + AuthError DbSessionStore::session_already_exists_error() + { + return make_auth_error( + AuthErrorCode::InvalidSession, + "Session already exists."); + } +} // namespace rixlib::auth diff --git a/src/stores/DbUserStore.cpp b/src/stores/DbUserStore.cpp new file mode 100644 index 0000000..a6d05f9 --- /dev/null +++ b/src/stores/DbUserStore.cpp @@ -0,0 +1,425 @@ +/** + * + * @file DbUserStore.cpp + * @author Gaspard Kirira + * + * Copyright 2026, Gaspard Kirira. + * All rights reserved. + * https://github.com/rixcpp/auth + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Rix + * + */ + +#include + +#include +#include + +#include +#include +#include +#include + +namespace rixlib::auth +{ + DbUserStore::DbUserStore(vix::db::Database &database, bool create_schema) + : database_(&database) + { + if (create_schema) + { + (void)ensure_schema(); + } + } + + AuthStatus DbUserStore::create(const User &user) + { + if (!user.valid()) + { + return AuthStatus::failure(invalid_user_error()); + } + + try + { + auto exists_id = exists_by_id(user.id()); + if (exists_id.failed()) + { + return AuthStatus::failure(exists_id.error()); + } + + if (exists_id.value()) + { + return AuthStatus::failure(user_already_exists_error()); + } + + auto exists_email = exists_by_email(user.email()); + if (exists_email.failed()) + { + return AuthStatus::failure(exists_email.error()); + } + + if (exists_email.value()) + { + return AuthStatus::failure(user_already_exists_error()); + } + + database_->exec( + "INSERT INTO rix_auth_users " + "(id, email, password_hash, email_verified, active, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + user.id(), + user.email(), + user.password_hash(), + user.email_verified(), + user.active(), + user.created_at(), + user.updated_at()); + + return AuthStatus::success(); + } + catch (const std::exception &e) + { + return AuthStatus::failure(store_error(e.what())); + } + catch (...) + { + return AuthStatus::failure(store_error("Unknown database error.")); + } + } + + AuthStatus DbUserStore::update(const User &user) + { + if (!user.valid()) + { + return AuthStatus::failure(invalid_user_error()); + } + + try + { + auto existing = find_by_id(user.id()); + if (existing.failed()) + { + return AuthStatus::failure(existing.error()); + } + + if (!existing.value().has_value()) + { + return AuthStatus::failure(user_not_found_error()); + } + + auto owner = find_by_email(user.email()); + if (owner.failed()) + { + return AuthStatus::failure(owner.error()); + } + + if (owner.value().has_value() && !owner.value()->has_id(user.id())) + { + return AuthStatus::failure(user_already_exists_error()); + } + + database_->exec( + "UPDATE rix_auth_users " + "SET email = ?, " + "password_hash = ?, " + "email_verified = ?, " + "active = ?, " + "created_at = ?, " + "updated_at = ? " + "WHERE id = ?", + user.email(), + user.password_hash(), + user.email_verified(), + user.active(), + user.created_at(), + user.updated_at(), + user.id()); + + return AuthStatus::success(); + } + catch (const std::exception &e) + { + return AuthStatus::failure(store_error(e.what())); + } + catch (...) + { + return AuthStatus::failure(store_error("Unknown database error.")); + } + } + + AuthStatus DbUserStore::remove_by_id(std::string_view id) + { + if (id.empty()) + { + return AuthStatus::failure(empty_id_error()); + } + + try + { + auto existing = exists_by_id(id); + if (existing.failed()) + { + return AuthStatus::failure(existing.error()); + } + + if (!existing.value()) + { + return AuthStatus::failure(user_not_found_error()); + } + + database_->exec( + "DELETE FROM rix_auth_users WHERE id = ?", + std::string(id)); + + return AuthStatus::success(); + } + catch (const std::exception &e) + { + return AuthStatus::failure(store_error(e.what())); + } + catch (...) + { + return AuthStatus::failure(store_error("Unknown database error.")); + } + } + + AuthResult> + DbUserStore::find_by_id(std::string_view id) const + { + if (id.empty()) + { + return AuthResult>::failure(empty_id_error()); + } + + try + { + auto result = database_->query( + "SELECT id, email, password_hash, email_verified, active, created_at, updated_at " + "FROM rix_auth_users " + "WHERE id = ? " + "LIMIT 1", + std::string(id)); + + if (!result->next()) + { + return AuthResult>::success(std::nullopt); + } + + return AuthResult>::success( + row_to_user(result->row())); + } + catch (const std::exception &e) + { + return AuthResult>::failure(store_error(e.what())); + } + catch (...) + { + return AuthResult>::failure( + store_error("Unknown database error.")); + } + } + + AuthResult> + DbUserStore::find_by_email(std::string_view email) const + { + if (email.empty()) + { + return AuthResult>::failure(empty_email_error()); + } + + try + { + auto result = database_->query( + "SELECT id, email, password_hash, email_verified, active, created_at, updated_at " + "FROM rix_auth_users " + "WHERE email = ? " + "LIMIT 1", + std::string(email)); + + if (!result->next()) + { + return AuthResult>::success(std::nullopt); + } + + return AuthResult>::success( + row_to_user(result->row())); + } + catch (const std::exception &e) + { + return AuthResult>::failure(store_error(e.what())); + } + catch (...) + { + return AuthResult>::failure( + store_error("Unknown database error.")); + } + } + + AuthResult + DbUserStore::exists_by_id(std::string_view id) const + { + if (id.empty()) + { + return AuthResult::failure(empty_id_error()); + } + + try + { + auto result = database_->query( + "SELECT id FROM rix_auth_users WHERE id = ? LIMIT 1", + std::string(id)); + + return AuthResult::success(result->next()); + } + catch (const std::exception &e) + { + return AuthResult::failure(store_error(e.what())); + } + catch (...) + { + return AuthResult::failure(store_error("Unknown database error.")); + } + } + + AuthResult + DbUserStore::exists_by_email(std::string_view email) const + { + if (email.empty()) + { + return AuthResult::failure(empty_email_error()); + } + + try + { + auto result = database_->query( + "SELECT id FROM rix_auth_users WHERE email = ? LIMIT 1", + std::string(email)); + + return AuthResult::success(result->next()); + } + catch (const std::exception &e) + { + return AuthResult::failure(store_error(e.what())); + } + catch (...) + { + return AuthResult::failure(store_error("Unknown database error.")); + } + } + + AuthResult> DbUserStore::all() const + { + try + { + auto result = database_->query( + "SELECT id, email, password_hash, email_verified, active, created_at, updated_at " + "FROM rix_auth_users " + "ORDER BY created_at ASC"); + + std::vector users; + + while (result->next()) + { + users.push_back(row_to_user(result->row())); + } + + return AuthResult>::success(std::move(users)); + } + catch (const std::exception &e) + { + return AuthResult>::failure(store_error(e.what())); + } + catch (...) + { + return AuthResult>::failure( + store_error("Unknown database error.")); + } + } + + AuthStatus DbUserStore::ensure_schema() + { + try + { + database_->exec( + "CREATE TABLE IF NOT EXISTS rix_auth_users (" + "id VARCHAR(128) NOT NULL PRIMARY KEY," + "email VARCHAR(320) NOT NULL UNIQUE," + "password_hash TEXT NOT NULL," + "email_verified INTEGER NOT NULL DEFAULT 0," + "active INTEGER NOT NULL DEFAULT 1," + "created_at BIGINT NOT NULL," + "updated_at BIGINT NOT NULL" + ")"); + + return AuthStatus::success(); + } + catch (const std::exception &e) + { + return AuthStatus::failure(store_error(e.what())); + } + catch (...) + { + return AuthStatus::failure(store_error("Unknown database error.")); + } + } + + User DbUserStore::row_to_user(const vix::db::ResultRow &row) + { + User user{ + row.getString(0), + row.getString(1), + row.getString(2), + row.getInt64(5)}; + + user.set_email_verified(row.getInt64(3) != 0); + user.set_active(row.getInt64(4) != 0); + user.set_updated_at(row.getInt64(6)); + + return user; + } + + AuthError DbUserStore::store_error(std::string message) + { + return make_auth_error( + AuthErrorCode::StoreError, + std::move(message)); + } + + AuthError DbUserStore::invalid_user_error() + { + return make_auth_error( + AuthErrorCode::InvalidInput, + "User is invalid."); + } + + AuthError DbUserStore::empty_id_error() + { + return make_auth_error( + AuthErrorCode::InvalidInput, + "User id cannot be empty."); + } + + AuthError DbUserStore::empty_email_error() + { + return make_auth_error( + AuthErrorCode::InvalidEmail, + "User email cannot be empty."); + } + + AuthError DbUserStore::user_not_found_error() + { + return make_auth_error( + AuthErrorCode::UserNotFound, + "User not found."); + } + + AuthError DbUserStore::user_already_exists_error() + { + return make_auth_error( + AuthErrorCode::UserAlreadyExists, + "User already exists."); + } +} // namespace rixlib::auth diff --git a/src/stores/MemorySessionStore.cpp b/src/stores/MemorySessionStore.cpp new file mode 100644 index 0000000..e7a4231 --- /dev/null +++ b/src/stores/MemorySessionStore.cpp @@ -0,0 +1,318 @@ +/** + * + * @file MemorySessionStore.cpp + * @author Gaspard Kirira + * + * Copyright 2026, Gaspard Kirira. + * All rights reserved. + * https://github.com/rixcpp/auth + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Rix + * + */ + +#include + +#include +#include + +namespace rixlib::auth +{ + namespace + { + [[nodiscard]] AuthError invalid_session_error() + { + return make_auth_error( + AuthErrorCode::InvalidSession, + "Session is invalid."); + } + + [[nodiscard]] AuthError empty_session_id_error() + { + return make_auth_error( + AuthErrorCode::InvalidSession, + "Session id cannot be empty."); + } + + [[nodiscard]] AuthError empty_user_id_error() + { + return make_auth_error( + AuthErrorCode::InvalidInput, + "User id cannot be empty."); + } + + [[nodiscard]] AuthError session_not_found_error() + { + return make_auth_error( + AuthErrorCode::InvalidSession, + "Session not found."); + } + + [[nodiscard]] AuthError session_already_exists_error() + { + return make_auth_error( + AuthErrorCode::InvalidSession, + "Session already exists."); + } + } // namespace + + AuthStatus MemorySessionStore::create(const Session &session) + { + if (!session.valid()) + { + return AuthStatus::failure(invalid_session_error()); + } + + std::lock_guard lock(mutex_); + + const std::string id_key = key(session.id()); + + if (sessions_by_id_.find(id_key) != sessions_by_id_.end()) + { + return AuthStatus::failure(session_already_exists_error()); + } + + sessions_by_id_.emplace(id_key, session); + index_session_locked(session); + + return AuthStatus::success(); + } + + AuthStatus MemorySessionStore::update(const Session &session) + { + if (!session.valid()) + { + return AuthStatus::failure(invalid_session_error()); + } + + std::lock_guard lock(mutex_); + + const std::string id_key = key(session.id()); + const auto existing = sessions_by_id_.find(id_key); + + if (existing == sessions_by_id_.end()) + { + return AuthStatus::failure(session_not_found_error()); + } + + unindex_session_locked(existing->second); + + existing->second = session; + + index_session_locked(existing->second); + + return AuthStatus::success(); + } + + AuthStatus MemorySessionStore::remove_by_id(std::string_view id) + { + if (id.empty()) + { + return AuthStatus::failure(empty_session_id_error()); + } + + std::lock_guard lock(mutex_); + + const std::string id_key = key(id); + const auto existing = sessions_by_id_.find(id_key); + + if (existing == sessions_by_id_.end()) + { + return AuthStatus::failure(session_not_found_error()); + } + + unindex_session_locked(existing->second); + sessions_by_id_.erase(existing); + + return AuthStatus::success(); + } + + AuthStatus MemorySessionStore::revoke_by_id(std::string_view id) + { + if (id.empty()) + { + return AuthStatus::failure(empty_session_id_error()); + } + + std::lock_guard lock(mutex_); + + const auto existing = sessions_by_id_.find(key(id)); + + if (existing == sessions_by_id_.end()) + { + return AuthStatus::failure(session_not_found_error()); + } + + existing->second.revoke(); + + return AuthStatus::success(); + } + + AuthStatus MemorySessionStore::revoke_by_user_id(std::string_view user_id) + { + if (user_id.empty()) + { + return AuthStatus::failure(empty_user_id_error()); + } + + std::lock_guard lock(mutex_); + + const auto index_it = ids_by_user_id_.find(key(user_id)); + + if (index_it == ids_by_user_id_.end()) + { + return AuthStatus::success(); + } + + for (const auto &session_id : index_it->second) + { + const auto session_it = sessions_by_id_.find(session_id); + + if (session_it != sessions_by_id_.end()) + { + session_it->second.revoke(); + } + } + + return AuthStatus::success(); + } + + AuthResult> + MemorySessionStore::find_by_id(std::string_view id) const + { + if (id.empty()) + { + return AuthResult>::failure( + empty_session_id_error()); + } + + std::lock_guard lock(mutex_); + + const auto it = sessions_by_id_.find(key(id)); + + if (it == sessions_by_id_.end()) + { + return AuthResult>::success(std::nullopt); + } + + return AuthResult>::success(it->second); + } + + AuthResult> + MemorySessionStore::find_by_user_id(std::string_view user_id) const + { + if (user_id.empty()) + { + return AuthResult>::failure( + empty_user_id_error()); + } + + std::lock_guard lock(mutex_); + + std::vector sessions; + + const auto index_it = ids_by_user_id_.find(key(user_id)); + + if (index_it == ids_by_user_id_.end()) + { + return AuthResult>::success(std::move(sessions)); + } + + sessions.reserve(index_it->second.size()); + + for (const auto &session_id : index_it->second) + { + const auto session_it = sessions_by_id_.find(session_id); + + if (session_it != sessions_by_id_.end()) + { + sessions.push_back(session_it->second); + } + } + + return AuthResult>::success(std::move(sessions)); + } + + AuthResult + MemorySessionStore::exists_by_id(std::string_view id) const + { + if (id.empty()) + { + return AuthResult::failure(empty_session_id_error()); + } + + std::lock_guard lock(mutex_); + + return AuthResult::success( + sessions_by_id_.find(key(id)) != sessions_by_id_.end()); + } + + AuthResult> MemorySessionStore::all() const + { + std::lock_guard lock(mutex_); + + std::vector sessions; + sessions.reserve(sessions_by_id_.size()); + + for (const auto &[id, session] : sessions_by_id_) + { + (void)id; + sessions.push_back(session); + } + + return AuthResult>::success(std::move(sessions)); + } + + void MemorySessionStore::clear() + { + std::lock_guard lock(mutex_); + + sessions_by_id_.clear(); + ids_by_user_id_.clear(); + } + + std::size_t MemorySessionStore::size() const + { + std::lock_guard lock(mutex_); + + return sessions_by_id_.size(); + } + + bool MemorySessionStore::empty() const + { + return size() == 0; + } + + std::string MemorySessionStore::key(std::string_view value) + { + return std::string(value); + } + + void MemorySessionStore::index_session_locked(const Session &session) + { + ids_by_user_id_[key(session.user_id())].insert(key(session.id())); + } + + void MemorySessionStore::unindex_session_locked(const Session &session) + { + const std::string user_key = key(session.user_id()); + const std::string id_key = key(session.id()); + + const auto index_it = ids_by_user_id_.find(user_key); + + if (index_it == ids_by_user_id_.end()) + { + return; + } + + index_it->second.erase(id_key); + + if (index_it->second.empty()) + { + ids_by_user_id_.erase(index_it); + } + } +} // namespace rixlib::auth diff --git a/src/stores/MemoryUserStore.cpp b/src/stores/MemoryUserStore.cpp new file mode 100644 index 0000000..b666193 --- /dev/null +++ b/src/stores/MemoryUserStore.cpp @@ -0,0 +1,261 @@ +/** + * + * @file MemoryUserStore.cpp + * @author Gaspard Kirira + * + * Copyright 2026, Gaspard Kirira. + * All rights reserved. + * https://github.com/rixcpp/auth + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Rix + * + */ + +#include + +#include + +namespace rixlib::auth +{ + namespace + { + [[nodiscard]] AuthError invalid_user_error() + { + return make_auth_error( + AuthErrorCode::InvalidInput, + "User is invalid."); + } + + [[nodiscard]] AuthError empty_id_error() + { + return make_auth_error( + AuthErrorCode::InvalidInput, + "User id cannot be empty."); + } + + [[nodiscard]] AuthError empty_email_error() + { + return make_auth_error( + AuthErrorCode::InvalidEmail, + "User email cannot be empty."); + } + + [[nodiscard]] AuthError user_not_found_error() + { + return make_auth_error( + AuthErrorCode::UserNotFound, + "User not found."); + } + + [[nodiscard]] AuthError user_already_exists_error() + { + return make_auth_error( + AuthErrorCode::UserAlreadyExists, + "User already exists."); + } + } // namespace + + AuthStatus MemoryUserStore::create(const User &user) + { + if (!user.valid()) + { + return AuthStatus::failure(invalid_user_error()); + } + + std::lock_guard lock(mutex_); + + const std::string id_key = key(user.id()); + const std::string email_key = key(user.email()); + + if (users_by_id_.find(id_key) != users_by_id_.end()) + { + return AuthStatus::failure(user_already_exists_error()); + } + + if (id_by_email_.find(email_key) != id_by_email_.end()) + { + return AuthStatus::failure(user_already_exists_error()); + } + + users_by_id_.emplace(id_key, user); + id_by_email_.emplace(email_key, id_key); + + return AuthStatus::success(); + } + + AuthStatus MemoryUserStore::update(const User &user) + { + if (!user.valid()) + { + return AuthStatus::failure(invalid_user_error()); + } + + std::lock_guard lock(mutex_); + + const std::string id_key = key(user.id()); + const auto existing = users_by_id_.find(id_key); + + if (existing == users_by_id_.end()) + { + return AuthStatus::failure(user_not_found_error()); + } + + const std::string old_email_key = key(existing->second.email()); + const std::string new_email_key = key(user.email()); + + const auto email_owner = id_by_email_.find(new_email_key); + + if (email_owner != id_by_email_.end() && email_owner->second != id_key) + { + return AuthStatus::failure(user_already_exists_error()); + } + + id_by_email_.erase(old_email_key); + id_by_email_[new_email_key] = id_key; + existing->second = user; + + return AuthStatus::success(); + } + + AuthStatus MemoryUserStore::remove_by_id(std::string_view id) + { + if (id.empty()) + { + return AuthStatus::failure(empty_id_error()); + } + + std::lock_guard lock(mutex_); + + const std::string id_key = key(id); + const auto existing = users_by_id_.find(id_key); + + if (existing == users_by_id_.end()) + { + return AuthStatus::failure(user_not_found_error()); + } + + id_by_email_.erase(key(existing->second.email())); + users_by_id_.erase(existing); + + return AuthStatus::success(); + } + + AuthResult> + MemoryUserStore::find_by_id(std::string_view id) const + { + if (id.empty()) + { + return AuthResult>::failure(empty_id_error()); + } + + std::lock_guard lock(mutex_); + + const auto it = users_by_id_.find(key(id)); + + if (it == users_by_id_.end()) + { + return AuthResult>::success(std::nullopt); + } + + return AuthResult>::success(it->second); + } + + AuthResult> + MemoryUserStore::find_by_email(std::string_view email) const + { + if (email.empty()) + { + return AuthResult>::failure(empty_email_error()); + } + + std::lock_guard lock(mutex_); + + const auto email_it = id_by_email_.find(key(email)); + + if (email_it == id_by_email_.end()) + { + return AuthResult>::success(std::nullopt); + } + + const auto user_it = users_by_id_.find(email_it->second); + + if (user_it == users_by_id_.end()) + { + return AuthResult>::success(std::nullopt); + } + + return AuthResult>::success(user_it->second); + } + + AuthResult + MemoryUserStore::exists_by_id(std::string_view id) const + { + if (id.empty()) + { + return AuthResult::failure(empty_id_error()); + } + + std::lock_guard lock(mutex_); + + return AuthResult::success( + users_by_id_.find(key(id)) != users_by_id_.end()); + } + + AuthResult + MemoryUserStore::exists_by_email(std::string_view email) const + { + if (email.empty()) + { + return AuthResult::failure(empty_email_error()); + } + + std::lock_guard lock(mutex_); + + return AuthResult::success( + id_by_email_.find(key(email)) != id_by_email_.end()); + } + + AuthResult> MemoryUserStore::all() const + { + std::lock_guard lock(mutex_); + + std::vector users; + users.reserve(users_by_id_.size()); + + for (const auto &[id, user] : users_by_id_) + { + (void)id; + users.push_back(user); + } + + return AuthResult>::success(std::move(users)); + } + + void MemoryUserStore::clear() + { + std::lock_guard lock(mutex_); + + users_by_id_.clear(); + id_by_email_.clear(); + } + + std::size_t MemoryUserStore::size() const + { + std::lock_guard lock(mutex_); + + return users_by_id_.size(); + } + + bool MemoryUserStore::empty() const + { + return size() == 0; + } + + std::string MemoryUserStore::key(std::string_view value) + { + return std::string(value); + } +} // namespace rixlib::auth diff --git a/tests/AuthTests.cpp b/tests/AuthTests.cpp index 9b271d1..6049c41 100644 --- a/tests/AuthTests.cpp +++ b/tests/AuthTests.cpp @@ -15,515 +15,635 @@ */ #include +#include +#include +#include +#include -#include -#include -#include -#include -#include -#include +#include namespace { - class MemoryUserStore final : public rixlib::auth::UserStore + using rixlib::auth::Auth; + using rixlib::auth::AuthConfig; + using rixlib::auth::AuthErrorCode; + using rixlib::auth::LoginRequest; + using rixlib::auth::MemorySessionStore; + using rixlib::auth::MemoryUserStore; + using rixlib::auth::RegisterRequest; + using rixlib::auth::User; + + [[nodiscard]] AuthConfig test_config() { - public: - [[nodiscard]] rixlib::auth::AuthStatus create( - const rixlib::auth::User &user) override - { - if (!user.valid()) - { - return rixlib::auth::AuthStatus::failure( - rixlib::auth::make_auth_error( - rixlib::auth::AuthErrorCode::InvalidInput, - "User is invalid.")); - } - - const auto exists = std::any_of( - users_.begin(), - users_.end(), - [&](const rixlib::auth::User &stored) - { - return stored.id() == user.id() || stored.email() == user.email(); - }); - - if (exists) - { - return rixlib::auth::AuthStatus::failure( - rixlib::auth::make_auth_error( - rixlib::auth::AuthErrorCode::UserAlreadyExists, - "User already exists.")); - } - - users_.push_back(user); - return rixlib::auth::AuthStatus::success(); - } + AuthConfig config = AuthConfig::development(); + config.set_min_password_length(8); + config.set_session_ttl_seconds(3600); + config.set_token_ttl_seconds(600); + config.set_issuer("rix/auth/tests"); + config.set_require_email_verification(false); + config.set_rotate_sessions(true); + return config; + } - [[nodiscard]] rixlib::auth::AuthStatus update( - const rixlib::auth::User &user) override - { - for (rixlib::auth::User &stored : users_) - { - if (stored.id() == user.id()) - { - stored = user; - return rixlib::auth::AuthStatus::success(); - } - } - - return rixlib::auth::AuthStatus::failure( - rixlib::auth::make_auth_error( - rixlib::auth::AuthErrorCode::UserNotFound, - "User not found.")); - } + struct AuthFixture + { + MemoryUserStore users; + MemorySessionStore sessions; + Auth auth; - [[nodiscard]] rixlib::auth::AuthStatus remove_by_id( - std::string_view id) override + AuthFixture() + : users(), + sessions(), + auth(users, sessions, test_config()) { - const auto old_size = users_.size(); - - users_.erase( - std::remove_if( - users_.begin(), - users_.end(), - [&](const rixlib::auth::User &user) - { - return user.id() == id; - }), - users_.end()); - - if (users_.size() == old_size) - { - return rixlib::auth::AuthStatus::failure( - rixlib::auth::make_auth_error( - rixlib::auth::AuthErrorCode::UserNotFound, - "User not found.")); - } - - return rixlib::auth::AuthStatus::success(); } + }; - [[nodiscard]] rixlib::auth::AuthResult> - find_by_id(std::string_view id) const override - { - for (const rixlib::auth::User &user : users_) - { - if (user.id() == id) - { - return rixlib::auth::AuthResult>::success(user); - } - } - - return rixlib::auth::AuthResult>::success(std::nullopt); - } + TEST(AuthTests, RegisterUserCreatesValidUser) + { + AuthFixture fixture; - [[nodiscard]] rixlib::auth::AuthResult> - find_by_email(std::string_view email) const override - { - for (const rixlib::auth::User &user : users_) - { - if (user.email() == email) - { - return rixlib::auth::AuthResult>::success(user); - } - } - - return rixlib::auth::AuthResult>::success(std::nullopt); - } + const auto result = fixture.auth.register_user( + RegisterRequest{ + "ada@example.com", + "correct-password"}); + + ASSERT_TRUE(result.ok()); + + const User &user = result.value(); + + EXPECT_TRUE(user.valid()); + EXPECT_FALSE(user.id().empty()); + EXPECT_EQ(user.email(), "ada@example.com"); + EXPECT_FALSE(user.password_hash().empty()); + EXPECT_NE(user.password_hash(), "correct-password"); + EXPECT_TRUE(user.email_verified()); + EXPECT_TRUE(user.active()); + EXPECT_GT(user.created_at(), 0); + EXPECT_GT(user.updated_at(), 0); + EXPECT_EQ(fixture.users.size(), 1); + } - [[nodiscard]] rixlib::auth::AuthResult - exists_by_id(std::string_view id) const override - { - const auto exists = std::any_of( - users_.begin(), - users_.end(), - [&](const rixlib::auth::User &user) - { - return user.id() == id; - }); - - return rixlib::auth::AuthResult::success(exists); - } + TEST(AuthTests, RegisterUserPersistsUserInStore) + { + AuthFixture fixture; - [[nodiscard]] rixlib::auth::AuthResult - exists_by_email(std::string_view email) const override - { - const auto exists = std::any_of( - users_.begin(), - users_.end(), - [&](const rixlib::auth::User &user) - { - return user.email() == email; - }); - - return rixlib::auth::AuthResult::success(exists); - } + const auto registered = fixture.auth.register_user( + RegisterRequest{ + "ada@example.com", + "correct-password"}); - [[nodiscard]] rixlib::auth::AuthResult> - all() const override - { - return rixlib::auth::AuthResult>::success(users_); - } + ASSERT_TRUE(registered.ok()); - private: - std::vector users_; - }; + const auto found = fixture.users.find_by_email("ada@example.com"); + + ASSERT_TRUE(found.ok()); + ASSERT_TRUE(found.value().has_value()); + + EXPECT_EQ(found.value()->id(), registered.value().id()); + EXPECT_EQ(found.value()->email(), "ada@example.com"); + } - class MemorySessionStore final : public rixlib::auth::SessionStore + TEST(AuthTests, RegisterUserRejectsInvalidEmail) { - public: - [[nodiscard]] rixlib::auth::AuthStatus create( - const rixlib::auth::Session &session) override - { - if (!session.valid()) - { - return rixlib::auth::AuthStatus::failure( - rixlib::auth::make_auth_error( - rixlib::auth::AuthErrorCode::InvalidInput, - "Session is invalid.")); - } - - const auto exists = std::any_of( - sessions_.begin(), - sessions_.end(), - [&](const rixlib::auth::Session &stored) - { - return stored.id() == session.id(); - }); - - if (exists) - { - return rixlib::auth::AuthStatus::failure( - rixlib::auth::make_auth_error( - rixlib::auth::AuthErrorCode::InvalidSession, - "Session already exists.")); - } - - sessions_.push_back(session); - return rixlib::auth::AuthStatus::success(); - } + AuthFixture fixture; - [[nodiscard]] rixlib::auth::AuthStatus update( - const rixlib::auth::Session &session) override - { - for (rixlib::auth::Session &stored : sessions_) - { - if (stored.id() == session.id()) - { - stored = session; - return rixlib::auth::AuthStatus::success(); - } - } - - return rixlib::auth::AuthStatus::failure( - rixlib::auth::make_auth_error( - rixlib::auth::AuthErrorCode::InvalidSession, - "Session not found.")); - } + const auto result = fixture.auth.register_user( + RegisterRequest{ + "invalid-email", + "correct-password"}); - [[nodiscard]] rixlib::auth::AuthStatus remove_by_id( - std::string_view id) override - { - const auto old_size = sessions_.size(); - - sessions_.erase( - std::remove_if( - sessions_.begin(), - sessions_.end(), - [&](const rixlib::auth::Session &session) - { - return session.id() == id; - }), - sessions_.end()); - - if (sessions_.size() == old_size) - { - return rixlib::auth::AuthStatus::failure( - rixlib::auth::make_auth_error( - rixlib::auth::AuthErrorCode::InvalidSession, - "Session not found.")); - } - - return rixlib::auth::AuthStatus::success(); - } + ASSERT_TRUE(result.failed()); + EXPECT_EQ(result.error().code(), AuthErrorCode::InvalidEmail); + EXPECT_TRUE(fixture.users.empty()); + } - [[nodiscard]] rixlib::auth::AuthStatus revoke_by_id( - std::string_view id) override - { - for (rixlib::auth::Session &session : sessions_) - { - if (session.id() == id) - { - session.set_revoked(true); - return rixlib::auth::AuthStatus::success(); - } - } - - return rixlib::auth::AuthStatus::failure( - rixlib::auth::make_auth_error( - rixlib::auth::AuthErrorCode::InvalidSession, - "Session not found.")); - } + TEST(AuthTests, RegisterUserRejectsEmptyEmail) + { + AuthFixture fixture; - [[nodiscard]] rixlib::auth::AuthStatus revoke_by_user_id( - std::string_view user_id) override - { - bool changed = false; - - for (rixlib::auth::Session &session : sessions_) - { - if (session.user_id() == user_id) - { - session.set_revoked(true); - changed = true; - } - } - - if (!changed) - { - return rixlib::auth::AuthStatus::failure( - rixlib::auth::make_auth_error( - rixlib::auth::AuthErrorCode::InvalidSession, - "Session not found.")); - } - - return rixlib::auth::AuthStatus::success(); - } + const auto result = fixture.auth.register_user( + RegisterRequest{ + "", + "correct-password"}); - [[nodiscard]] rixlib::auth::AuthResult> - find_by_id(std::string_view id) const override - { - for (const rixlib::auth::Session &session : sessions_) - { - if (session.id() == id) - { - return rixlib::auth::AuthResult>::success(session); - } - } - - return rixlib::auth::AuthResult>::success(std::nullopt); - } + ASSERT_TRUE(result.failed()); + EXPECT_EQ(result.error().code(), AuthErrorCode::InvalidEmail); + EXPECT_TRUE(fixture.users.empty()); + } - [[nodiscard]] rixlib::auth::AuthResult> - find_by_user_id(std::string_view user_id) const override - { - std::vector result; + TEST(AuthTests, RegisterUserRejectsWeakPassword) + { + AuthFixture fixture; - for (const rixlib::auth::Session &session : sessions_) - { - if (session.user_id() == user_id) - { - result.push_back(session); - } - } + const auto result = fixture.auth.register_user( + RegisterRequest{ + "ada@example.com", + "short"}); - return rixlib::auth::AuthResult>::success(result); - } + ASSERT_TRUE(result.failed()); + EXPECT_EQ(result.error().code(), AuthErrorCode::InvalidPassword); + EXPECT_TRUE(fixture.users.empty()); + } - [[nodiscard]] rixlib::auth::AuthResult - exists_by_id(std::string_view id) const override - { - const auto exists = std::any_of( - sessions_.begin(), - sessions_.end(), - [&](const rixlib::auth::Session &session) - { - return session.id() == id; - }); - - return rixlib::auth::AuthResult::success(exists); - } + TEST(AuthTests, RegisterUserRejectsDuplicateEmail) + { + AuthFixture fixture; - [[nodiscard]] rixlib::auth::AuthResult> - all() const override - { - return rixlib::auth::AuthResult>::success(sessions_); - } + const auto first = fixture.auth.register_user( + RegisterRequest{ + "ada@example.com", + "correct-password"}); - private: - std::vector sessions_; - }; + ASSERT_TRUE(first.ok()); + + const auto second = fixture.auth.register_user( + RegisterRequest{ + "ada@example.com", + "another-password"}); - void test_register_user_creates_user() + ASSERT_TRUE(second.failed()); + EXPECT_EQ(second.error().code(), AuthErrorCode::UserAlreadyExists); + EXPECT_EQ(fixture.users.size(), 1); + } + + TEST(AuthTests, RegisterUserHonorsEmailVerificationConfig) { MemoryUserStore users; MemorySessionStore sessions; - rixlib::auth::Auth auth{users, sessions}; + + AuthConfig config = test_config(); + config.set_require_email_verification(true); + + Auth auth{users, sessions, config}; const auto result = auth.register_user( - rixlib::auth::RegisterRequest{ + RegisterRequest{ + "ada@example.com", + "correct-password"}); + + ASSERT_TRUE(result.ok()); + EXPECT_FALSE(result.value().email_verified()); + } + + TEST(AuthTests, LoginCreatesSessionAndToken) + { + AuthFixture fixture; + + const auto registered = fixture.auth.register_user( + RegisterRequest{ + "ada@example.com", + "correct-password"}); + + ASSERT_TRUE(registered.ok()); + + const auto logged_in = fixture.auth.login( + LoginRequest{ "ada@example.com", - "secret123"}); + "correct-password"}); + + ASSERT_TRUE(logged_in.ok()); + + EXPECT_EQ(logged_in.value().user.email(), "ada@example.com"); - assert(result.ok()); - assert(result.value().valid()); - assert(result.value().email() == "ada@example.com"); - assert(!result.value().password_hash().empty()); + EXPECT_TRUE(logged_in.value().session.valid()); + EXPECT_FALSE(logged_in.value().session.id().empty()); + EXPECT_EQ(logged_in.value().session.user_id(), registered.value().id()); + EXPECT_FALSE(logged_in.value().session.revoked()); + EXPECT_GT(logged_in.value().session.created_at(), 0); + EXPECT_GT(logged_in.value().session.expires_at(), + logged_in.value().session.created_at()); - const auto exists = users.exists_by_email("ada@example.com"); + EXPECT_TRUE(logged_in.value().token.valid()); + EXPECT_FALSE(logged_in.value().token.value().empty()); + EXPECT_EQ(logged_in.value().token.user_id(), registered.value().id()); + EXPECT_EQ(logged_in.value().token.issuer(), "rix/auth/tests"); + EXPECT_GT(logged_in.value().token.issued_at(), 0); + EXPECT_GT(logged_in.value().token.expires_at(), + logged_in.value().token.issued_at()); - assert(exists.ok()); - assert(exists.value()); + EXPECT_EQ(fixture.sessions.size(), 1); } - void test_register_user_rejects_invalid_email() + TEST(AuthTests, LoginStoresCreatedSession) { - MemoryUserStore users; - MemorySessionStore sessions; - rixlib::auth::Auth auth{users, sessions}; + AuthFixture fixture; - const auto result = auth.register_user( - rixlib::auth::RegisterRequest{ + ASSERT_TRUE(fixture.auth.register_user( + RegisterRequest{ + "ada@example.com", + "correct-password"}) + .ok()); + + const auto logged_in = fixture.auth.login( + LoginRequest{ + "ada@example.com", + "correct-password"}); + + ASSERT_TRUE(logged_in.ok()); + + const auto stored = fixture.sessions.find_by_id( + logged_in.value().session.id()); + + ASSERT_TRUE(stored.ok()); + ASSERT_TRUE(stored.value().has_value()); + + EXPECT_EQ(stored.value()->id(), logged_in.value().session.id()); + EXPECT_EQ(stored.value()->user_id(), logged_in.value().user.id()); + } + + TEST(AuthTests, LoginRejectsUnknownUserWithInvalidCredentials) + { + AuthFixture fixture; + + const auto logged_in = fixture.auth.login( + LoginRequest{ + "missing@example.com", + "correct-password"}); + + ASSERT_TRUE(logged_in.failed()); + EXPECT_EQ(logged_in.error().code(), AuthErrorCode::InvalidCredentials); + EXPECT_TRUE(fixture.sessions.empty()); + } + + TEST(AuthTests, LoginRejectsWrongPasswordWithInvalidCredentials) + { + AuthFixture fixture; + + ASSERT_TRUE(fixture.auth.register_user( + RegisterRequest{ + "ada@example.com", + "correct-password"}) + .ok()); + + const auto logged_in = fixture.auth.login( + LoginRequest{ + "ada@example.com", + "wrong-password"}); + + ASSERT_TRUE(logged_in.failed()); + EXPECT_EQ(logged_in.error().code(), AuthErrorCode::InvalidCredentials); + EXPECT_TRUE(fixture.sessions.empty()); + } + + TEST(AuthTests, LoginRejectsInvalidEmail) + { + AuthFixture fixture; + + const auto logged_in = fixture.auth.login( + LoginRequest{ "invalid-email", - "secret123"}); + "correct-password"}); - assert(result.failed()); - assert(result.error().code() == rixlib::auth::AuthErrorCode::InvalidEmail); + ASSERT_TRUE(logged_in.failed()); + EXPECT_EQ(logged_in.error().code(), AuthErrorCode::InvalidEmail); } - void test_register_user_rejects_short_password() + TEST(AuthTests, LoginRejectsEmptyPassword) { - MemoryUserStore users; - MemorySessionStore sessions; - rixlib::auth::Auth auth{users, sessions}; + AuthFixture fixture; - const auto result = auth.register_user( - rixlib::auth::RegisterRequest{ + const auto logged_in = fixture.auth.login( + LoginRequest{ "ada@example.com", - "short"}); + ""}); - assert(result.failed()); - assert(result.error().code() == rixlib::auth::AuthErrorCode::InvalidPassword); + ASSERT_TRUE(logged_in.failed()); + EXPECT_EQ(logged_in.error().code(), AuthErrorCode::InvalidPassword); } - void test_register_user_rejects_duplicate_email() + TEST(AuthTests, LoginRejectsInactiveUser) { - MemoryUserStore users; - MemorySessionStore sessions; - rixlib::auth::Auth auth{users, sessions}; + AuthFixture fixture; - const auto first = auth.register_user( - rixlib::auth::RegisterRequest{ + const auto registered = fixture.auth.register_user( + RegisterRequest{ "ada@example.com", - "secret123"}); + "correct-password"}); + + ASSERT_TRUE(registered.ok()); - const auto second = auth.register_user( - rixlib::auth::RegisterRequest{ + User user = registered.value(); + user.set_active(false); + + ASSERT_TRUE(fixture.users.update(user).ok()); + + const auto logged_in = fixture.auth.login( + LoginRequest{ "ada@example.com", - "secret123"}); + "correct-password"}); - assert(first.ok()); - assert(second.failed()); - assert(second.error().code() == rixlib::auth::AuthErrorCode::UserAlreadyExists); + ASSERT_TRUE(logged_in.failed()); + EXPECT_EQ(logged_in.error().code(), AuthErrorCode::InvalidCredentials); } - void test_login_creates_session() + TEST(AuthTests, LoginRejectsUnverifiedUserWhenVerificationIsRequired) { MemoryUserStore users; MemorySessionStore sessions; - rixlib::auth::Auth auth{users, sessions}; + + AuthConfig config = test_config(); + config.set_require_email_verification(true); + + Auth auth{users, sessions, config}; const auto registered = auth.register_user( - rixlib::auth::RegisterRequest{ + RegisterRequest{ "ada@example.com", - "secret123"}); + "correct-password"}); + + ASSERT_TRUE(registered.ok()); + EXPECT_FALSE(registered.value().email_verified()); const auto logged_in = auth.login( - rixlib::auth::LoginRequest{ + LoginRequest{ "ada@example.com", - "secret123"}); + "correct-password"}); - assert(registered.ok()); - assert(logged_in.ok()); - assert(logged_in.value().user.email() == "ada@example.com"); - assert(logged_in.value().session.valid()); - assert(logged_in.value().session.user_id() == logged_in.value().user.id()); + ASSERT_TRUE(logged_in.failed()); + EXPECT_EQ(logged_in.error().code(), AuthErrorCode::InvalidState); } - void test_login_rejects_wrong_password() + TEST(AuthTests, AuthenticateSessionReturnsUsableSession) { - MemoryUserStore users; - MemorySessionStore sessions; - rixlib::auth::Auth auth{users, sessions}; + AuthFixture fixture; - const auto registered = auth.register_user( - rixlib::auth::RegisterRequest{ + ASSERT_TRUE(fixture.auth.register_user( + RegisterRequest{ + "ada@example.com", + "correct-password"}) + .ok()); + + const auto logged_in = fixture.auth.login( + LoginRequest{ "ada@example.com", - "secret123"}); + "correct-password"}); - const auto logged_in = auth.login( - rixlib::auth::LoginRequest{ + ASSERT_TRUE(logged_in.ok()); + + const auto authenticated = fixture.auth.authenticate_session( + logged_in.value().session.id()); + + ASSERT_TRUE(authenticated.ok()); + EXPECT_EQ(authenticated.value().id(), logged_in.value().session.id()); + EXPECT_FALSE(authenticated.value().revoked()); + } + + TEST(AuthTests, AuthenticateSessionRejectsEmptySessionId) + { + AuthFixture fixture; + + const auto authenticated = fixture.auth.authenticate_session(""); + + ASSERT_TRUE(authenticated.failed()); + EXPECT_EQ(authenticated.error().code(), AuthErrorCode::InvalidSession); + } + + TEST(AuthTests, AuthenticateSessionRejectsMissingSession) + { + AuthFixture fixture; + + const auto authenticated = fixture.auth.authenticate_session("missing"); + + ASSERT_TRUE(authenticated.failed()); + EXPECT_EQ(authenticated.error().code(), AuthErrorCode::InvalidSession); + } + + TEST(AuthTests, AuthenticateSessionRejectsRevokedSession) + { + AuthFixture fixture; + + ASSERT_TRUE(fixture.auth.register_user( + RegisterRequest{ + "ada@example.com", + "correct-password"}) + .ok()); + + const auto logged_in = fixture.auth.login( + LoginRequest{ "ada@example.com", - "wrong-password"}); + "correct-password"}); + + ASSERT_TRUE(logged_in.ok()); + + ASSERT_TRUE(fixture.auth.logout(logged_in.value().session.id()).ok()); - assert(registered.ok()); - assert(logged_in.failed()); - assert(logged_in.error().code() == rixlib::auth::AuthErrorCode::InvalidCredentials); + const auto authenticated = fixture.auth.authenticate_session( + logged_in.value().session.id()); + + ASSERT_TRUE(authenticated.failed()); + EXPECT_EQ(authenticated.error().code(), AuthErrorCode::InvalidSession); } - void test_authenticate_session_returns_session() + TEST(AuthTests, AuthenticateSessionRejectsExpiredSession) { MemoryUserStore users; MemorySessionStore sessions; - rixlib::auth::Auth auth{users, sessions}; - const auto registered = auth.register_user( - rixlib::auth::RegisterRequest{ - "ada@example.com", - "secret123"}); + AuthConfig config = test_config(); + config.set_session_ttl_seconds(-1); + + Auth auth{users, sessions, config}; + + ASSERT_TRUE(auth.register_user( + RegisterRequest{ + "ada@example.com", + "correct-password"}) + .ok()); const auto logged_in = auth.login( - rixlib::auth::LoginRequest{ + LoginRequest{ "ada@example.com", - "secret123"}); + "correct-password"}); + + ASSERT_TRUE(logged_in.ok()); const auto authenticated = auth.authenticate_session( logged_in.value().session.id()); - assert(registered.ok()); - assert(logged_in.ok()); - assert(authenticated.ok()); - assert(authenticated.value().id() == logged_in.value().session.id()); + ASSERT_TRUE(authenticated.failed()); + EXPECT_EQ(authenticated.error().code(), AuthErrorCode::SessionExpired); } - void test_logout_revokes_session() + TEST(AuthTests, LogoutRevokesSession) { - MemoryUserStore users; - MemorySessionStore sessions; - rixlib::auth::Auth auth{users, sessions}; + AuthFixture fixture; - const auto registered = auth.register_user( - rixlib::auth::RegisterRequest{ + ASSERT_TRUE(fixture.auth.register_user( + RegisterRequest{ + "ada@example.com", + "correct-password"}) + .ok()); + + const auto logged_in = fixture.auth.login( + LoginRequest{ "ada@example.com", - "secret123"}); + "correct-password"}); - const auto logged_in = auth.login( - rixlib::auth::LoginRequest{ + ASSERT_TRUE(logged_in.ok()); + + const auto status = fixture.auth.logout( + logged_in.value().session.id()); + + ASSERT_TRUE(status.ok()); + + const auto stored = fixture.sessions.find_by_id( + logged_in.value().session.id()); + + ASSERT_TRUE(stored.ok()); + ASSERT_TRUE(stored.value().has_value()); + EXPECT_TRUE(stored.value()->revoked()); + } + + TEST(AuthTests, LogoutRejectsEmptySessionId) + { + AuthFixture fixture; + + const auto status = fixture.auth.logout(""); + + ASSERT_TRUE(status.failed()); + EXPECT_EQ(status.error().code(), AuthErrorCode::InvalidSession); + } + + TEST(AuthTests, LogoutUserRevokesAllUserSessions) + { + AuthFixture fixture; + + const auto registered = fixture.auth.register_user( + RegisterRequest{ "ada@example.com", - "secret123"}); + "correct-password"}); - const auto logout = auth.logout(logged_in.value().session.id()); - const auto authenticated = auth.authenticate_session( + ASSERT_TRUE(registered.ok()); + + const auto first = fixture.auth.login( + LoginRequest{ + "ada@example.com", + "correct-password"}); + + const auto second = fixture.auth.login( + LoginRequest{ + "ada@example.com", + "correct-password"}); + + ASSERT_TRUE(first.ok()); + ASSERT_TRUE(second.ok()); + + const auto status = fixture.auth.logout_user(registered.value().id()); + + ASSERT_TRUE(status.ok()); + + const auto sessions = fixture.sessions.find_by_user_id( + registered.value().id()); + + ASSERT_TRUE(sessions.ok()); + ASSERT_EQ(sessions.value().size(), 2); + + for (const auto &session : sessions.value()) + { + EXPECT_TRUE(session.revoked()); + } + } + + TEST(AuthTests, LogoutUserRejectsEmptyUserId) + { + AuthFixture fixture; + + const auto status = fixture.auth.logout_user(""); + + ASSERT_TRUE(status.failed()); + EXPECT_EQ(status.error().code(), AuthErrorCode::InvalidInput); + } + + TEST(AuthTests, RefreshSessionExtendsExpiration) + { + AuthFixture fixture; + + ASSERT_TRUE(fixture.auth.register_user( + RegisterRequest{ + "ada@example.com", + "correct-password"}) + .ok()); + + const auto logged_in = fixture.auth.login( + LoginRequest{ + "ada@example.com", + "correct-password"}); + + ASSERT_TRUE(logged_in.ok()); + + const auto refreshed = fixture.auth.refresh_session( logged_in.value().session.id()); - assert(registered.ok()); - assert(logged_in.ok()); - assert(logout.ok()); - assert(authenticated.failed()); - assert(authenticated.error().code() == rixlib::auth::AuthErrorCode::InvalidSession); + ASSERT_TRUE(refreshed.ok()); + + EXPECT_EQ(refreshed.value().id(), logged_in.value().session.id()); + EXPECT_GE(refreshed.value().expires_at(), + logged_in.value().session.expires_at()); + EXPECT_GE(refreshed.value().last_seen_at(), + logged_in.value().session.last_seen_at()); } -} // namespace -int main() -{ - test_register_user_creates_user(); - test_register_user_rejects_invalid_email(); - test_register_user_rejects_short_password(); - test_register_user_rejects_duplicate_email(); - test_login_creates_session(); - test_login_rejects_wrong_password(); - test_authenticate_session_returns_session(); - test_logout_revokes_session(); - - return 0; -} + TEST(AuthTests, RefreshSessionRejectsMissingSession) + { + AuthFixture fixture; + + const auto refreshed = fixture.auth.refresh_session("missing"); + + ASSERT_TRUE(refreshed.failed()); + EXPECT_EQ(refreshed.error().code(), AuthErrorCode::InvalidSession); + } + + TEST(AuthTests, IssueTokenCreatesUsableToken) + { + AuthFixture fixture; + + const auto token = fixture.auth.issue_token("user_1"); + + ASSERT_TRUE(token.ok()); + + EXPECT_TRUE(token.value().valid()); + EXPECT_FALSE(token.value().value().empty()); + EXPECT_EQ(token.value().user_id(), "user_1"); + EXPECT_EQ(token.value().issuer(), "rix/auth/tests"); + EXPECT_FALSE(token.value().revoked()); + EXPECT_GT(token.value().issued_at(), 0); + EXPECT_GT(token.value().expires_at(), token.value().issued_at()); + } + + TEST(AuthTests, IssueTokenRejectsEmptyUserId) + { + AuthFixture fixture; + + const auto token = fixture.auth.issue_token(""); + + ASSERT_TRUE(token.failed()); + EXPECT_EQ(token.error().code(), AuthErrorCode::InvalidInput); + } + + TEST(AuthTests, ConfigReturnsConfiguredValues) + { + AuthFixture fixture; + + EXPECT_EQ(fixture.auth.config().min_password_length(), 8); + EXPECT_EQ(fixture.auth.config().session_ttl_seconds(), 3600); + EXPECT_EQ(fixture.auth.config().token_ttl_seconds(), 600); + EXPECT_EQ(fixture.auth.config().issuer(), "rix/auth/tests"); + EXPECT_FALSE(fixture.auth.config().require_email_verification()); + EXPECT_TRUE(fixture.auth.config().rotate_sessions()); + } + + TEST(AuthTests, PasswordHasherUsesAuthConfiguration) + { + MemoryUserStore users; + MemorySessionStore sessions; + + AuthConfig config = test_config(); + config.set_min_password_length(16); + + Auth auth{users, sessions, config}; + + EXPECT_FALSE(auth.password_hasher().accepts("short-password")); + EXPECT_TRUE(auth.password_hasher().accepts("very-strong-password")); + } +} // namespace diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 433f32d..70fd720 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,69 +1,48 @@ -add_executable(rix_auth_user_tests - UserTests.cpp -) - -target_link_libraries(rix_auth_user_tests - PRIVATE - rix::auth -) +find_package(GTest QUIET) -add_test( - NAME rix_auth_user_tests - COMMAND rix_auth_user_tests -) +if(NOT GTest_FOUND) + include(FetchContent) -add_executable(rix_auth_session_tests - SessionTests.cpp -) + FetchContent_Declare( + googletest + URL https://github.com/google/googletest/archive/refs/tags/v1.14.0.zip + ) -target_link_libraries(rix_auth_session_tests - PRIVATE - rix::auth -) + set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) -add_test( - NAME rix_auth_session_tests - COMMAND rix_auth_session_tests -) + FetchContent_MakeAvailable(googletest) +endif() -add_executable(rix_auth_token_tests +add_executable(rix_auth_tests + UserTests.cpp + SessionTests.cpp TokenTests.cpp + PasswordHasherTests.cpp + MemoryUserStoreTests.cpp + MemorySessionStoreTests.cpp + AuthTests.cpp ) -target_link_libraries(rix_auth_token_tests +target_compile_features(rix_auth_tests PRIVATE - rix::auth -) - -add_test( - NAME rix_auth_token_tests - COMMAND rix_auth_token_tests + cxx_std_20 ) -add_executable(rix_auth_password_hasher_tests - PasswordHasherTests.cpp -) - -target_link_libraries(rix_auth_password_hasher_tests +target_link_libraries(rix_auth_tests PRIVATE rix::auth + GTest::gtest_main ) -add_test( - NAME rix_auth_password_hasher_tests - COMMAND rix_auth_password_hasher_tests -) +if(NOT MSVC) + target_compile_options(rix_auth_tests + PRIVATE + -Wall + -Wextra + -Wpedantic + ) +endif() -add_executable(rix_auth_auth_tests - AuthTests.cpp -) +include(GoogleTest) -target_link_libraries(rix_auth_auth_tests - PRIVATE - rix::auth -) - -add_test( - NAME rix_auth_auth_tests - COMMAND rix_auth_auth_tests -) +gtest_discover_tests(rix_auth_tests) diff --git a/tests/MemorySessionStoreTests.cpp b/tests/MemorySessionStoreTests.cpp new file mode 100644 index 0000000..a8d36f2 --- /dev/null +++ b/tests/MemorySessionStoreTests.cpp @@ -0,0 +1,337 @@ +/** + * + * @file MemorySessionStoreTests.cpp + * @author Gaspard Kirira + * + * Copyright 2026, Gaspard Kirira. + * All rights reserved. + * https://github.com/rixcpp/auth + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Rix + * + */ + +#include +#include + +#include + +namespace +{ + using rixlib::auth::AuthErrorCode; + using rixlib::auth::MemorySessionStore; + using rixlib::auth::Session; + + [[nodiscard]] Session make_session( + std::string id = "session_1", + std::string user_id = "user_1") + { + return Session{ + std::move(id), + std::move(user_id), + 1000, + 2000}; + } + + TEST(MemorySessionStoreTests, StartsEmpty) + { + const MemorySessionStore store; + + EXPECT_TRUE(store.empty()); + EXPECT_EQ(store.size(), 0); + } + + TEST(MemorySessionStoreTests, CreateStoresSession) + { + MemorySessionStore store; + + const auto status = store.create(make_session()); + + EXPECT_TRUE(status.ok()); + EXPECT_FALSE(store.empty()); + EXPECT_EQ(store.size(), 1); + } + + TEST(MemorySessionStoreTests, CreateRejectsInvalidSession) + { + MemorySessionStore store; + + const Session session; + + const auto status = store.create(session); + + ASSERT_TRUE(status.failed()); + EXPECT_EQ(status.error().code(), AuthErrorCode::InvalidSession); + EXPECT_TRUE(store.empty()); + } + + TEST(MemorySessionStoreTests, CreateRejectsDuplicateSessionId) + { + MemorySessionStore store; + + ASSERT_TRUE(store.create(make_session("session_1", "user_1")).ok()); + + const auto status = store.create(make_session("session_1", "user_2")); + + ASSERT_TRUE(status.failed()); + EXPECT_EQ(status.error().code(), AuthErrorCode::InvalidSession); + } + + TEST(MemorySessionStoreTests, FindByIdReturnsStoredSession) + { + MemorySessionStore store; + + ASSERT_TRUE(store.create(make_session("session_1", "user_1")).ok()); + + const auto found = store.find_by_id("session_1"); + + ASSERT_TRUE(found.ok()); + ASSERT_TRUE(found.value().has_value()); + EXPECT_EQ(found.value()->id(), "session_1"); + EXPECT_EQ(found.value()->user_id(), "user_1"); + } + + TEST(MemorySessionStoreTests, FindByIdReturnsEmptyOptionalWhenMissing) + { + MemorySessionStore store; + + const auto found = store.find_by_id("missing"); + + ASSERT_TRUE(found.ok()); + EXPECT_FALSE(found.value().has_value()); + } + + TEST(MemorySessionStoreTests, FindByIdRejectsEmptyId) + { + MemorySessionStore store; + + const auto found = store.find_by_id(""); + + ASSERT_TRUE(found.failed()); + EXPECT_EQ(found.error().code(), AuthErrorCode::InvalidSession); + } + + TEST(MemorySessionStoreTests, FindByUserIdReturnsMatchingSessions) + { + MemorySessionStore store; + + ASSERT_TRUE(store.create(make_session("session_1", "user_1")).ok()); + ASSERT_TRUE(store.create(make_session("session_2", "user_1")).ok()); + ASSERT_TRUE(store.create(make_session("session_3", "user_2")).ok()); + + const auto sessions = store.find_by_user_id("user_1"); + + ASSERT_TRUE(sessions.ok()); + EXPECT_EQ(sessions.value().size(), 2); + + for (const auto &session : sessions.value()) + { + EXPECT_TRUE(session.belongs_to("user_1")); + } + } + + TEST(MemorySessionStoreTests, FindByUserIdReturnsEmptyVectorWhenMissing) + { + MemorySessionStore store; + + const auto sessions = store.find_by_user_id("missing"); + + ASSERT_TRUE(sessions.ok()); + EXPECT_TRUE(sessions.value().empty()); + } + + TEST(MemorySessionStoreTests, FindByUserIdRejectsEmptyUserId) + { + MemorySessionStore store; + + const auto sessions = store.find_by_user_id(""); + + ASSERT_TRUE(sessions.failed()); + EXPECT_EQ(sessions.error().code(), AuthErrorCode::InvalidInput); + } + + TEST(MemorySessionStoreTests, ExistsByIdReturnsExpectedState) + { + MemorySessionStore store; + + ASSERT_TRUE(store.create(make_session("session_1", "user_1")).ok()); + + const auto existing = store.exists_by_id("session_1"); + const auto missing = store.exists_by_id("missing"); + + ASSERT_TRUE(existing.ok()); + ASSERT_TRUE(missing.ok()); + + EXPECT_TRUE(existing.value()); + EXPECT_FALSE(missing.value()); + } + + TEST(MemorySessionStoreTests, UpdateChangesStoredSession) + { + MemorySessionStore store; + + ASSERT_TRUE(store.create(make_session("session_1", "user_1")).ok()); + + Session session = make_session("session_1", "user_2"); + session.set_last_seen_at(1500); + session.set_expires_at(5000); + session.set_revoked(true); + + const auto status = store.update(session); + + ASSERT_TRUE(status.ok()); + + const auto found = store.find_by_id("session_1"); + + ASSERT_TRUE(found.ok()); + ASSERT_TRUE(found.value().has_value()); + + EXPECT_EQ(found.value()->user_id(), "user_2"); + EXPECT_EQ(found.value()->last_seen_at(), 1500); + EXPECT_EQ(found.value()->expires_at(), 5000); + EXPECT_TRUE(found.value()->revoked()); + + const auto old_user_sessions = store.find_by_user_id("user_1"); + const auto new_user_sessions = store.find_by_user_id("user_2"); + + ASSERT_TRUE(old_user_sessions.ok()); + ASSERT_TRUE(new_user_sessions.ok()); + + EXPECT_TRUE(old_user_sessions.value().empty()); + EXPECT_EQ(new_user_sessions.value().size(), 1); + } + + TEST(MemorySessionStoreTests, UpdateRejectsMissingSession) + { + MemorySessionStore store; + + const auto status = store.update(make_session("missing", "user_1")); + + ASSERT_TRUE(status.failed()); + EXPECT_EQ(status.error().code(), AuthErrorCode::InvalidSession); + } + + TEST(MemorySessionStoreTests, RemoveByIdDeletesSessionAndIndex) + { + MemorySessionStore store; + + ASSERT_TRUE(store.create(make_session("session_1", "user_1")).ok()); + + const auto removed = store.remove_by_id("session_1"); + + ASSERT_TRUE(removed.ok()); + EXPECT_TRUE(store.empty()); + + const auto by_id = store.exists_by_id("session_1"); + const auto by_user = store.find_by_user_id("user_1"); + + ASSERT_TRUE(by_id.ok()); + ASSERT_TRUE(by_user.ok()); + + EXPECT_FALSE(by_id.value()); + EXPECT_TRUE(by_user.value().empty()); + } + + TEST(MemorySessionStoreTests, RemoveByIdRejectsMissingSession) + { + MemorySessionStore store; + + const auto removed = store.remove_by_id("missing"); + + ASSERT_TRUE(removed.failed()); + EXPECT_EQ(removed.error().code(), AuthErrorCode::InvalidSession); + } + + TEST(MemorySessionStoreTests, RevokeByIdMarksSessionAsRevoked) + { + MemorySessionStore store; + + ASSERT_TRUE(store.create(make_session("session_1", "user_1")).ok()); + + const auto revoked = store.revoke_by_id("session_1"); + + ASSERT_TRUE(revoked.ok()); + + const auto found = store.find_by_id("session_1"); + + ASSERT_TRUE(found.ok()); + ASSERT_TRUE(found.value().has_value()); + + EXPECT_TRUE(found.value()->revoked()); + } + + TEST(MemorySessionStoreTests, RevokeByIdRejectsMissingSession) + { + MemorySessionStore store; + + const auto revoked = store.revoke_by_id("missing"); + + ASSERT_TRUE(revoked.failed()); + EXPECT_EQ(revoked.error().code(), AuthErrorCode::InvalidSession); + } + + TEST(MemorySessionStoreTests, RevokeByUserIdRevokesAllMatchingSessions) + { + MemorySessionStore store; + + ASSERT_TRUE(store.create(make_session("session_1", "user_1")).ok()); + ASSERT_TRUE(store.create(make_session("session_2", "user_1")).ok()); + ASSERT_TRUE(store.create(make_session("session_3", "user_2")).ok()); + + const auto revoked = store.revoke_by_user_id("user_1"); + + ASSERT_TRUE(revoked.ok()); + + const auto user_1_sessions = store.find_by_user_id("user_1"); + const auto user_2_sessions = store.find_by_user_id("user_2"); + + ASSERT_TRUE(user_1_sessions.ok()); + ASSERT_TRUE(user_2_sessions.ok()); + + for (const auto &session : user_1_sessions.value()) + { + EXPECT_TRUE(session.revoked()); + } + + ASSERT_EQ(user_2_sessions.value().size(), 1); + EXPECT_FALSE(user_2_sessions.value().front().revoked()); + } + + TEST(MemorySessionStoreTests, RevokeByUserIdSucceedsWhenUserHasNoSessions) + { + MemorySessionStore store; + + const auto revoked = store.revoke_by_user_id("missing"); + + EXPECT_TRUE(revoked.ok()); + } + + TEST(MemorySessionStoreTests, AllReturnsAllSessions) + { + MemorySessionStore store; + + ASSERT_TRUE(store.create(make_session("session_1", "user_1")).ok()); + ASSERT_TRUE(store.create(make_session("session_2", "user_2")).ok()); + + const auto sessions = store.all(); + + ASSERT_TRUE(sessions.ok()); + EXPECT_EQ(sessions.value().size(), 2); + } + + TEST(MemorySessionStoreTests, ClearRemovesEverything) + { + MemorySessionStore store; + + ASSERT_TRUE(store.create(make_session("session_1", "user_1")).ok()); + ASSERT_TRUE(store.create(make_session("session_2", "user_2")).ok()); + + store.clear(); + + EXPECT_TRUE(store.empty()); + EXPECT_EQ(store.size(), 0); + } +} // namespace diff --git a/tests/MemoryUserStoreTests.cpp b/tests/MemoryUserStoreTests.cpp new file mode 100644 index 0000000..5447311 --- /dev/null +++ b/tests/MemoryUserStoreTests.cpp @@ -0,0 +1,313 @@ +/** + * + * @file MemoryUserStoreTests.cpp + * @author Gaspard Kirira + * + * Copyright 2026, Gaspard Kirira. + * All rights reserved. + * https://github.com/rixcpp/auth + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Rix + * + */ + +#include +#include + +#include + +namespace +{ + using rixlib::auth::AuthErrorCode; + using rixlib::auth::MemoryUserStore; + using rixlib::auth::User; + + [[nodiscard]] User make_user( + std::string id = "user_1", + std::string email = "ada@example.com") + { + return User{ + std::move(id), + std::move(email), + "encoded-password-hash", + 1000}; + } + + TEST(MemoryUserStoreTests, StartsEmpty) + { + const MemoryUserStore store; + + EXPECT_TRUE(store.empty()); + EXPECT_EQ(store.size(), 0); + } + + TEST(MemoryUserStoreTests, CreateStoresUser) + { + MemoryUserStore store; + + const auto status = store.create(make_user()); + + EXPECT_TRUE(status.ok()); + EXPECT_FALSE(store.empty()); + EXPECT_EQ(store.size(), 1); + } + + TEST(MemoryUserStoreTests, CreateRejectsInvalidUser) + { + MemoryUserStore store; + + const User user; + + const auto status = store.create(user); + + ASSERT_TRUE(status.failed()); + EXPECT_EQ(status.error().code(), AuthErrorCode::InvalidInput); + EXPECT_TRUE(store.empty()); + } + + TEST(MemoryUserStoreTests, CreateRejectsDuplicateId) + { + MemoryUserStore store; + + ASSERT_TRUE(store.create(make_user("user_1", "ada@example.com")).ok()); + + const auto status = store.create(make_user("user_1", "grace@example.com")); + + ASSERT_TRUE(status.failed()); + EXPECT_EQ(status.error().code(), AuthErrorCode::UserAlreadyExists); + } + + TEST(MemoryUserStoreTests, CreateRejectsDuplicateEmail) + { + MemoryUserStore store; + + ASSERT_TRUE(store.create(make_user("user_1", "ada@example.com")).ok()); + + const auto status = store.create(make_user("user_2", "ada@example.com")); + + ASSERT_TRUE(status.failed()); + EXPECT_EQ(status.error().code(), AuthErrorCode::UserAlreadyExists); + } + + TEST(MemoryUserStoreTests, FindByIdReturnsStoredUser) + { + MemoryUserStore store; + + ASSERT_TRUE(store.create(make_user("user_1", "ada@example.com")).ok()); + + const auto found = store.find_by_id("user_1"); + + ASSERT_TRUE(found.ok()); + ASSERT_TRUE(found.value().has_value()); + EXPECT_EQ(found.value()->id(), "user_1"); + EXPECT_EQ(found.value()->email(), "ada@example.com"); + } + + TEST(MemoryUserStoreTests, FindByIdReturnsEmptyOptionalWhenMissing) + { + MemoryUserStore store; + + const auto found = store.find_by_id("missing"); + + ASSERT_TRUE(found.ok()); + EXPECT_FALSE(found.value().has_value()); + } + + TEST(MemoryUserStoreTests, FindByIdRejectsEmptyId) + { + MemoryUserStore store; + + const auto found = store.find_by_id(""); + + ASSERT_TRUE(found.failed()); + EXPECT_EQ(found.error().code(), AuthErrorCode::InvalidInput); + } + + TEST(MemoryUserStoreTests, FindByEmailReturnsStoredUser) + { + MemoryUserStore store; + + ASSERT_TRUE(store.create(make_user("user_1", "ada@example.com")).ok()); + + const auto found = store.find_by_email("ada@example.com"); + + ASSERT_TRUE(found.ok()); + ASSERT_TRUE(found.value().has_value()); + EXPECT_EQ(found.value()->id(), "user_1"); + EXPECT_EQ(found.value()->email(), "ada@example.com"); + } + + TEST(MemoryUserStoreTests, FindByEmailReturnsEmptyOptionalWhenMissing) + { + MemoryUserStore store; + + const auto found = store.find_by_email("missing@example.com"); + + ASSERT_TRUE(found.ok()); + EXPECT_FALSE(found.value().has_value()); + } + + TEST(MemoryUserStoreTests, FindByEmailRejectsEmptyEmail) + { + MemoryUserStore store; + + const auto found = store.find_by_email(""); + + ASSERT_TRUE(found.failed()); + EXPECT_EQ(found.error().code(), AuthErrorCode::InvalidEmail); + } + + TEST(MemoryUserStoreTests, ExistsByIdReturnsExpectedState) + { + MemoryUserStore store; + + ASSERT_TRUE(store.create(make_user("user_1", "ada@example.com")).ok()); + + const auto existing = store.exists_by_id("user_1"); + const auto missing = store.exists_by_id("missing"); + + ASSERT_TRUE(existing.ok()); + ASSERT_TRUE(missing.ok()); + + EXPECT_TRUE(existing.value()); + EXPECT_FALSE(missing.value()); + } + + TEST(MemoryUserStoreTests, ExistsByEmailReturnsExpectedState) + { + MemoryUserStore store; + + ASSERT_TRUE(store.create(make_user("user_1", "ada@example.com")).ok()); + + const auto existing = store.exists_by_email("ada@example.com"); + const auto missing = store.exists_by_email("missing@example.com"); + + ASSERT_TRUE(existing.ok()); + ASSERT_TRUE(missing.ok()); + + EXPECT_TRUE(existing.value()); + EXPECT_FALSE(missing.value()); + } + + TEST(MemoryUserStoreTests, UpdateChangesStoredUser) + { + MemoryUserStore store; + + ASSERT_TRUE(store.create(make_user("user_1", "ada@example.com")).ok()); + + User user = make_user("user_1", "grace@example.com"); + user.set_password_hash("new-hash"); + user.set_email_verified(true); + user.set_active(false); + user.set_updated_at(2000); + + const auto status = store.update(user); + + ASSERT_TRUE(status.ok()); + + const auto found = store.find_by_id("user_1"); + + ASSERT_TRUE(found.ok()); + ASSERT_TRUE(found.value().has_value()); + + EXPECT_EQ(found.value()->email(), "grace@example.com"); + EXPECT_EQ(found.value()->password_hash(), "new-hash"); + EXPECT_TRUE(found.value()->email_verified()); + EXPECT_FALSE(found.value()->active()); + EXPECT_EQ(found.value()->updated_at(), 2000); + + const auto old_email = store.exists_by_email("ada@example.com"); + const auto new_email = store.exists_by_email("grace@example.com"); + + ASSERT_TRUE(old_email.ok()); + ASSERT_TRUE(new_email.ok()); + + EXPECT_FALSE(old_email.value()); + EXPECT_TRUE(new_email.value()); + } + + TEST(MemoryUserStoreTests, UpdateRejectsMissingUser) + { + MemoryUserStore store; + + const auto status = store.update(make_user("missing", "ada@example.com")); + + ASSERT_TRUE(status.failed()); + EXPECT_EQ(status.error().code(), AuthErrorCode::UserNotFound); + } + + TEST(MemoryUserStoreTests, UpdateRejectsDuplicateEmailOwner) + { + MemoryUserStore store; + + ASSERT_TRUE(store.create(make_user("user_1", "ada@example.com")).ok()); + ASSERT_TRUE(store.create(make_user("user_2", "grace@example.com")).ok()); + + User user = make_user("user_2", "ada@example.com"); + + const auto status = store.update(user); + + ASSERT_TRUE(status.failed()); + EXPECT_EQ(status.error().code(), AuthErrorCode::UserAlreadyExists); + } + + TEST(MemoryUserStoreTests, RemoveByIdDeletesUserAndEmailIndex) + { + MemoryUserStore store; + + ASSERT_TRUE(store.create(make_user("user_1", "ada@example.com")).ok()); + + const auto removed = store.remove_by_id("user_1"); + + ASSERT_TRUE(removed.ok()); + EXPECT_TRUE(store.empty()); + + const auto by_id = store.exists_by_id("user_1"); + const auto by_email = store.exists_by_email("ada@example.com"); + + ASSERT_TRUE(by_id.ok()); + ASSERT_TRUE(by_email.ok()); + + EXPECT_FALSE(by_id.value()); + EXPECT_FALSE(by_email.value()); + } + + TEST(MemoryUserStoreTests, RemoveByIdRejectsMissingUser) + { + MemoryUserStore store; + + const auto removed = store.remove_by_id("missing"); + + ASSERT_TRUE(removed.failed()); + EXPECT_EQ(removed.error().code(), AuthErrorCode::UserNotFound); + } + + TEST(MemoryUserStoreTests, AllReturnsAllUsers) + { + MemoryUserStore store; + + ASSERT_TRUE(store.create(make_user("user_1", "ada@example.com")).ok()); + ASSERT_TRUE(store.create(make_user("user_2", "grace@example.com")).ok()); + + const auto users = store.all(); + + ASSERT_TRUE(users.ok()); + EXPECT_EQ(users.value().size(), 2); + } + + TEST(MemoryUserStoreTests, ClearRemovesEverything) + { + MemoryUserStore store; + + ASSERT_TRUE(store.create(make_user("user_1", "ada@example.com")).ok()); + ASSERT_TRUE(store.create(make_user("user_2", "grace@example.com")).ok()); + + store.clear(); + + EXPECT_TRUE(store.empty()); + EXPECT_EQ(store.size(), 0); + } +} // namespace diff --git a/tests/PasswordHasherTests.cpp b/tests/PasswordHasherTests.cpp index 7de06c2..015d186 100644 --- a/tests/PasswordHasherTests.cpp +++ b/tests/PasswordHasherTests.cpp @@ -14,111 +14,134 @@ * */ +#include +#include #include -#include -#include +#include namespace { - void test_default_min_password_length_is_eight() + using rixlib::auth::AuthConfig; + using rixlib::auth::AuthErrorCode; + using rixlib::auth::PasswordHasher; + + TEST(PasswordHasherTests, DefaultHasherAcceptsPasswordsWithMinimumLength) { - const rixlib::auth::PasswordHasher hasher; + const PasswordHasher hasher; - assert(hasher.min_password_length() == 8); + EXPECT_FALSE(hasher.accepts("")); + EXPECT_FALSE(hasher.accepts("short")); + EXPECT_TRUE(hasher.accepts("password")); + EXPECT_TRUE(hasher.accepts("very-strong-password")); } - void test_set_min_password_length_updates_policy() + TEST(PasswordHasherTests, ConfigConstructorUsesConfiguredMinimumLength) { - rixlib::auth::PasswordHasher hasher; + AuthConfig config; + config.set_min_password_length(12); + + const PasswordHasher hasher{config}; + EXPECT_FALSE(hasher.accepts("password")); + EXPECT_TRUE(hasher.accepts("long-password")); + } + + TEST(PasswordHasherTests, HashRejectsPasswordBelowMinimumLength) + { + PasswordHasher hasher; hasher.set_min_password_length(12); - assert(hasher.min_password_length() == 12); - assert(!hasher.accepts("short123")); - assert(hasher.accepts("long-password")); + const auto result = hasher.hash("short"); + + ASSERT_TRUE(result.failed()); + EXPECT_EQ(result.error().code(), AuthErrorCode::InvalidPassword); } - void test_accepts_password_when_length_is_valid() + TEST(PasswordHasherTests, HashAcceptsPasswordThatSatisfiesPolicy) { - const rixlib::auth::PasswordHasher hasher; + const PasswordHasher hasher; + + const auto result = hasher.hash("correct-password"); - assert(hasher.accepts("secret123")); - assert(!hasher.accepts("short")); - assert(!hasher.accepts("")); + ASSERT_TRUE(result.ok()); + EXPECT_FALSE(result.value().empty()); + EXPECT_NE(result.value(), "correct-password"); } - void test_hash_fails_when_password_is_too_short() + TEST(PasswordHasherTests, HashProducesEncodedPasswordHash) { - const rixlib::auth::PasswordHasher hasher; + const PasswordHasher hasher; - const auto result = hasher.hash("short"); + const auto result = hasher.hash("correct-password"); - assert(result.failed()); - assert(result.error().code() == rixlib::auth::AuthErrorCode::InvalidPassword); + ASSERT_TRUE(result.ok()); + EXPECT_NE(result.value().find("rix-auth$"), std::string::npos); } - void test_hash_succeeds_when_password_is_valid() + TEST(PasswordHasherTests, VerifyAcceptsMatchingPassword) { - const rixlib::auth::PasswordHasher hasher; + const PasswordHasher hasher; - const auto result = hasher.hash("secret123"); + const auto hash = hasher.hash("correct-password"); - assert(result.ok()); - assert(!result.value().empty()); + ASSERT_TRUE(hash.ok()); + EXPECT_TRUE(hasher.verify("correct-password", hash.value())); } - void test_verify_returns_true_for_matching_password() + TEST(PasswordHasherTests, VerifyRejectsWrongPassword) { - const rixlib::auth::PasswordHasher hasher; + const PasswordHasher hasher; + + const auto hash = hasher.hash("correct-password"); - const auto result = hasher.hash("secret123"); + ASSERT_TRUE(hash.ok()); + EXPECT_FALSE(hasher.verify("wrong-password", hash.value())); + } + + TEST(PasswordHasherTests, VerifyRejectsEmptyHash) + { + const PasswordHasher hasher; - assert(result.ok()); - assert(hasher.verify("secret123", result.value())); + EXPECT_FALSE(hasher.verify("correct-password", "")); } - void test_verify_returns_false_for_wrong_password() + TEST(PasswordHasherTests, VerifyRejectsPasswordBelowPolicy) { - const rixlib::auth::PasswordHasher hasher; + PasswordHasher hasher; + hasher.set_min_password_length(12); - const auto result = hasher.hash("secret123"); + const auto hash = hasher.hash("long-password"); - assert(result.ok()); - assert(!hasher.verify("another-password", result.value())); + ASSERT_TRUE(hash.ok()); + EXPECT_FALSE(hasher.verify("short", hash.value())); } - void test_verify_returns_false_for_empty_hash() + TEST(PasswordHasherTests, SamePasswordCanBeVerifiedFromStoredHash) { - const rixlib::auth::PasswordHasher hasher; + const PasswordHasher hasher; + + const auto hash = hasher.hash("another-correct-password"); - assert(!hasher.verify("secret123", "")); + ASSERT_TRUE(hash.ok()); + + const std::string stored_hash = hash.value(); + + EXPECT_TRUE(hasher.verify("another-correct-password", stored_hash)); } - void test_same_password_produces_same_hash_for_current_basic_hasher() + TEST(PasswordHasherTests, DifferentHashesAreProducedForSamePassword) { - const rixlib::auth::PasswordHasher hasher; + const PasswordHasher hasher; + + const auto first = hasher.hash("correct-password"); + const auto second = hasher.hash("correct-password"); - const auto first = hasher.hash("secret123"); - const auto second = hasher.hash("secret123"); + ASSERT_TRUE(first.ok()); + ASSERT_TRUE(second.ok()); - assert(first.ok()); - assert(second.ok()); - assert(first.value() == second.value()); + EXPECT_NE(first.value(), second.value()); + EXPECT_TRUE(hasher.verify("correct-password", first.value())); + EXPECT_TRUE(hasher.verify("correct-password", second.value())); } } // namespace - -int main() -{ - test_default_min_password_length_is_eight(); - test_set_min_password_length_updates_policy(); - test_accepts_password_when_length_is_valid(); - test_hash_fails_when_password_is_too_short(); - test_hash_succeeds_when_password_is_valid(); - test_verify_returns_true_for_matching_password(); - test_verify_returns_false_for_wrong_password(); - test_verify_returns_false_for_empty_hash(); - test_same_password_produces_same_hash_for_current_basic_hasher(); - - return 0; -} diff --git a/tests/SessionTests.cpp b/tests/SessionTests.cpp index 84ed5e8..44f7f37 100644 --- a/tests/SessionTests.cpp +++ b/tests/SessionTests.cpp @@ -16,140 +16,200 @@ #include -#include -#include +#include namespace { - void test_default_session_is_invalid() + using rixlib::auth::Session; + + TEST(SessionTests, DefaultSessionIsInvalid) { - const rixlib::auth::Session session; - - assert(session.id().empty()); - assert(session.user_id().empty()); - assert(session.created_at() == 0); - assert(session.expires_at() == 0); - assert(session.last_seen_at() == 0); - assert(!session.revoked()); - assert(!session.valid()); + const Session session; + + EXPECT_TRUE(session.id().empty()); + EXPECT_TRUE(session.user_id().empty()); + EXPECT_EQ(session.created_at(), 0); + EXPECT_EQ(session.expires_at(), 0); + EXPECT_EQ(session.last_seen_at(), 0); + EXPECT_FALSE(session.revoked()); + EXPECT_FALSE(session.valid()); } - void test_constructed_session_has_expected_values() + TEST(SessionTests, ConstructedSessionStoresFields) { - const std::int64_t created_at = 1000; - const std::int64_t expires_at = 2000; - - const rixlib::auth::Session session{ + const Session session{ "session_1", "user_1", - created_at, - expires_at}; - - assert(session.id() == "session_1"); - assert(session.user_id() == "user_1"); - assert(session.created_at() == created_at); - assert(session.expires_at() == expires_at); - assert(session.last_seen_at() == created_at); - assert(!session.revoked()); - assert(session.valid()); + 1000, + 2000}; + + EXPECT_EQ(session.id(), "session_1"); + EXPECT_EQ(session.user_id(), "user_1"); + EXPECT_EQ(session.created_at(), 1000); + EXPECT_EQ(session.expires_at(), 2000); + EXPECT_EQ(session.last_seen_at(), 1000); + EXPECT_FALSE(session.revoked()); + EXPECT_TRUE(session.valid()); } - void test_session_setters_update_values() + TEST(SessionTests, SettersUpdateSessionFields) { - rixlib::auth::Session session; + Session session; session.set_id("session_2"); session.set_user_id("user_2"); - session.set_created_at(3000); - session.set_expires_at(4000); - session.set_last_seen_at(3500); + session.set_created_at(100); + session.set_expires_at(200); + session.set_last_seen_at(150); session.set_revoked(true); - assert(session.id() == "session_2"); - assert(session.user_id() == "user_2"); - assert(session.created_at() == 3000); - assert(session.expires_at() == 4000); - assert(session.last_seen_at() == 3500); - assert(session.revoked()); - assert(session.valid()); + EXPECT_EQ(session.id(), "session_2"); + EXPECT_EQ(session.user_id(), "user_2"); + EXPECT_EQ(session.created_at(), 100); + EXPECT_EQ(session.expires_at(), 200); + EXPECT_EQ(session.last_seen_at(), 150); + EXPECT_TRUE(session.revoked()); + EXPECT_TRUE(session.valid()); } - void test_belongs_to_checks_user_id() + TEST(SessionTests, BelongsToReturnsTrueOnlyForMatchingUser) { - rixlib::auth::Session session; - session.set_user_id("user_3"); + const Session session{ + "session_3", + "user_3", + 1000, + 2000}; - assert(session.belongs_to("user_3")); - assert(!session.belongs_to("user_4")); - assert(!session.belongs_to("")); + EXPECT_TRUE(session.belongs_to("user_3")); + EXPECT_FALSE(session.belongs_to("user_4")); + EXPECT_FALSE(session.belongs_to("")); } - void test_session_requires_id_and_user_id_to_be_valid() + TEST(SessionTests, ExpiredReturnsFalseBeforeExpiration) { - rixlib::auth::Session session; + const Session session{ + "session_4", + "user_4", + 1000, + 2000}; - assert(!session.valid()); + EXPECT_FALSE(session.expired(1000)); + EXPECT_FALSE(session.expired(1999)); + } + + TEST(SessionTests, ExpiredReturnsTrueAtAndAfterExpiration) + { + const Session session{ + "session_5", + "user_5", + 1000, + 2000}; - session.set_id("session_3"); - assert(!session.valid()); + EXPECT_TRUE(session.expired(2000)); + EXPECT_TRUE(session.expired(2001)); + } - session.set_user_id("user_3"); - assert(session.valid()); + TEST(SessionTests, ZeroExpirationDoesNotExpire) + { + const Session session{ + "session_6", + "user_6", + 1000, + 0}; - session.set_id(""); - assert(!session.valid()); + EXPECT_FALSE(session.expired(1000)); + EXPECT_FALSE(session.expired(999999)); } - void test_expired_returns_true_after_expiration() + TEST(SessionTests, UsableRequiresValidNotRevokedAndNotExpired) { - rixlib::auth::Session session; - session.set_expires_at(1000); + Session session{ + "session_7", + "user_7", + 1000, + 2000}; - assert(!session.expired(999)); - assert(session.expired(1000)); - assert(session.expired(1001)); + EXPECT_TRUE(session.usable(1500)); + + session.set_revoked(true); + + EXPECT_FALSE(session.usable(1500)); + + session.set_revoked(false); + + EXPECT_FALSE(session.usable(2000)); } - void test_zero_expiration_means_not_expired() + TEST(SessionTests, RevokeMarksSessionAsRevoked) { - rixlib::auth::Session session; - session.set_expires_at(0); + Session session{ + "session_8", + "user_8", + 1000, + 2000}; + + EXPECT_FALSE(session.revoked()); - assert(!session.expired(0)); - assert(!session.expired(1000)); + session.revoke(); + + EXPECT_TRUE(session.revoked()); + EXPECT_FALSE(session.usable(1500)); } - void test_usable_requires_valid_not_revoked_and_not_expired() + TEST(SessionTests, RefreshUpdatesExpirationAndLastSeen) { - rixlib::auth::Session session{ - "session_4", - "user_4", + Session session{ + "session_9", + "user_9", + 1000, + 2000}; + + session.refresh(1500, 3600); + + EXPECT_EQ(session.last_seen_at(), 1500); + EXPECT_EQ(session.expires_at(), 5100); + EXPECT_TRUE(session.usable(5000)); + EXPECT_FALSE(session.usable(5100)); + } + + TEST(SessionTests, RefreshableRequiresValidNotRevokedAndNotExpired) + { + Session session{ + "session_10", + "user_10", 1000, 2000}; - assert(session.usable(1500)); + EXPECT_TRUE(session.refreshable(1500)); session.set_revoked(true); - assert(!session.usable(1500)); + + EXPECT_FALSE(session.refreshable(1500)); session.set_revoked(false); - assert(!session.usable(2000)); - session.set_id(""); - assert(!session.usable(1500)); + EXPECT_FALSE(session.refreshable(2000)); } -} // namespace -int main() -{ - test_default_session_is_invalid(); - test_constructed_session_has_expected_values(); - test_session_setters_update_values(); - test_belongs_to_checks_user_id(); - test_session_requires_id_and_user_id_to_be_valid(); - test_expired_returns_true_after_expiration(); - test_zero_expiration_means_not_expired(); - test_usable_requires_valid_not_revoked_and_not_expired(); - - return 0; -} + TEST(SessionTests, MissingIdMakesSessionInvalid) + { + const Session session{ + "", + "user_11", + 1000, + 2000}; + + EXPECT_FALSE(session.valid()); + } + + TEST(SessionTests, MissingUserIdMakesSessionInvalid) + { + const Session session{ + "session_11", + "", + 1000, + 2000}; + + EXPECT_FALSE(session.valid()); + } +} // namespace diff --git a/tests/TokenTests.cpp b/tests/TokenTests.cpp index e24a617..95aa3f5 100644 --- a/tests/TokenTests.cpp +++ b/tests/TokenTests.cpp @@ -16,151 +16,177 @@ #include -#include -#include +#include namespace { - void test_default_token_is_invalid() + using rixlib::auth::Token; + + TEST(TokenTests, DefaultTokenIsInvalid) { - const rixlib::auth::Token token; - - assert(token.value().empty()); - assert(token.user_id().empty()); - assert(token.issuer().empty()); - assert(token.issued_at() == 0); - assert(token.expires_at() == 0); - assert(!token.revoked()); - assert(!token.valid()); + const Token token; + + EXPECT_TRUE(token.value().empty()); + EXPECT_TRUE(token.user_id().empty()); + EXPECT_TRUE(token.issuer().empty()); + EXPECT_EQ(token.issued_at(), 0); + EXPECT_EQ(token.expires_at(), 0); + EXPECT_FALSE(token.revoked()); + EXPECT_FALSE(token.valid()); } - void test_constructed_token_has_expected_values() + TEST(TokenTests, ConstructedTokenStoresFields) { - const std::int64_t issued_at = 1000; - const std::int64_t expires_at = 2000; - - const rixlib::auth::Token token{ - "token_value", + const Token token{ + "token_1", "user_1", - issued_at, - expires_at}; - - assert(token.value() == "token_value"); - assert(token.user_id() == "user_1"); - assert(token.issuer().empty()); - assert(token.issued_at() == issued_at); - assert(token.expires_at() == expires_at); - assert(!token.revoked()); - assert(token.valid()); + 1000, + 2000}; + + EXPECT_EQ(token.value(), "token_1"); + EXPECT_EQ(token.user_id(), "user_1"); + EXPECT_EQ(token.issued_at(), 1000); + EXPECT_EQ(token.expires_at(), 2000); + EXPECT_FALSE(token.revoked()); + EXPECT_TRUE(token.valid()); } - void test_token_setters_update_values() + TEST(TokenTests, SettersUpdateTokenFields) { - rixlib::auth::Token token; + Token token; token.set_value("token_2"); token.set_user_id("user_2"); token.set_issuer("rix/auth"); - token.set_issued_at(3000); - token.set_expires_at(4000); + token.set_issued_at(100); + token.set_expires_at(200); token.set_revoked(true); - assert(token.value() == "token_2"); - assert(token.user_id() == "user_2"); - assert(token.issuer() == "rix/auth"); - assert(token.issued_at() == 3000); - assert(token.expires_at() == 4000); - assert(token.revoked()); - assert(token.valid()); + EXPECT_EQ(token.value(), "token_2"); + EXPECT_EQ(token.user_id(), "user_2"); + EXPECT_EQ(token.issuer(), "rix/auth"); + EXPECT_EQ(token.issued_at(), 100); + EXPECT_EQ(token.expires_at(), 200); + EXPECT_TRUE(token.revoked()); + EXPECT_TRUE(token.valid()); } - void test_belongs_to_checks_user_id() + TEST(TokenTests, BelongsToReturnsTrueOnlyForMatchingUser) { - rixlib::auth::Token token; - token.set_user_id("user_3"); + const Token token{ + "token_3", + "user_3", + 1000, + 2000}; - assert(token.belongs_to("user_3")); - assert(!token.belongs_to("user_4")); - assert(!token.belongs_to("")); + EXPECT_TRUE(token.belongs_to("user_3")); + EXPECT_FALSE(token.belongs_to("user_4")); + EXPECT_FALSE(token.belongs_to("")); } - void test_matches_checks_token_value() + TEST(TokenTests, MatchesReturnsTrueOnlyForMatchingTokenValue) { - rixlib::auth::Token token; - token.set_value("secret-token"); + const Token token{ + "token_4", + "user_4", + 1000, + 2000}; - assert(token.matches("secret-token")); - assert(!token.matches("other-token")); - assert(!token.matches("")); + EXPECT_TRUE(token.matches("token_4")); + EXPECT_FALSE(token.matches("token_5")); + EXPECT_FALSE(token.matches("")); } - void test_token_requires_value_and_user_id_to_be_valid() + TEST(TokenTests, ExpiredReturnsFalseBeforeExpiration) { - rixlib::auth::Token token; - - assert(!token.valid()); - - token.set_value("token_3"); - assert(!token.valid()); - - token.set_user_id("user_3"); - assert(token.valid()); + const Token token{ + "token_5", + "user_5", + 1000, + 2000}; - token.set_value(""); - assert(!token.valid()); + EXPECT_FALSE(token.expired(1000)); + EXPECT_FALSE(token.expired(1999)); } - void test_expired_returns_true_after_expiration() + TEST(TokenTests, ExpiredReturnsTrueAtAndAfterExpiration) { - rixlib::auth::Token token; - token.set_expires_at(1000); + const Token token{ + "token_6", + "user_6", + 1000, + 2000}; - assert(!token.expired(999)); - assert(token.expired(1000)); - assert(token.expired(1001)); + EXPECT_TRUE(token.expired(2000)); + EXPECT_TRUE(token.expired(2001)); } - void test_zero_expiration_means_not_expired() + TEST(TokenTests, ZeroExpirationDoesNotExpire) { - rixlib::auth::Token token; - token.set_expires_at(0); + const Token token{ + "token_7", + "user_7", + 1000, + 0}; - assert(!token.expired(0)); - assert(!token.expired(1000)); + EXPECT_FALSE(token.expired(1000)); + EXPECT_FALSE(token.expired(999999)); } - void test_usable_requires_valid_not_revoked_and_not_expired() + TEST(TokenTests, UsableRequiresValidNotRevokedAndNotExpired) { - rixlib::auth::Token token{ - "token_4", - "user_4", + Token token{ + "token_8", + "user_8", 1000, 2000}; - assert(token.usable(1500)); + EXPECT_TRUE(token.usable(1500)); token.set_revoked(true); - assert(!token.usable(1500)); + + EXPECT_FALSE(token.usable(1500)); token.set_revoked(false); - assert(!token.usable(2000)); - token.set_value(""); - assert(!token.usable(1500)); + EXPECT_FALSE(token.usable(2000)); } -} // namespace -int main() -{ - test_default_token_is_invalid(); - test_constructed_token_has_expected_values(); - test_token_setters_update_values(); - test_belongs_to_checks_user_id(); - test_matches_checks_token_value(); - test_token_requires_value_and_user_id_to_be_valid(); - test_expired_returns_true_after_expiration(); - test_zero_expiration_means_not_expired(); - test_usable_requires_valid_not_revoked_and_not_expired(); - - return 0; -} + TEST(TokenTests, RevokeMarksTokenAsRevoked) + { + Token token{ + "token_9", + "user_9", + 1000, + 2000}; + + EXPECT_FALSE(token.revoked()); + + token.revoke(); + + EXPECT_TRUE(token.revoked()); + EXPECT_FALSE(token.usable(1500)); + } + + TEST(TokenTests, MissingValueMakesTokenInvalid) + { + const Token token{ + "", + "user_10", + 1000, + 2000}; + + EXPECT_FALSE(token.valid()); + } + + TEST(TokenTests, MissingUserIdMakesTokenInvalid) + { + const Token token{ + "token_10", + "", + 1000, + 2000}; + + EXPECT_FALSE(token.valid()); + } +} // namespace diff --git a/tests/UserTests.cpp b/tests/UserTests.cpp index 3ea27b3..5961942 100644 --- a/tests/UserTests.cpp +++ b/tests/UserTests.cpp @@ -16,102 +16,120 @@ #include -#include -#include -#include +#include namespace { - void test_default_user_is_invalid() + using rixlib::auth::User; + + TEST(UserTests, DefaultUserIsInvalid) { - const rixlib::auth::User user; - - assert(user.id().empty()); - assert(user.email().empty()); - assert(user.password_hash().empty()); - assert(!user.email_verified()); - assert(user.active()); - assert(user.created_at() == 0); - assert(user.updated_at() == 0); - assert(!user.valid()); + const User user; + + EXPECT_TRUE(user.id().empty()); + EXPECT_TRUE(user.email().empty()); + EXPECT_TRUE(user.password_hash().empty()); + EXPECT_FALSE(user.email_verified()); + EXPECT_TRUE(user.active()); + EXPECT_EQ(user.created_at(), 0); + EXPECT_EQ(user.updated_at(), 0); + EXPECT_FALSE(user.valid()); } - void test_constructed_user_has_expected_values() + TEST(UserTests, ConstructedUserStoresIdentityAndTimestamps) { - const std::int64_t created_at = 1000; - - const rixlib::auth::User user{ + const User user{ "user_1", "ada@example.com", - "hashed-password", - created_at}; - - assert(user.id() == "user_1"); - assert(user.email() == "ada@example.com"); - assert(user.password_hash() == "hashed-password"); - assert(user.created_at() == created_at); - assert(user.updated_at() == created_at); - assert(user.active()); - assert(!user.email_verified()); - assert(user.valid()); + "encoded-password-hash", + 1000}; + + EXPECT_EQ(user.id(), "user_1"); + EXPECT_EQ(user.email(), "ada@example.com"); + EXPECT_EQ(user.password_hash(), "encoded-password-hash"); + EXPECT_EQ(user.created_at(), 1000); + EXPECT_EQ(user.updated_at(), 1000); + EXPECT_TRUE(user.valid()); } - void test_user_setters_update_values() + TEST(UserTests, SettersUpdateUserFields) { - rixlib::auth::User user; + User user; user.set_id("user_2"); user.set_email("grace@example.com"); - user.set_password_hash("new-hash"); + user.set_password_hash("hash"); user.set_email_verified(true); user.set_active(false); user.set_created_at(2000); user.set_updated_at(3000); - assert(user.id() == "user_2"); - assert(user.email() == "grace@example.com"); - assert(user.password_hash() == "new-hash"); - assert(user.email_verified()); - assert(!user.active()); - assert(user.created_at() == 2000); - assert(user.updated_at() == 3000); - assert(user.valid()); + EXPECT_EQ(user.id(), "user_2"); + EXPECT_EQ(user.email(), "grace@example.com"); + EXPECT_EQ(user.password_hash(), "hash"); + EXPECT_TRUE(user.email_verified()); + EXPECT_FALSE(user.active()); + EXPECT_EQ(user.created_at(), 2000); + EXPECT_EQ(user.updated_at(), 3000); + EXPECT_TRUE(user.valid()); } - void test_has_email_checks_exact_email() + TEST(UserTests, HasIdReturnsTrueOnlyForMatchingId) { - rixlib::auth::User user; - user.set_email("user@example.com"); + const User user{ + "user_3", + "linus@example.com", + "hash", + 1000}; + + EXPECT_TRUE(user.has_id("user_3")); + EXPECT_FALSE(user.has_id("user_4")); + EXPECT_FALSE(user.has_id("")); + } - assert(user.has_email("user@example.com")); - assert(!user.has_email("other@example.com")); - assert(!user.has_email("")); + TEST(UserTests, HasEmailReturnsTrueOnlyForMatchingEmail) + { + const User user{ + "user_4", + "bjarne@example.com", + "hash", + 1000}; + + EXPECT_TRUE(user.has_email("bjarne@example.com")); + EXPECT_FALSE(user.has_email("other@example.com")); + EXPECT_FALSE(user.has_email("")); } - void test_user_requires_id_and_email_to_be_valid() + TEST(UserTests, MissingIdMakesUserInvalid) { - rixlib::auth::User user; + const User user{ + "", + "ada@example.com", + "hash", + 1000}; - assert(!user.valid()); + EXPECT_FALSE(user.valid()); + } - user.set_id("user_3"); - assert(!user.valid()); + TEST(UserTests, MissingEmailMakesUserInvalid) + { + const User user{ + "user_5", + "", + "hash", + 1000}; - user.set_email("user3@example.com"); - assert(user.valid()); + EXPECT_FALSE(user.valid()); + } + + TEST(UserTests, MissingPasswordHashMakesUserInvalid) + { + const User user{ + "user_6", + "ada@example.com", + "", + 1000}; - user.set_id(""); - assert(!user.valid()); + EXPECT_FALSE(user.valid()); } } // namespace - -int main() -{ - test_default_user_is_invalid(); - test_constructed_user_has_expected_values(); - test_user_setters_update_values(); - test_has_email_checks_exact_email(); - test_user_requires_id_and_email_to_be_valid(); - - return 0; -} diff --git a/vix.json b/vix.json index 15ddf1c..909de2b 100644 --- a/vix.json +++ b/vix.json @@ -1,7 +1,7 @@ { "name": "auth", "namespace": "rix", - "version": "0.1.0", + "version": "0.2.0", "type": "library", "include": "include", "license": "MIT", From c6628f24ac2fe8eec17f3b5a2129716ee51a95d5 Mon Sep 17 00:00:00 2001 From: Gaspard Kirira Date: Tue, 9 Jun 2026 10:20:21 +0300 Subject: [PATCH 05/10] fix(auth): align Vix dependency and password hash integration --- CMakeLists.txt | 239 ++++++++++++++++++++++++++------- cmake/RixAuthConfig.cmake.in | 25 ++++ include/rix/auth/AuthError.hpp | 1 + src/AuthError.cpp | 2 + src/PasswordHasher.cpp | 10 +- tests/PasswordHasherTests.cpp | 2 +- vix.json | 2 +- 7 files changed, 227 insertions(+), 54 deletions(-) create mode 100644 cmake/RixAuthConfig.cmake.in diff --git a/CMakeLists.txt b/CMakeLists.txt index 1bc80cb..d2965e2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -7,58 +7,169 @@ project(rix_auth ) include(GNUInstallDirs) +include(CMakePackageConfigHelpers) + +# ------------------------------------------------------------------------------ +# Options +# ------------------------------------------------------------------------------ option(RIX_AUTH_BUILD_EXAMPLES "Build rix/auth examples" ON) option(RIX_AUTH_BUILD_TESTS "Build rix/auth tests" ON) +option(RIX_AUTH_INSTALL "Install rix/auth package" ON) + +option(RIX_AUTH_ENABLE_LOCAL_DEPS + "Allow rix/auth to use explicitly provided local dependency source trees" + ON +) + +set(RIX_AUTH_VIX_SOURCE_DIR + "" + CACHE PATH "Optional path to a local Vix source tree" +) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_POSITION_INDEPENDENT_CODE ON) # ------------------------------------------------------------------------------ -# Dependencies +# Helpers # ------------------------------------------------------------------------------ -if(NOT TARGET vix::crypto) - if(EXISTS "${CMAKE_CURRENT_LIST_DIR}/../../vix/modules/crypto/CMakeLists.txt") - add_subdirectory("${CMAKE_CURRENT_LIST_DIR}/../../vix/modules/crypto" vix_crypto_build) - elseif(EXISTS "${CMAKE_CURRENT_LIST_DIR}/../crypto/CMakeLists.txt") - add_subdirectory("${CMAKE_CURRENT_LIST_DIR}/../crypto" vix_crypto_build) - else() - find_package(Vix REQUIRED COMPONENTS crypto) - endif() +function(rix_auth_find_target output_var) + foreach(candidate IN LISTS ARGN) + if(TARGET "${candidate}") + set(${output_var} "${candidate}" PARENT_SCOPE) + return() + endif() + endforeach() + + set(${output_var} "" PARENT_SCOPE) +endfunction() + +# ------------------------------------------------------------------------------ +# Default dependency prefixes +# ------------------------------------------------------------------------------ + +set(RIX_AUTH_DEFAULT_PREFIXES) + +if(DEFINED ENV{HOME}) + list(APPEND RIX_AUTH_DEFAULT_PREFIXES + "$ENV{HOME}/.local" + "$ENV{HOME}/.vix" + ) endif() -if(NOT TARGET vix::validation) - if(EXISTS "${CMAKE_CURRENT_LIST_DIR}/../../vix/modules/validation/CMakeLists.txt") - add_subdirectory("${CMAKE_CURRENT_LIST_DIR}/../../vix/modules/validation" vix_validation_build) - elseif(EXISTS "${CMAKE_CURRENT_LIST_DIR}/../validation/CMakeLists.txt") - add_subdirectory("${CMAKE_CURRENT_LIST_DIR}/../validation" vix_validation_build) - else() - find_package(Vix REQUIRED COMPONENTS validation) - endif() +if(DEFINED ENV{LOCALAPPDATA}) + list(APPEND RIX_AUTH_DEFAULT_PREFIXES + "$ENV{LOCALAPPDATA}/Vix" + ) endif() -if(NOT TARGET vix::time) - if(EXISTS "${CMAKE_CURRENT_LIST_DIR}/../../vix/modules/time/CMakeLists.txt") - add_subdirectory("${CMAKE_CURRENT_LIST_DIR}/../../vix/modules/time" vix_time_build) - elseif(EXISTS "${CMAKE_CURRENT_LIST_DIR}/../time/CMakeLists.txt") - add_subdirectory("${CMAKE_CURRENT_LIST_DIR}/../time" vix_time_build) - else() - find_package(Vix REQUIRED COMPONENTS time) +foreach(prefix IN LISTS RIX_AUTH_DEFAULT_PREFIXES) + if(EXISTS "${prefix}") + list(PREPEND CMAKE_PREFIX_PATH "${prefix}") endif() -endif() +endforeach() + +list(REMOVE_DUPLICATES CMAKE_PREFIX_PATH) + +# ------------------------------------------------------------------------------ +# Optional local source tree +# ------------------------------------------------------------------------------ -if(NOT TARGET vix::db) - if(EXISTS "${CMAKE_CURRENT_LIST_DIR}/../../vix/modules/db/CMakeLists.txt") - add_subdirectory("${CMAKE_CURRENT_LIST_DIR}/../../vix/modules/db" vix_db_build) - elseif(EXISTS "${CMAKE_CURRENT_LIST_DIR}/../db/CMakeLists.txt") - add_subdirectory("${CMAKE_CURRENT_LIST_DIR}/../db" vix_db_build) - else() - find_package(Vix REQUIRED COMPONENTS db) +if(RIX_AUTH_ENABLE_LOCAL_DEPS) + if(NOT "${RIX_AUTH_VIX_SOURCE_DIR}" STREQUAL "") + if(EXISTS "${RIX_AUTH_VIX_SOURCE_DIR}/CMakeLists.txt") + add_subdirectory( + "${RIX_AUTH_VIX_SOURCE_DIR}" + "${CMAKE_BINARY_DIR}/_deps/vix" + ) + else() + message(FATAL_ERROR + "RIX_AUTH_VIX_SOURCE_DIR is set, but no CMakeLists.txt was found.\n" + "Path: ${RIX_AUTH_VIX_SOURCE_DIR}" + ) + endif() endif() endif() +# ------------------------------------------------------------------------------ +# Installed packages +# ------------------------------------------------------------------------------ + +find_package(Vix CONFIG QUIET) + +# ------------------------------------------------------------------------------ +# Vix dependency targets +# ------------------------------------------------------------------------------ + +rix_auth_find_target(RIX_AUTH_VIX_TARGET + vix::vix + Vix::Vix + vix +) + +rix_auth_find_target(RIX_AUTH_VIX_CRYPTO_TARGET + vix::crypto + vix_crypto +) + +rix_auth_find_target(RIX_AUTH_VIX_VALIDATION_TARGET + vix::validation + vix_validation +) + +rix_auth_find_target(RIX_AUTH_VIX_TIME_TARGET + vix::time + vix_time +) + +rix_auth_find_target(RIX_AUTH_VIX_DB_TARGET + vix::db + vix_db +) + +if("${RIX_AUTH_VIX_TARGET}" STREQUAL "" + AND "${RIX_AUTH_VIX_CRYPTO_TARGET}" STREQUAL "" + AND "${RIX_AUTH_VIX_VALIDATION_TARGET}" STREQUAL "" + AND "${RIX_AUTH_VIX_TIME_TARGET}" STREQUAL "" + AND "${RIX_AUTH_VIX_DB_TARGET}" STREQUAL "") + message(FATAL_ERROR + "Vix development package was not found.\n" + "Install Vix first or pass -DRIX_AUTH_VIX_SOURCE_DIR=/path/to/vix.\n" + "Expected CMake package: VixConfig.cmake\n" + "Expected target: vix::vix, Vix::Vix, vix, or individual vix::* module targets." + ) +endif() + +set(RIX_AUTH_PUBLIC_DEPENDENCIES) + +if(NOT "${RIX_AUTH_VIX_TARGET}" STREQUAL "") + list(APPEND RIX_AUTH_PUBLIC_DEPENDENCIES + ${RIX_AUTH_VIX_TARGET} + ) +endif() + +foreach(candidate + ${RIX_AUTH_VIX_CRYPTO_TARGET} + ${RIX_AUTH_VIX_VALIDATION_TARGET} + ${RIX_AUTH_VIX_TIME_TARGET} + ${RIX_AUTH_VIX_DB_TARGET} +) + if(NOT "${candidate}" STREQUAL "") + list(APPEND RIX_AUTH_PUBLIC_DEPENDENCIES "${candidate}") + endif() +endforeach() + +list(REMOVE_DUPLICATES RIX_AUTH_PUBLIC_DEPENDENCIES) + +message(STATUS "[rix/auth] Vix target: ${RIX_AUTH_VIX_TARGET}") +message(STATUS "[rix/auth] Vix crypto: ${RIX_AUTH_VIX_CRYPTO_TARGET}") +message(STATUS "[rix/auth] Vix validation: ${RIX_AUTH_VIX_VALIDATION_TARGET}") +message(STATUS "[rix/auth] Vix time: ${RIX_AUTH_VIX_TIME_TARGET}") +message(STATUS "[rix/auth] Vix db: ${RIX_AUTH_VIX_DB_TARGET}") +message(STATUS "[rix/auth] Vix deps: ${RIX_AUTH_PUBLIC_DEPENDENCIES}") + # ------------------------------------------------------------------------------ # Library # ------------------------------------------------------------------------------ @@ -96,10 +207,7 @@ target_include_directories(rix_auth target_link_libraries(rix_auth PUBLIC - vix::crypto - vix::validation - vix::time - vix::db + ${RIX_AUTH_PUBLIC_DEPENDENCIES} ) set_target_properties(rix_auth PROPERTIES @@ -137,15 +245,52 @@ endif() # Install # ------------------------------------------------------------------------------ -install(TARGETS rix_auth - EXPORT RixAuthTargets - ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} - LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} - RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} - INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR} -) +if(RIX_AUTH_INSTALL) + install(TARGETS rix_auth + EXPORT RixAuthTargets + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} + INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR} + ) -install(DIRECTORY include/ - DESTINATION ${CMAKE_INSTALL_INCLUDEDIR} - FILES_MATCHING PATTERN "*.hpp" PATTERN "*.h" -) + install(DIRECTORY include/ + DESTINATION ${CMAKE_INSTALL_INCLUDEDIR} + FILES_MATCHING + PATTERN "*.hpp" + PATTERN "*.h" + ) + + install(EXPORT RixAuthTargets + FILE RixAuthTargets.cmake + NAMESPACE rix:: + DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/rix_auth" + ) + + write_basic_package_version_file( + "${CMAKE_CURRENT_BINARY_DIR}/RixAuthConfigVersion.cmake" + VERSION ${PROJECT_VERSION} + COMPATIBILITY SameMajorVersion + ) + + configure_package_config_file( + "${CMAKE_CURRENT_SOURCE_DIR}/cmake/RixAuthConfig.cmake.in" + "${CMAKE_CURRENT_BINARY_DIR}/RixAuthConfig.cmake" + INSTALL_DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/rix_auth" + ) + + install( + FILES + "${CMAKE_CURRENT_BINARY_DIR}/RixAuthConfig.cmake" + "${CMAKE_CURRENT_BINARY_DIR}/RixAuthConfigVersion.cmake" + DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/rix_auth" + ) + + install( + FILES + "${CMAKE_CURRENT_SOURCE_DIR}/README.md" + "${CMAKE_CURRENT_SOURCE_DIR}/LICENSE" + DESTINATION "${CMAKE_INSTALL_DATADIR}/rix_auth" + OPTIONAL + ) +endif() diff --git a/cmake/RixAuthConfig.cmake.in b/cmake/RixAuthConfig.cmake.in new file mode 100644 index 0000000..84ebd2d --- /dev/null +++ b/cmake/RixAuthConfig.cmake.in @@ -0,0 +1,25 @@ +@PACKAGE_INIT@ + +include(CMakeFindDependencyMacro) + +# ------------------------------------------------------------------------------ +# Dependencies +# ------------------------------------------------------------------------------ + +find_dependency(Vix CONFIG) + +# ------------------------------------------------------------------------------ +# Targets +# ------------------------------------------------------------------------------ + +include("${CMAKE_CURRENT_LIST_DIR}/RixAuthTargets.cmake") + +# ------------------------------------------------------------------------------ +# Compatibility aliases +# ------------------------------------------------------------------------------ + +if(TARGET rix::auth AND NOT TARGET rix_auth) + add_library(rix_auth ALIAS rix::auth) +endif() + +check_required_components(RixAuth) diff --git a/include/rix/auth/AuthError.hpp b/include/rix/auth/AuthError.hpp index cddf914..77ed2e8 100644 --- a/include/rix/auth/AuthError.hpp +++ b/include/rix/auth/AuthError.hpp @@ -33,6 +33,7 @@ namespace rixlib::auth None, InvalidInput, + InvalidState, InvalidEmail, InvalidPassword, diff --git a/src/AuthError.cpp b/src/AuthError.cpp index b1dc530..d5b111d 100644 --- a/src/AuthError.cpp +++ b/src/AuthError.cpp @@ -60,6 +60,8 @@ namespace rixlib::auth case AuthErrorCode::InvalidInput: return "InvalidInput"; + case AuthErrorCode::InvalidState: + return "invalid_state"; case AuthErrorCode::InvalidEmail: return "InvalidEmail"; case AuthErrorCode::InvalidPassword: diff --git a/src/PasswordHasher.cpp b/src/PasswordHasher.cpp index 8a65fb0..9909174 100644 --- a/src/PasswordHasher.cpp +++ b/src/PasswordHasher.cpp @@ -70,12 +70,12 @@ namespace rixlib::auth make_password_error("Password exceeds the maximum length policy.")); } - vix::crypto::PasswordHashOptions options; - options.iterations = iterations_; - options.salt_size = salt_size_; - options.hash_size = hash_size_; + vix::crypto::PasswordHashParams params; + params.iterations = iterations_; + params.salt_size = salt_size_; + params.hash_size = hash_size_; - auto hashed = vix::crypto::password_hash(password, options); + auto hashed = vix::crypto::password_hash(password, params); if (!hashed.ok()) { diff --git a/tests/PasswordHasherTests.cpp b/tests/PasswordHasherTests.cpp index 015d186..10b0911 100644 --- a/tests/PasswordHasherTests.cpp +++ b/tests/PasswordHasherTests.cpp @@ -76,7 +76,7 @@ namespace const auto result = hasher.hash("correct-password"); ASSERT_TRUE(result.ok()); - EXPECT_NE(result.value().find("rix-auth$"), std::string::npos); + EXPECT_EQ(result.value().find("vix-pbkdf2-sha256$"), 0U); } TEST(PasswordHasherTests, VerifyAcceptsMatchingPassword) diff --git a/vix.json b/vix.json index 909de2b..15ddf1c 100644 --- a/vix.json +++ b/vix.json @@ -1,7 +1,7 @@ { "name": "auth", "namespace": "rix", - "version": "0.2.0", + "version": "0.1.0", "type": "library", "include": "include", "license": "MIT", From d47790d82f4ead5a30975a2d554614e379e063f3 Mon Sep 17 00:00:00 2001 From: Gaspard Kirira Date: Tue, 9 Jun 2026 10:23:28 +0300 Subject: [PATCH 06/10] ci: add rix auth build and test workflow --- .github/workflows/ci.yml | 485 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 485 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..80f43e6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,485 @@ +name: CI + +on: + push: + branches: + - main + - dev + - release/** + pull_request: + branches: + - main + - dev + - release/** + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: rix-auth-ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + VIX_VERSION: v2.6.2 + BUILD_JOBS: 2 + +jobs: + build-test-install: + name: ${{ matrix.os }} / ${{ matrix.build_type }} / tests=${{ matrix.tests }} / examples=${{ matrix.examples }} + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-24.04 + build_type: Debug + tests: ON + examples: OFF + + - os: ubuntu-24.04 + build_type: Release + tests: ON + examples: ON + + - os: macos-14 + build_type: Release + tests: ON + examples: ON + + - os: windows-2022 + build_type: Release + tests: ON + examples: OFF + + steps: + - name: Checkout rix/auth + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup CMake and Ninja + uses: lukka/get-cmake@latest + + - name: Setup MSVC + if: runner.os == 'Windows' + uses: ilammy/msvc-dev-cmd@v1 + + - name: Install Windows dependencies + if: runner.os == 'Windows' + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + + choco install pkgconfiglite -y + + git clone https://github.com/microsoft/vcpkg "$env:GITHUB_WORKSPACE/vcpkg" + & "$env:GITHUB_WORKSPACE/vcpkg/bootstrap-vcpkg.bat" + + & "$env:GITHUB_WORKSPACE/vcpkg/vcpkg.exe" install ` + fmt:x64-windows ` + spdlog:x64-windows ` + nlohmann-json:x64-windows ` + openssl:x64-windows ` + sqlite3:x64-windows ` + gtest:x64-windows + + "VCPKG_ROOT=$env:GITHUB_WORKSPACE/vcpkg" | Out-File -FilePath $env:GITHUB_ENV -Append + "CMAKE_TOOLCHAIN_FILE=$env:GITHUB_WORKSPACE/vcpkg/scripts/buildsystems/vcpkg.cmake" | Out-File -FilePath $env:GITHUB_ENV -Append + + - name: Install Linux dependencies + if: runner.os == 'Linux' + shell: bash + run: | + set -euxo pipefail + + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential \ + cmake \ + ninja-build \ + git \ + curl \ + ca-certificates \ + tar \ + zip \ + unzip \ + pkg-config \ + libasio-dev \ + libssl-dev \ + libsqlite3-dev \ + nlohmann-json3-dev \ + libfmt-dev \ + libspdlog-dev \ + libgtest-dev + + - name: Install macOS dependencies + if: runner.os == 'macOS' + shell: bash + run: | + set -euxo pipefail + + brew update + brew install \ + cmake \ + ninja \ + git \ + curl \ + pkg-config \ + openssl@3 \ + sqlite \ + nlohmann-json \ + spdlog \ + fmt \ + googletest + + - name: Clean workspace + shell: bash + run: | + set -euxo pipefail + + rm -rf \ + build \ + install \ + deps \ + smoke-rix-auth \ + logs + + - name: Install Vix SDK Unix + if: runner.os != 'Windows' + shell: bash + run: | + set -euxo pipefail + + if curl -fsSL https://vixcpp.com/install.sh | VIX_VERSION="${VIX_VERSION}" VIX_INSTALL_KIND=sdk sh; then + test -f "$HOME/.local/lib/cmake/Vix/VixConfig.cmake" + test -f "$HOME/.local/include/vix.hpp" + + echo "VIX_PREFIX=$HOME/.local" >> "$GITHUB_ENV" + exit 0 + fi + + echo "Vix SDK binary install failed. Building Vix from source instead." + + git clone --depth 1 --branch "${VIX_VERSION}" --recurse-submodules https://github.com/vixcpp/vix.git deps/vix + + cmake -S deps/vix -B deps/vix/build -G Ninja \ + -DCMAKE_BUILD_TYPE=${{ matrix.build_type }} \ + -DCMAKE_INSTALL_PREFIX="${{ github.workspace }}/deps/vix-install" \ + -DVIX_ENABLE_INSTALL=ON \ + -DVIX_BUILD_EXAMPLES=OFF \ + -DVIX_BUILD_TESTS=OFF \ + -DVIX_ENABLE_WARNINGS=ON \ + -DVIX_ENABLE_HTTP_COMPRESSION=OFF \ + -DVIX_ENABLE_DB=ON \ + -DVIX_DB_USE_SQLITE=ON \ + -DVIX_DB_USE_MYSQL=OFF \ + -DVIX_DB_USE_POSTGRES=OFF \ + -DVIX_DB_USE_REDIS=OFF \ + -DVIX_CORE_WITH_MYSQL=OFF \ + -DVIX_ENABLE_ORM=OFF \ + -DVIX_ENABLE_WEBSOCKET=OFF \ + -DVIX_ENABLE_CLI=OFF \ + -DVIX_ENABLE_MIDDLEWARE=OFF \ + -DVIX_ENABLE_P2P=OFF \ + -DVIX_ENABLE_P2P_HTTP=OFF \ + -DVIX_ENABLE_CACHE=OFF \ + -DVIX_ENABLE_ASYNC=OFF \ + -DVIX_ENABLE_VALIDATION=ON \ + -DVIX_ENABLE_CRYPTO=ON \ + -DVIX_ENABLE_WEBRPC=OFF \ + -DVIX_ENABLE_TIME=ON \ + -DVIX_ENABLE_TESTS_MODULE=OFF \ + -DVIX_ENABLE_TEMPLATE=OFF \ + -DVIX_ENABLE_PROCESS=OFF \ + -DVIX_ENABLE_THREADPOOL=OFF \ + -DVIX_ENABLE_KV=OFF \ + -DVIX_ENABLE_AGENT=OFF \ + -DVIX_ENABLE_GAME=OFF \ + -DVIX_TIME_BUILD_TESTS=OFF \ + -DVIX_TIME_BUILD_BENCH=OFF + + cmake --build deps/vix/build --config ${{ matrix.build_type }} -j"${BUILD_JOBS}" + cmake --install deps/vix/build --config ${{ matrix.build_type }} + + test -f "${{ github.workspace }}/deps/vix-install/lib/cmake/Vix/VixConfig.cmake" + test -f "${{ github.workspace }}/deps/vix-install/include/vix.hpp" + + echo "VIX_PREFIX=${{ github.workspace }}/deps/vix-install" >> "$GITHUB_ENV" + + - name: Install Vix SDK Windows + if: runner.os == 'Windows' + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + + $vixPrefix = "$env:GITHUB_WORKSPACE/deps/vix-install" + $vixBinaryPrefix = "$env:GITHUB_WORKSPACE/deps/vix-sdk" + $asset = "vix-sdk-windows-x86_64.zip" + $url = "https://github.com/vixcpp/vix/releases/download/$env:VIX_VERSION/$asset" + + New-Item -ItemType Directory -Force -Path "$env:GITHUB_WORKSPACE/deps" | Out-Null + + try { + Write-Host "Trying Vix SDK binary: $url" + + Invoke-WebRequest -Uri "$url" -OutFile "$asset" + + New-Item -ItemType Directory -Force -Path "$vixBinaryPrefix" | Out-Null + Expand-Archive -Path "$asset" -DestinationPath "$vixBinaryPrefix" -Force + + if (!(Test-Path "$vixBinaryPrefix/lib/cmake/Vix/VixConfig.cmake")) { + throw "Downloaded SDK does not contain VixConfig.cmake" + } + + if (!(Test-Path "$vixBinaryPrefix/include/vix.hpp")) { + throw "Downloaded SDK does not contain vix.hpp" + } + + "VIX_PREFIX=$vixBinaryPrefix" | Out-File -FilePath $env:GITHUB_ENV -Append + Write-Host "Using Vix SDK binary at $vixBinaryPrefix" + exit 0 + } + catch { + Write-Host "Vix SDK binary is not available for Windows. Building Vix from source." + Write-Host $_ + } + + git clone --depth 1 --branch "$env:VIX_VERSION" --recurse-submodules https://github.com/vixcpp/vix.git deps/vix + + cmake -S deps/vix -B deps/vix/build -G Ninja ` + -DCMAKE_BUILD_TYPE=${{ matrix.build_type }} ` + -DCMAKE_TOOLCHAIN_FILE="$env:CMAKE_TOOLCHAIN_FILE" ` + -DVCPKG_TARGET_TRIPLET=x64-windows ` + -DCMAKE_INSTALL_PREFIX="$vixPrefix" ` + -DVIX_ENABLE_INSTALL=ON ` + -DVIX_BUILD_EXAMPLES=OFF ` + -DVIX_BUILD_TESTS=OFF ` + -DVIX_ENABLE_WARNINGS=ON ` + -DVIX_ENABLE_HTTP_COMPRESSION=OFF ` + -DVIX_ENABLE_DB=ON ` + -DVIX_DB_USE_SQLITE=ON ` + -DVIX_DB_USE_MYSQL=OFF ` + -DVIX_DB_USE_POSTGRES=OFF ` + -DVIX_DB_USE_REDIS=OFF ` + -DVIX_CORE_WITH_MYSQL=OFF ` + -DVIX_ENABLE_ORM=OFF ` + -DVIX_ENABLE_WEBSOCKET=OFF ` + -DVIX_ENABLE_CLI=OFF ` + -DVIX_ENABLE_MIDDLEWARE=OFF ` + -DVIX_ENABLE_P2P=OFF ` + -DVIX_ENABLE_P2P_HTTP=OFF ` + -DVIX_ENABLE_CACHE=OFF ` + -DVIX_ENABLE_ASYNC=OFF ` + -DVIX_ENABLE_VALIDATION=ON ` + -DVIX_ENABLE_CRYPTO=ON ` + -DVIX_ENABLE_WEBRPC=OFF ` + -DVIX_ENABLE_TIME=ON ` + -DVIX_ENABLE_TESTS_MODULE=OFF ` + -DVIX_ENABLE_TEMPLATE=OFF ` + -DVIX_ENABLE_PROCESS=OFF ` + -DVIX_ENABLE_THREADPOOL=OFF ` + -DVIX_ENABLE_KV=OFF ` + -DVIX_ENABLE_AGENT=OFF ` + -DVIX_ENABLE_GAME=OFF ` + -DVIX_TIME_BUILD_TESTS=OFF ` + -DVIX_TIME_BUILD_BENCH=OFF + + cmake --build deps/vix/build --config ${{ matrix.build_type }} -j "$env:BUILD_JOBS" + cmake --install deps/vix/build --config ${{ matrix.build_type }} + + if (!(Test-Path "$vixPrefix/lib/cmake/Vix/VixConfig.cmake")) { + Write-Host "VixConfig.cmake not found after source build" + Get-ChildItem -Recurse "$vixPrefix" | Select-Object FullName + exit 1 + } + + if (!(Test-Path "$vixPrefix/include/vix.hpp")) { + Write-Host "vix.hpp not found after source build" + Get-ChildItem -Recurse "$vixPrefix" | Select-Object FullName + exit 1 + } + + "VIX_PREFIX=$vixPrefix" | Out-File -FilePath $env:GITHUB_ENV -Append + Write-Host "Using source-built Vix SDK at $vixPrefix" + + - name: Show Vix SDK prefix + shell: bash + run: | + set -euxo pipefail + + echo "VIX_PREFIX=$VIX_PREFIX" + test -n "$VIX_PREFIX" + + - name: Configure rix/auth + shell: bash + run: | + set -euxo pipefail + + CMAKE_ARGS=( + -S . -B build -G Ninja + -DCMAKE_BUILD_TYPE=${{ matrix.build_type }} + "-DCMAKE_PREFIX_PATH=$VIX_PREFIX" + -DCMAKE_INSTALL_PREFIX="${{ github.workspace }}/install" + -DRIX_AUTH_BUILD_TESTS=${{ matrix.tests }} + -DRIX_AUTH_BUILD_EXAMPLES=${{ matrix.examples }} + -DRIX_AUTH_INSTALL=ON + -DRIX_AUTH_ENABLE_LOCAL_DEPS=OFF + ) + + if [ "${{ runner.os }}" = "Windows" ]; then + CMAKE_ARGS+=( + "-DCMAKE_TOOLCHAIN_FILE=$CMAKE_TOOLCHAIN_FILE" + -DVCPKG_TARGET_TRIPLET=x64-windows + ) + fi + + cmake "${CMAKE_ARGS[@]}" + + - name: Build rix/auth + shell: bash + run: | + set -euxo pipefail + + cmake --build build --config ${{ matrix.build_type }} -j"${BUILD_JOBS}" + + - name: Run rix/auth tests + if: matrix.tests == 'ON' + shell: bash + run: | + set -euxo pipefail + + ctest --test-dir build --build-config ${{ matrix.build_type }} --output-on-failure + + - name: Install rix/auth + shell: bash + run: | + set -euxo pipefail + + cmake --install build --config ${{ matrix.build_type }} + + - name: Validate installed rix/auth package tree + shell: bash + run: | + set -euxo pipefail + + test -d install/include/rix/auth + test -f install/lib/cmake/rix_auth/RixAuthConfig.cmake + test -f install/lib/cmake/rix_auth/RixAuthConfigVersion.cmake + test -f install/lib/cmake/rix_auth/RixAuthTargets.cmake + + find install/include -maxdepth 5 -type f | sort > installed-rix-auth-headers.txt + find install/lib/cmake -maxdepth 5 -type f | sort > installed-rix-auth-cmake.txt + + sed -n '1,120p' installed-rix-auth-headers.txt + cat installed-rix-auth-cmake.txt + + - name: Smoke test installed rix/auth package + shell: bash + run: | + set -euxo pipefail + + rm -rf smoke-rix-auth + mkdir -p smoke-rix-auth + + cat > smoke-rix-auth/CMakeLists.txt <<'CMAKE_EOF' + cmake_minimum_required(VERSION 3.20) + project(rix_auth_consumer LANGUAGES CXX) + + set(CMAKE_CXX_STANDARD 20) + set(CMAKE_CXX_STANDARD_REQUIRED ON) + set(CMAKE_CXX_EXTENSIONS OFF) + + find_package(RixAuth CONFIG REQUIRED) + + add_executable(app main.cpp) + target_link_libraries(app PRIVATE rix::auth) + CMAKE_EOF + + cat > smoke-rix-auth/main.cpp <<'CPP_EOF' + #include + #include + + int main() + { + rixlib::auth::AuthConfig config; + rixlib::auth::PasswordHasher hasher{config}; + + auto hash = hasher.hash("correct-password"); + if (hash.failed()) + { + return 1; + } + + if (!hasher.verify("correct-password", hash.value())) + { + return 2; + } + + return 0; + } + CPP_EOF + + SMOKE_ARGS=( + -S smoke-rix-auth -B smoke-rix-auth/build -G Ninja + -DCMAKE_BUILD_TYPE=${{ matrix.build_type }} + "-DCMAKE_PREFIX_PATH=${{ github.workspace }}/install;$VIX_PREFIX" + ) + + if [ "${{ runner.os }}" = "Windows" ]; then + SMOKE_ARGS+=( + "-DCMAKE_TOOLCHAIN_FILE=$CMAKE_TOOLCHAIN_FILE" + -DVCPKG_TARGET_TRIPLET=x64-windows + ) + fi + + cmake "${SMOKE_ARGS[@]}" + + cmake --build smoke-rix-auth/build --config ${{ matrix.build_type }} -j"${BUILD_JOBS}" + + if [ "${{ runner.os }}" = "Windows" ]; then + ./smoke-rix-auth/build/app.exe + else + ./smoke-rix-auth/build/app + fi + + - name: Collect logs + if: always() + shell: bash + run: | + set +e + + OUT="logs/${{ matrix.os }}-${{ matrix.build_type }}" + mkdir -p "$OUT" + + echo "runner.os=${{ runner.os }}" > "$OUT/context.txt" + echo "matrix.os=${{ matrix.os }}" >> "$OUT/context.txt" + echo "matrix.build_type=${{ matrix.build_type }}" >> "$OUT/context.txt" + echo "VIX_PREFIX=${VIX_PREFIX:-}" >> "$OUT/context.txt" + + for file in \ + build/CMakeCache.txt \ + build/CMakeFiles/CMakeOutput.log \ + build/CMakeFiles/CMakeError.log \ + build/CMakeFiles/CMakeConfigureLog.yaml \ + build/Testing/Temporary/LastTest.log \ + smoke-rix-auth/build/CMakeCache.txt \ + smoke-rix-auth/build/CMakeFiles/CMakeOutput.log \ + smoke-rix-auth/build/CMakeFiles/CMakeError.log + do + if [ -f "$file" ]; then + mkdir -p "$OUT/$(dirname "$file")" + cp -f "$file" "$OUT/$file" + fi + done + + find "$OUT" -type f | sort || true + + - name: Upload logs on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: rix-auth-logs-${{ matrix.os }}-${{ matrix.build_type }} + path: logs/**/* + if-no-files-found: ignore From f7b5c20851dc3eaa7e6994ec353c00dab33fe8fa Mon Sep 17 00:00:00 2001 From: Gaspard Kirira Date: Tue, 9 Jun 2026 10:27:16 +0300 Subject: [PATCH 07/10] ci: use published Vix SDK version --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80f43e6..c10cb35 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ concurrency: cancel-in-progress: true env: - VIX_VERSION: v2.6.2 + VIX_VERSION: v2.6.1 BUILD_JOBS: 2 jobs: From f41f8d04a4fa72ad3f21f12461b2a6a8465c0b15 Mon Sep 17 00:00:00 2001 From: Gaspard Kirira Date: Tue, 9 Jun 2026 10:31:28 +0300 Subject: [PATCH 08/10] ci: add full Vix SDK workflow --- .github/workflows/ci.yml | 146 ++++++--------------------------------- 1 file changed, 21 insertions(+), 125 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c10cb35..d8b1afa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,7 +82,10 @@ jobs: spdlog:x64-windows ` nlohmann-json:x64-windows ` openssl:x64-windows ` + zlib:x64-windows ` + brotli:x64-windows ` sqlite3:x64-windows ` + libmysql:x64-windows ` gtest:x64-windows "VCPKG_ROOT=$env:GITHUB_WORKSPACE/vcpkg" | Out-File -FilePath $env:GITHUB_ENV -Append @@ -108,10 +111,13 @@ jobs: pkg-config \ libasio-dev \ libssl-dev \ + zlib1g-dev \ + libbrotli-dev \ libsqlite3-dev \ nlohmann-json3-dev \ libfmt-dev \ libspdlog-dev \ + libmysqlcppconn-dev \ libgtest-dev - name: Install macOS dependencies @@ -128,10 +134,13 @@ jobs: curl \ pkg-config \ openssl@3 \ + zlib \ + brotli \ sqlite \ nlohmann-json \ spdlog \ fmt \ + mysql-client \ googletest - name: Clean workspace @@ -142,7 +151,6 @@ jobs: rm -rf \ build \ install \ - deps \ smoke-rix-auth \ logs @@ -152,61 +160,12 @@ jobs: run: | set -euxo pipefail - if curl -fsSL https://vixcpp.com/install.sh | VIX_VERSION="${VIX_VERSION}" VIX_INSTALL_KIND=sdk sh; then - test -f "$HOME/.local/lib/cmake/Vix/VixConfig.cmake" - test -f "$HOME/.local/include/vix.hpp" + curl -fsSL https://vixcpp.com/install.sh | VIX_VERSION="${VIX_VERSION}" VIX_INSTALL_KIND=sdk sh - echo "VIX_PREFIX=$HOME/.local" >> "$GITHUB_ENV" - exit 0 - fi + test -f "$HOME/.local/lib/cmake/Vix/VixConfig.cmake" + test -f "$HOME/.local/include/vix.hpp" - echo "Vix SDK binary install failed. Building Vix from source instead." - - git clone --depth 1 --branch "${VIX_VERSION}" --recurse-submodules https://github.com/vixcpp/vix.git deps/vix - - cmake -S deps/vix -B deps/vix/build -G Ninja \ - -DCMAKE_BUILD_TYPE=${{ matrix.build_type }} \ - -DCMAKE_INSTALL_PREFIX="${{ github.workspace }}/deps/vix-install" \ - -DVIX_ENABLE_INSTALL=ON \ - -DVIX_BUILD_EXAMPLES=OFF \ - -DVIX_BUILD_TESTS=OFF \ - -DVIX_ENABLE_WARNINGS=ON \ - -DVIX_ENABLE_HTTP_COMPRESSION=OFF \ - -DVIX_ENABLE_DB=ON \ - -DVIX_DB_USE_SQLITE=ON \ - -DVIX_DB_USE_MYSQL=OFF \ - -DVIX_DB_USE_POSTGRES=OFF \ - -DVIX_DB_USE_REDIS=OFF \ - -DVIX_CORE_WITH_MYSQL=OFF \ - -DVIX_ENABLE_ORM=OFF \ - -DVIX_ENABLE_WEBSOCKET=OFF \ - -DVIX_ENABLE_CLI=OFF \ - -DVIX_ENABLE_MIDDLEWARE=OFF \ - -DVIX_ENABLE_P2P=OFF \ - -DVIX_ENABLE_P2P_HTTP=OFF \ - -DVIX_ENABLE_CACHE=OFF \ - -DVIX_ENABLE_ASYNC=OFF \ - -DVIX_ENABLE_VALIDATION=ON \ - -DVIX_ENABLE_CRYPTO=ON \ - -DVIX_ENABLE_WEBRPC=OFF \ - -DVIX_ENABLE_TIME=ON \ - -DVIX_ENABLE_TESTS_MODULE=OFF \ - -DVIX_ENABLE_TEMPLATE=OFF \ - -DVIX_ENABLE_PROCESS=OFF \ - -DVIX_ENABLE_THREADPOOL=OFF \ - -DVIX_ENABLE_KV=OFF \ - -DVIX_ENABLE_AGENT=OFF \ - -DVIX_ENABLE_GAME=OFF \ - -DVIX_TIME_BUILD_TESTS=OFF \ - -DVIX_TIME_BUILD_BENCH=OFF - - cmake --build deps/vix/build --config ${{ matrix.build_type }} -j"${BUILD_JOBS}" - cmake --install deps/vix/build --config ${{ matrix.build_type }} - - test -f "${{ github.workspace }}/deps/vix-install/lib/cmake/Vix/VixConfig.cmake" - test -f "${{ github.workspace }}/deps/vix-install/include/vix.hpp" - - echo "VIX_PREFIX=${{ github.workspace }}/deps/vix-install" >> "$GITHUB_ENV" + echo "VIX_PREFIX=$HOME/.local" >> "$GITHUB_ENV" - name: Install Vix SDK Windows if: runner.os == 'Windows' @@ -214,95 +173,30 @@ jobs: run: | $ErrorActionPreference = "Stop" - $vixPrefix = "$env:GITHUB_WORKSPACE/deps/vix-install" - $vixBinaryPrefix = "$env:GITHUB_WORKSPACE/deps/vix-sdk" + $vixPrefix = "$env:GITHUB_WORKSPACE/deps/vix-sdk" $asset = "vix-sdk-windows-x86_64.zip" $url = "https://github.com/vixcpp/vix/releases/download/$env:VIX_VERSION/$asset" New-Item -ItemType Directory -Force -Path "$env:GITHUB_WORKSPACE/deps" | Out-Null - try { - Write-Host "Trying Vix SDK binary: $url" - - Invoke-WebRequest -Uri "$url" -OutFile "$asset" - - New-Item -ItemType Directory -Force -Path "$vixBinaryPrefix" | Out-Null - Expand-Archive -Path "$asset" -DestinationPath "$vixBinaryPrefix" -Force - - if (!(Test-Path "$vixBinaryPrefix/lib/cmake/Vix/VixConfig.cmake")) { - throw "Downloaded SDK does not contain VixConfig.cmake" - } - - if (!(Test-Path "$vixBinaryPrefix/include/vix.hpp")) { - throw "Downloaded SDK does not contain vix.hpp" - } - - "VIX_PREFIX=$vixBinaryPrefix" | Out-File -FilePath $env:GITHUB_ENV -Append - Write-Host "Using Vix SDK binary at $vixBinaryPrefix" - exit 0 - } - catch { - Write-Host "Vix SDK binary is not available for Windows. Building Vix from source." - Write-Host $_ - } + Invoke-WebRequest -Uri "$url" -OutFile "$asset" - git clone --depth 1 --branch "$env:VIX_VERSION" --recurse-submodules https://github.com/vixcpp/vix.git deps/vix - - cmake -S deps/vix -B deps/vix/build -G Ninja ` - -DCMAKE_BUILD_TYPE=${{ matrix.build_type }} ` - -DCMAKE_TOOLCHAIN_FILE="$env:CMAKE_TOOLCHAIN_FILE" ` - -DVCPKG_TARGET_TRIPLET=x64-windows ` - -DCMAKE_INSTALL_PREFIX="$vixPrefix" ` - -DVIX_ENABLE_INSTALL=ON ` - -DVIX_BUILD_EXAMPLES=OFF ` - -DVIX_BUILD_TESTS=OFF ` - -DVIX_ENABLE_WARNINGS=ON ` - -DVIX_ENABLE_HTTP_COMPRESSION=OFF ` - -DVIX_ENABLE_DB=ON ` - -DVIX_DB_USE_SQLITE=ON ` - -DVIX_DB_USE_MYSQL=OFF ` - -DVIX_DB_USE_POSTGRES=OFF ` - -DVIX_DB_USE_REDIS=OFF ` - -DVIX_CORE_WITH_MYSQL=OFF ` - -DVIX_ENABLE_ORM=OFF ` - -DVIX_ENABLE_WEBSOCKET=OFF ` - -DVIX_ENABLE_CLI=OFF ` - -DVIX_ENABLE_MIDDLEWARE=OFF ` - -DVIX_ENABLE_P2P=OFF ` - -DVIX_ENABLE_P2P_HTTP=OFF ` - -DVIX_ENABLE_CACHE=OFF ` - -DVIX_ENABLE_ASYNC=OFF ` - -DVIX_ENABLE_VALIDATION=ON ` - -DVIX_ENABLE_CRYPTO=ON ` - -DVIX_ENABLE_WEBRPC=OFF ` - -DVIX_ENABLE_TIME=ON ` - -DVIX_ENABLE_TESTS_MODULE=OFF ` - -DVIX_ENABLE_TEMPLATE=OFF ` - -DVIX_ENABLE_PROCESS=OFF ` - -DVIX_ENABLE_THREADPOOL=OFF ` - -DVIX_ENABLE_KV=OFF ` - -DVIX_ENABLE_AGENT=OFF ` - -DVIX_ENABLE_GAME=OFF ` - -DVIX_TIME_BUILD_TESTS=OFF ` - -DVIX_TIME_BUILD_BENCH=OFF - - cmake --build deps/vix/build --config ${{ matrix.build_type }} -j "$env:BUILD_JOBS" - cmake --install deps/vix/build --config ${{ matrix.build_type }} + New-Item -ItemType Directory -Force -Path "$vixPrefix" | Out-Null + Expand-Archive -Path "$asset" -DestinationPath "$vixPrefix" -Force if (!(Test-Path "$vixPrefix/lib/cmake/Vix/VixConfig.cmake")) { - Write-Host "VixConfig.cmake not found after source build" + Write-Host "VixConfig.cmake not found in SDK" Get-ChildItem -Recurse "$vixPrefix" | Select-Object FullName exit 1 } if (!(Test-Path "$vixPrefix/include/vix.hpp")) { - Write-Host "vix.hpp not found after source build" + Write-Host "vix.hpp not found in SDK" Get-ChildItem -Recurse "$vixPrefix" | Select-Object FullName exit 1 } "VIX_PREFIX=$vixPrefix" | Out-File -FilePath $env:GITHUB_ENV -Append - Write-Host "Using source-built Vix SDK at $vixPrefix" - name: Show Vix SDK prefix shell: bash @@ -311,6 +205,8 @@ jobs: echo "VIX_PREFIX=$VIX_PREFIX" test -n "$VIX_PREFIX" + test -f "$VIX_PREFIX/lib/cmake/Vix/VixConfig.cmake" + test -f "$VIX_PREFIX/include/vix.hpp" - name: Configure rix/auth shell: bash From 4eeae179e94fba462a5eea67017c3f27e27db1c7 Mon Sep 17 00:00:00 2001 From: Gaspard Kirira Date: Tue, 9 Jun 2026 10:34:34 +0300 Subject: [PATCH 09/10] ci: install full Vix SDK system dependencies --- .github/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8b1afa..fc95e2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,6 +86,8 @@ jobs: brotli:x64-windows ` sqlite3:x64-windows ` libmysql:x64-windows ` + sdl2:x64-windows ` + sdl2-image:x64-windows ` gtest:x64-windows "VCPKG_ROOT=$env:GITHUB_WORKSPACE/vcpkg" | Out-File -FilePath $env:GITHUB_ENV -Append @@ -118,6 +120,9 @@ jobs: libfmt-dev \ libspdlog-dev \ libmysqlcppconn-dev \ + libsdl2-dev \ + libsdl2-image-dev \ + libgl1-mesa-dev \ libgtest-dev - name: Install macOS dependencies @@ -141,6 +146,8 @@ jobs: spdlog \ fmt \ mysql-client \ + sdl2 \ + sdl2_image \ googletest - name: Clean workspace From dd4d859d5c50ab37d6e31c1e3c1f514bce35d699 Mon Sep 17 00:00:00 2001 From: Gaspard Kirira Date: Tue, 9 Jun 2026 10:50:35 +0300 Subject: [PATCH 10/10] feat(auth): add rix auth facade module --- CMakeLists.txt | 2 + include/rix/auth/AuthModule.hpp | 209 +++++++++++++++++++++++++++++++ include/rix/auth/ManagedAuth.hpp | 198 +++++++++++++++++++++++++++++ src/AuthModule.cpp | 135 ++++++++++++++++++++ src/ManagedAuth.cpp | 149 ++++++++++++++++++++++ vix.json | 2 +- 6 files changed, 694 insertions(+), 1 deletion(-) create mode 100644 include/rix/auth/AuthModule.hpp create mode 100644 include/rix/auth/ManagedAuth.hpp create mode 100644 src/AuthModule.cpp create mode 100644 src/ManagedAuth.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index d2965e2..67b1107 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -178,6 +178,8 @@ add_library(rix_auth STATIC src/Auth.cpp src/AuthConfig.cpp src/AuthError.cpp + src/AuthModule.cpp + src/ManagedAuth.cpp src/PasswordHasher.cpp src/User.cpp src/Session.cpp diff --git a/include/rix/auth/AuthModule.hpp b/include/rix/auth/AuthModule.hpp new file mode 100644 index 0000000..e42c91b --- /dev/null +++ b/include/rix/auth/AuthModule.hpp @@ -0,0 +1,209 @@ +/** + * + * @file AuthModule.hpp + * @author Gaspard Kirira + * + * Copyright 2026, Gaspard Kirira. + * All rights reserved. + * https://github.com/rixcpp/auth + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Rix + * + */ + +#ifndef RIXCPP_AUTH_INCLUDE_RIX_AUTH_AUTHMODULE_HPP_INCLUDED +#define RIXCPP_AUTH_INCLUDE_RIX_AUTH_AUTHMODULE_HPP_INCLUDED + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +namespace rixlib::auth +{ + /** + * @brief Auth configuration facade exposed through rix.auth.config. + */ + class AuthConfigModule + { + public: + /** + * @brief Return the default development auth configuration. + * + * @return Development AuthConfig. + */ + [[nodiscard]] AuthConfig development() const; + + /** + * @brief Return the default production auth configuration. + * + * @return Production AuthConfig. + */ + [[nodiscard]] AuthConfig production() const; + }; + + /** + * @brief Password helper facade exposed through rix.auth.password. + */ + class AuthPasswordModule + { + public: + /** + * @brief Hash a plain-text password using the default hasher. + * + * @param password Plain-text password. + * @return Encoded password hash on success. + */ + [[nodiscard]] AuthResult + hash(std::string_view password) const; + + /** + * @brief Verify a password against an encoded password hash. + * + * @param password Plain-text password. + * @param password_hash Encoded password hash. + * @return true when the password matches. + */ + [[nodiscard]] bool verify( + std::string_view password, + std::string_view password_hash) const; + + /** + * @brief Return a new password hasher with default settings. + * + * @return PasswordHasher instance. + */ + [[nodiscard]] PasswordHasher hasher() const; + }; + + /** + * @brief High-level authentication facade exposed through rix.auth. + * + * AuthModule is the simple public entry point used by the global Rix facade. + * It can create memory-backed auth services for examples and tests, + * database-backed auth services for durable applications, or Auth services + * from custom stores. + */ + class AuthModule + { + public: + /** + * @brief Configuration helpers. + */ + AuthConfigModule config{}; + + /** + * @brief Password hashing helpers. + */ + AuthPasswordModule password{}; + + /** + * @brief Create an Auth service with caller-owned stores. + * + * @param users User storage backend. + * @param sessions Session storage backend. + * @return Auth service using development defaults. + */ + [[nodiscard]] Auth create( + UserStore &users, + SessionStore &sessions) const; + + /** + * @brief Create an Auth service with caller-owned stores and config. + * + * @param users User storage backend. + * @param sessions Session storage backend. + * @param config Authentication configuration. + * @return Auth service. + */ + [[nodiscard]] Auth create( + UserStore &users, + SessionStore &sessions, + AuthConfig config) const; + + /** + * @brief Create a memory-backed managed Auth service. + * + * @return Managed auth service using development defaults. + */ + [[nodiscard]] ManagedAuth memory() const; + + /** + * @brief Create a memory-backed managed Auth service with config. + * + * @param config Authentication configuration. + * @return Managed auth service. + */ + [[nodiscard]] ManagedAuth memory(AuthConfig config) const; + + /** + * @brief Create a database-backed managed Auth service. + * + * @param database Vix database facade. + * @return Managed auth service using production defaults. + */ + [[nodiscard]] ManagedAuth database(vix::db::Database &database) const; + + /** + * @brief Create a database-backed managed Auth service with config. + * + * @param database Vix database facade. + * @param config Authentication configuration. + * @return Managed auth service. + */ + [[nodiscard]] ManagedAuth database( + vix::db::Database &database, + AuthConfig config) const; + + /** + * @brief Return the package version string. + * + * @return Version string. + */ + [[nodiscard]] std::string version() const; + + /** + * @brief Return the package major version. + * + * @return Major version. + */ + [[nodiscard]] int version_major() const noexcept; + + /** + * @brief Return the package minor version. + * + * @return Minor version. + */ + [[nodiscard]] int version_minor() const noexcept; + + /** + * @brief Return the package patch version. + * + * @return Patch version. + */ + [[nodiscard]] int version_patch() const noexcept; + + /** + * @brief Return the encoded package version number. + * + * @return Encoded version. + */ + [[nodiscard]] int version_number() const noexcept; + }; +} // namespace rixlib::auth + +#endif // RIXCPP_AUTH_INCLUDE_RIX_AUTH_AUTHMODULE_HPP_INCLUDED diff --git a/include/rix/auth/ManagedAuth.hpp b/include/rix/auth/ManagedAuth.hpp new file mode 100644 index 0000000..d6cc072 --- /dev/null +++ b/include/rix/auth/ManagedAuth.hpp @@ -0,0 +1,198 @@ +/** + * + * @file ManagedAuth.hpp + * @author Gaspard Kirira + * + * Copyright 2026, Gaspard Kirira. + * All rights reserved. + * https://github.com/rixcpp/auth + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Rix + * + */ + +#ifndef RIXCPP_AUTH_INCLUDE_RIX_AUTH_MANAGEDAUTH_HPP_INCLUDED +#define RIXCPP_AUTH_INCLUDE_RIX_AUTH_MANAGEDAUTH_HPP_INCLUDED + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace rixlib::auth +{ + /** + * @brief Owns auth stores and exposes the public Auth API. + * + * ManagedAuth is used by the high-level Rix facade for simple APIs such as: + * + * @code + * auto auth = rix.auth.memory(); + * @endcode + * + * Auth itself keeps references to UserStore and SessionStore. ManagedAuth + * safely owns those stores so the internal Auth object never points to + * destroyed storage. + */ + class ManagedAuth + { + public: + /** + * @brief Construct a managed auth service. + * + * @param users Owned user storage backend. + * @param sessions Owned session storage backend. + * @param config Authentication configuration. + */ + ManagedAuth( + std::unique_ptr users, + std::unique_ptr sessions, + AuthConfig config); + + ManagedAuth(const ManagedAuth &) = delete; + ManagedAuth &operator=(const ManagedAuth &) = delete; + + ManagedAuth(ManagedAuth &&other) noexcept; + ManagedAuth &operator=(ManagedAuth &&other) noexcept; + + /** + * @brief Register a new user. + * + * @param request Registration request. + * @return AuthResult containing the created user on success. + */ + [[nodiscard]] AuthResult + register_user(const RegisterRequest &request); + + /** + * @brief Authenticate a user and create a session. + * + * @param request Login request. + * @return AuthResult containing user, session, and token on success. + */ + [[nodiscard]] AuthResult + login(const LoginRequest &request); + + /** + * @brief Revoke a session. + * + * @param session_id Session identifier. + * @return AuthStatus indicating success or failure. + */ + [[nodiscard]] AuthStatus logout(std::string_view session_id); + + /** + * @brief Revoke all sessions for a user. + * + * @param user_id User identifier. + * @return AuthStatus indicating success or failure. + */ + [[nodiscard]] AuthStatus logout_user(std::string_view user_id); + + /** + * @brief Find and validate a session. + * + * @param session_id Session identifier. + * @return AuthResult containing the usable session. + */ + [[nodiscard]] AuthResult + authenticate_session(std::string_view session_id); + + /** + * @brief Refresh a valid session. + * + * @param session_id Session identifier. + * @return AuthResult containing the refreshed session. + */ + [[nodiscard]] AuthResult + refresh_session(std::string_view session_id); + + /** + * @brief Create a short-lived token for a user. + * + * @param user_id User identifier. + * @return AuthResult containing the generated token. + */ + [[nodiscard]] AuthResult + issue_token(std::string_view user_id); + + /** + * @brief Return the current configuration. + * + * @return Authentication configuration. + */ + [[nodiscard]] const AuthConfig &config() const noexcept; + + /** + * @brief Return the password hasher. + * + * @return Password hasher. + */ + [[nodiscard]] const PasswordHasher &password_hasher() const noexcept; + + /** + * @brief Access the underlying Auth service. + * + * @return Auth service. + */ + [[nodiscard]] Auth &service() noexcept; + + /** + * @brief Access the underlying Auth service. + * + * @return Auth service. + */ + [[nodiscard]] const Auth &service() const noexcept; + + /** + * @brief Access the owned user store. + * + * @return User store. + */ + [[nodiscard]] UserStore &users() noexcept; + + /** + * @brief Access the owned user store. + * + * @return User store. + */ + [[nodiscard]] const UserStore &users() const noexcept; + + /** + * @brief Access the owned session store. + * + * @return Session store. + */ + [[nodiscard]] SessionStore &sessions() noexcept; + + /** + * @brief Access the owned session store. + * + * @return Session store. + */ + [[nodiscard]] const SessionStore &sessions() const noexcept; + + private: + void rebuild_service(); + + std::unique_ptr users_; + std::unique_ptr sessions_; + AuthConfig config_; + std::unique_ptr auth_; + }; +} // namespace rixlib::auth + +#endif // RIXCPP_AUTH_INCLUDE_RIX_AUTH_MANAGEDAUTH_HPP_INCLUDED diff --git a/src/AuthModule.cpp b/src/AuthModule.cpp new file mode 100644 index 0000000..359af5d --- /dev/null +++ b/src/AuthModule.cpp @@ -0,0 +1,135 @@ +/** + * + * @file AuthModule.cpp + * @author Gaspard Kirira + * + * Copyright 2026, Gaspard Kirira. + * All rights reserved. + * https://github.com/rixcpp/auth + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Rix + * + */ + +#include + +#include +#include + +namespace rixlib::auth +{ + AuthConfig AuthConfigModule::development() const + { + return AuthConfig::development(); + } + + AuthConfig AuthConfigModule::production() const + { + return AuthConfig::production(); + } + + AuthResult AuthPasswordModule::hash( + std::string_view password) const + { + PasswordHasher hasher; + return hasher.hash(password); + } + + bool AuthPasswordModule::verify( + std::string_view password, + std::string_view password_hash) const + { + PasswordHasher hasher; + return hasher.verify(password, password_hash); + } + + PasswordHasher AuthPasswordModule::hasher() const + { + return PasswordHasher{}; + } + + Auth AuthModule::create( + UserStore &users, + SessionStore &sessions) const + { + return Auth{ + users, + sessions, + AuthConfig::development()}; + } + + Auth AuthModule::create( + UserStore &users, + SessionStore &sessions, + AuthConfig config) const + { + return Auth{ + users, + sessions, + std::move(config)}; + } + + ManagedAuth AuthModule::memory() const + { + return memory(AuthConfig::development()); + } + + ManagedAuth AuthModule::memory(AuthConfig config) const + { + auto users = std::make_unique(); + auto sessions = std::make_unique(); + + return ManagedAuth{ + std::move(users), + std::move(sessions), + std::move(config)}; + } + + ManagedAuth AuthModule::database(vix::db::Database &database) const + { + return this->database( + database, + AuthConfig::production()); + } + + ManagedAuth AuthModule::database( + vix::db::Database &database, + AuthConfig config) const + { + auto users = std::make_unique(database); + auto sessions = std::make_unique(database); + + return ManagedAuth{ + std::move(users), + std::move(sessions), + std::move(config)}; + } + + std::string AuthModule::version() const + { + return rixlib::auth::version(); + } + + int AuthModule::version_major() const noexcept + { + return rixlib::auth::version_major(); + } + + int AuthModule::version_minor() const noexcept + { + return rixlib::auth::version_minor(); + } + + int AuthModule::version_patch() const noexcept + { + return rixlib::auth::version_patch(); + } + + int AuthModule::version_number() const noexcept + { + return rixlib::auth::version_number(); + } +} // namespace rixlib::auth diff --git a/src/ManagedAuth.cpp b/src/ManagedAuth.cpp new file mode 100644 index 0000000..9326921 --- /dev/null +++ b/src/ManagedAuth.cpp @@ -0,0 +1,149 @@ +/** + * + * @file ManagedAuth.cpp + * @author Gaspard Kirira + * + * Copyright 2026, Gaspard Kirira. + * All rights reserved. + * https://github.com/rixcpp/auth + * + * Use of this source code is governed by a MIT license + * that can be found in the License file. + * + * Rix + * + */ + +#include + +#include +#include +#include + +namespace rixlib::auth +{ + ManagedAuth::ManagedAuth( + std::unique_ptr users, + std::unique_ptr sessions, + AuthConfig config) + : users_(std::move(users)), + sessions_(std::move(sessions)), + config_(std::move(config)) + { + rebuild_service(); + } + + ManagedAuth::ManagedAuth(ManagedAuth &&other) noexcept + : users_(std::move(other.users_)), + sessions_(std::move(other.sessions_)), + config_(std::move(other.config_)) + { + rebuild_service(); + } + + ManagedAuth &ManagedAuth::operator=(ManagedAuth &&other) noexcept + { + if (this == &other) + { + return *this; + } + + users_ = std::move(other.users_); + sessions_ = std::move(other.sessions_); + config_ = std::move(other.config_); + + rebuild_service(); + + return *this; + } + + AuthResult ManagedAuth::register_user( + const RegisterRequest &request) + { + return auth_->register_user(request); + } + + AuthResult ManagedAuth::login( + const LoginRequest &request) + { + return auth_->login(request); + } + + AuthStatus ManagedAuth::logout(std::string_view session_id) + { + return auth_->logout(session_id); + } + + AuthStatus ManagedAuth::logout_user(std::string_view user_id) + { + return auth_->logout_user(user_id); + } + + AuthResult ManagedAuth::authenticate_session( + std::string_view session_id) + { + return auth_->authenticate_session(session_id); + } + + AuthResult ManagedAuth::refresh_session( + std::string_view session_id) + { + return auth_->refresh_session(session_id); + } + + AuthResult ManagedAuth::issue_token(std::string_view user_id) + { + return auth_->issue_token(user_id); + } + + const AuthConfig &ManagedAuth::config() const noexcept + { + return auth_->config(); + } + + const PasswordHasher &ManagedAuth::password_hasher() const noexcept + { + return auth_->password_hasher(); + } + + Auth &ManagedAuth::service() noexcept + { + return *auth_; + } + + const Auth &ManagedAuth::service() const noexcept + { + return *auth_; + } + + UserStore &ManagedAuth::users() noexcept + { + return *users_; + } + + const UserStore &ManagedAuth::users() const noexcept + { + return *users_; + } + + SessionStore &ManagedAuth::sessions() noexcept + { + return *sessions_; + } + + const SessionStore &ManagedAuth::sessions() const noexcept + { + return *sessions_; + } + + void ManagedAuth::rebuild_service() + { + assert(users_ != nullptr); + assert(sessions_ != nullptr); + + auth_ = std::make_unique( + *users_, + *sessions_, + config_); + } +} // namespace rixlib::auth diff --git a/vix.json b/vix.json index 15ddf1c..909de2b 100644 --- a/vix.json +++ b/vix.json @@ -1,7 +1,7 @@ { "name": "auth", "namespace": "rix", - "version": "0.1.0", + "version": "0.2.0", "type": "library", "include": "include", "license": "MIT",