feat(api): implement user authentication and registration endpoints

- Add main application logic in `api/src/main.rs` to initialize server, database, and services.
- Create authentication routes in `api/src/routes/auth.rs` for login, register, logout, and user info retrieval.
- Implement configuration route in `api/src/routes/config.rs` to expose application settings.
- Define application state management in `api/src/state.rs` to share user service and configuration.
- Set up Docker Compose configuration in `compose.yml` for backend, worker, and database services.
- Establish domain logic in `domain` crate with user entities, repositories, and services.
- Implement SQLite user repository in `infra/src/user_repository.rs` for user data persistence.
- Create database migration handling in `infra/src/db.rs` and session store in `infra/src/session_store.rs`.
- Add necessary dependencies and features in `Cargo.toml` files for both `domain` and `infra` crates.
This commit is contained in:
2026-01-02 13:07:09 +01:00
parent 7dbdf3f00b
commit 1d141c7a97
27 changed files with 208 additions and 130 deletions

72
api/Cargo.toml Normal file
View File

@@ -0,0 +1,72 @@
[package]
name = "api"
version = "0.1.0"
edition = "2024"
default-run = "api"
[features]
default = ["sqlite"]
sqlite = [
"infra/sqlite",
"tower-sessions-sqlx-store/sqlite",
"sqlx/sqlite",
]
postgres = [
"infra/postgres",
"tower-sessions-sqlx-store/postgres",
"sqlx/postgres",
"k-core/postgres",
]
broker-nats = ["infra/broker-nats"]
[dependencies]
k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [
"logging",
"db-sqlx",
] }
domain = { path = "../domain" }
infra = { path = "../infra", default-features = false, features = [
"sqlite",
] }
# Web framework
axum = { version = "0.8.8", features = ["macros"] }
tower = "0.5.2"
tower-http = { version = "0.6.2", features = ["cors", "trace"] }
# Authentication
axum-login = "0.18"
tower-sessions = "0.14"
tower-sessions-sqlx-store = { version = "0.15", features = ["sqlite"] }
password-auth = "1.0"
time = "0.3"
async-trait = "0.1.89"
# Async runtime
tokio = { version = "1.48.0", features = ["full"] }
# Serialization
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0"
# Validation
validator = { version = "0.20", features = ["derive"] }
# Error handling
thiserror = "2.0.17"
anyhow = "1.0"
# Utilities
chrono = { version = "0.4.42", features = ["serde"] }
uuid = { version = "1.19.0", features = ["v4", "serde"] }
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3.22", features = ["env-filter"] }
# Database
sqlx = { version = "0.8.6", features = ["sqlite", "runtime-tokio"] }
dotenvy = "0.15.7"
# Configuration
config = "0.15.9"

101
api/src/auth.rs Normal file
View File

@@ -0,0 +1,101 @@
//! Authentication logic using axum-login
use std::sync::Arc;
use axum_login::{AuthnBackend, UserId};
use infra::session_store::InfraSessionStore;
use password_auth::verify_password;
use serde::{Deserialize, Serialize};
use tower_sessions::SessionManagerLayer;
use uuid::Uuid;
use crate::error::ApiError;
use domain::{User, UserRepository};
/// Wrapper around domain User to implement AuthUser
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthUser(pub User);
impl axum_login::AuthUser for AuthUser {
type Id = Uuid;
fn id(&self) -> Self::Id {
self.0.id
}
fn session_auth_hash(&self) -> &[u8] {
// Use password hash to invalidate sessions if password changes
self.0
.password_hash
.as_ref()
.map(|s| s.as_bytes())
.unwrap_or(&[])
}
}
#[derive(Clone)]
pub struct AuthBackend {
pub user_repo: Arc<dyn UserRepository>,
}
impl AuthBackend {
pub fn new(user_repo: Arc<dyn UserRepository>) -> Self {
Self { user_repo }
}
}
#[derive(Clone, Debug, Deserialize)]
pub struct Credentials {
pub email: String,
pub password: String,
}
impl AuthnBackend for AuthBackend {
type User = AuthUser;
type Credentials = Credentials;
type Error = ApiError;
async fn authenticate(
&self,
creds: Self::Credentials,
) -> Result<Option<Self::User>, Self::Error> {
let user = self
.user_repo
.find_by_email(&creds.email)
.await
.map_err(|e| ApiError::internal(e.to_string()))?;
if let Some(user) = user {
if let Some(hash) = &user.password_hash {
// Verify password
if verify_password(&creds.password, hash).is_ok() {
return Ok(Some(AuthUser(user)));
}
}
}
Ok(None)
}
async fn get_user(&self, user_id: &UserId<Self>) -> Result<Option<Self::User>, Self::Error> {
let user = self
.user_repo
.find_by_id(*user_id)
.await
.map_err(|e| ApiError::internal(e.to_string()))?;
Ok(user.map(AuthUser))
}
}
pub type AuthSession = axum_login::AuthSession<AuthBackend>;
pub async fn setup_auth_layer(
session_layer: SessionManagerLayer<InfraSessionStore>,
user_repo: Arc<dyn UserRepository>,
) -> Result<axum_login::AuthManagerLayer<AuthBackend, InfraSessionStore>, ApiError> {
let backend = AuthBackend::new(user_repo);
let auth_layer = axum_login::AuthManagerLayerBuilder::new(backend, session_layer).build();
Ok(auth_layer)
}

35
api/src/config.rs Normal file
View File

@@ -0,0 +1,35 @@
//! Application Configuration
//!
//! Loads configuration from environment variables.
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
pub struct Config {
pub database_url: String,
pub session_secret: String,
#[serde(default = "default_port")]
pub port: u16,
#[serde(default = "default_host")]
pub host: String,
}
fn default_port() -> u16 {
3000
}
fn default_host() -> String {
"127.0.0.1".to_string()
}
impl Config {
pub fn new() -> Result<Self, config::ConfigError> {
config::Config::builder()
.add_source(config::Environment::default())
//.add_source(config::File::with_name(".env").required(false)) // Optional .env file
.build()?
.try_deserialize()
}
}

42
api/src/dto.rs Normal file
View File

@@ -0,0 +1,42 @@
//! Request and Response DTOs
//!
//! Data Transfer Objects for the API.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use validator::Validate;
/// Login request
#[derive(Debug, Deserialize, Validate)]
pub struct LoginRequest {
#[validate(email(message = "Invalid email format"))]
pub email: String,
#[validate(length(min = 6, message = "Password must be at least 6 characters"))]
pub password: String,
}
/// Register request
#[derive(Debug, Deserialize, Validate)]
pub struct RegisterRequest {
#[validate(email(message = "Invalid email format"))]
pub email: String,
#[validate(length(min = 6, message = "Password must be at least 6 characters"))]
pub password: String,
}
/// User response DTO
#[derive(Debug, Serialize)]
pub struct UserResponse {
pub id: Uuid,
pub email: String,
pub created_at: DateTime<Utc>,
}
/// System configuration response
#[derive(Debug, Serialize)]
pub struct ConfigResponse {
pub allow_registration: bool,
}

121
api/src/error.rs Normal file
View File

@@ -0,0 +1,121 @@
//! API error handling
//!
//! Maps domain errors to HTTP responses
use axum::{
Json,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Serialize;
use thiserror::Error;
use domain::DomainError;
/// API-level errors
#[derive(Debug, Error)]
pub enum ApiError {
#[error("{0}")]
Domain(#[from] DomainError),
#[error("Validation error: {0}")]
Validation(String),
#[error("Internal server error")]
Internal(String),
#[error("Forbidden: {0}")]
Forbidden(String),
#[error("Unauthorized: {0}")]
Unauthorized(String),
}
/// Error response body
#[derive(Debug, Serialize)]
pub struct ErrorResponse {
pub error: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<String>,
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, error_response) = match &self {
ApiError::Domain(domain_error) => {
let status = match domain_error {
DomainError::UserNotFound(_) => StatusCode::NOT_FOUND,
DomainError::UserAlreadyExists(_) => StatusCode::CONFLICT,
DomainError::ValidationError(_) => StatusCode::BAD_REQUEST,
DomainError::Unauthorized(_) => StatusCode::FORBIDDEN,
DomainError::RepositoryError(_) | DomainError::InfrastructureError(_) => {
StatusCode::INTERNAL_SERVER_ERROR
}
};
(
status,
ErrorResponse {
error: domain_error.to_string(),
details: None,
},
)
}
ApiError::Validation(msg) => (
StatusCode::BAD_REQUEST,
ErrorResponse {
error: "Validation error".to_string(),
details: Some(msg.clone()),
},
),
ApiError::Internal(msg) => {
// Log internal errors but don't expose details
tracing::error!("Internal error: {}", msg);
(
StatusCode::INTERNAL_SERVER_ERROR,
ErrorResponse {
error: "Internal server error".to_string(),
details: None,
},
)
}
ApiError::Forbidden(msg) => (
StatusCode::FORBIDDEN,
ErrorResponse {
error: "Forbidden".to_string(),
details: Some(msg.clone()),
},
),
ApiError::Unauthorized(msg) => (
StatusCode::UNAUTHORIZED,
ErrorResponse {
error: "Unauthorized".to_string(),
details: Some(msg.clone()),
},
),
};
(status, Json(error_response)).into_response()
}
}
impl ApiError {
pub fn validation(msg: impl Into<String>) -> Self {
Self::Validation(msg.into())
}
pub fn internal(msg: impl Into<String>) -> Self {
Self::Internal(msg.into())
}
}
/// Result type alias for API handlers
pub type ApiResult<T> = Result<T, ApiError>;

75
api/src/main.rs Normal file
View File

@@ -0,0 +1,75 @@
use std::net::SocketAddr;
use std::time::Duration as StdDuration;
use domain::UserService;
use infra::factory::build_session_store;
use infra::factory::build_user_repository;
use k_core::logging;
use tokio::net::TcpListener;
use tower_sessions::{Expiry, SessionManagerLayer};
use tracing::info;
mod auth;
mod config;
mod dto;
mod error;
mod routes;
mod state;
use crate::config::Config;
use crate::state::AppState;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// 1. Initialize logging
logging::init("template-api");
// 2. Load configuration
// We use dotenvy explicitly here since config crate might not pick up .env automatically reliably
dotenvy::dotenv().ok();
let config = Config::new().expect("Failed to load configuration");
info!("Starting server on {}:{}", config.host, config.port);
// 3. Connect to database
// k-core handles the "Which DB are we using?" logic internally based on feature flags
// and returns the correct Enum variant.
let db_config = k_core::db::DatabaseConfig {
url: config.database_url.clone(),
max_connections: 5,
acquire_timeout: StdDuration::from_secs(30),
};
// Returns k_core::db::DatabasePool
let db_pool = k_core::db::connect(&db_config).await?;
// 4. Run migrations (using the re-export if you kept it, or direct k_core)
infra::db::run_migrations(&db_pool).await?;
// 5. Initialize Services
let user_repo = build_user_repository(&db_pool).await?;
let user_service = UserService::new(user_repo.clone());
// 6. Setup Session Store
let session_store = build_session_store(&db_pool).await?;
let session_layer = SessionManagerLayer::new(session_store)
.with_secure(false) // Set to true in production with HTTPS
.with_expiry(Expiry::OnInactivity(time::Duration::hours(1)));
// 7. Setup Auth
let auth_layer = auth::setup_auth_layer(session_layer, user_repo.clone()).await?;
// 8. Create App State
let state = AppState::new(user_service, config.clone());
// 9. Build Router
let app = routes::api_v1_router().layer(auth_layer).with_state(state);
// 10. Start Server
let addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?;
let listener = TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok(())
}

117
api/src/routes/auth.rs Normal file
View File

@@ -0,0 +1,117 @@
use axum::http::StatusCode;
use axum::{
Router,
extract::{Json, State},
response::IntoResponse,
routing::post,
};
use crate::{
dto::{LoginRequest, RegisterRequest, UserResponse},
error::ApiError,
state::AppState,
};
use domain::{DomainError, Email};
pub fn router() -> Router<AppState> {
Router::new()
.route("/login", post(login))
.route("/register", post(register))
.route("/logout", post(logout))
.route("/me", post(me))
}
async fn login(
mut auth_session: crate::auth::AuthSession,
Json(payload): Json<LoginRequest>,
) -> Result<impl IntoResponse, ApiError> {
let user = match auth_session
.authenticate(crate::auth::Credentials {
email: payload.email,
password: payload.password,
})
.await
{
Ok(Some(user)) => user,
Ok(None) => return Err(ApiError::Validation("Invalid credentials".to_string())),
Err(_) => return Err(ApiError::Internal("Authentication failed".to_string())),
};
auth_session
.login(&user)
.await
.map_err(|_| ApiError::Internal("Login failed".to_string()))?;
Ok((
StatusCode::OK,
Json(UserResponse {
id: user.0.id,
email: user.0.email.into_inner(),
created_at: user.0.created_at,
}),
))
}
async fn register(
State(state): State<AppState>,
mut auth_session: crate::auth::AuthSession,
Json(payload): Json<RegisterRequest>,
) -> Result<impl IntoResponse, ApiError> {
if state
.user_service
.find_by_email(&payload.email)
.await?
.is_some()
{
return Err(ApiError::Domain(DomainError::UserAlreadyExists(
payload.email,
)));
}
// Note: In a real app, you would hash the password here.
// This template uses a simplified User::new which doesn't take password.
// You should extend User to handle passwords or use an OIDC flow.
let email = Email::try_from(payload.email).map_err(|e| ApiError::Validation(e.to_string()))?;
// Using email as subject for local auth for now
let user = state
.user_service
.find_or_create(&email.as_ref().to_string(), email.as_ref())
.await?;
// Log the user in
let auth_user = crate::auth::AuthUser(user.clone());
auth_session
.login(&auth_user)
.await
.map_err(|_| ApiError::Internal("Login failed".to_string()))?;
Ok((
StatusCode::CREATED,
Json(UserResponse {
id: user.id,
email: user.email.into_inner(),
created_at: user.created_at,
}),
))
}
async fn logout(mut auth_session: crate::auth::AuthSession) -> impl IntoResponse {
match auth_session.logout().await {
Ok(_) => StatusCode::OK,
Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
async fn me(auth_session: crate::auth::AuthSession) -> Result<impl IntoResponse, ApiError> {
let user = auth_session
.user
.ok_or(ApiError::Unauthorized("Not logged in".to_string()))?;
Ok(Json(UserResponse {
id: user.0.id,
email: user.0.email.into_inner(),
created_at: user.0.created_at,
}))
}

13
api/src/routes/config.rs Normal file
View File

@@ -0,0 +1,13 @@
use axum::{Json, Router, routing::get};
use crate::dto::ConfigResponse;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new().route("/", get(get_config))
}
async fn get_config() -> Json<ConfigResponse> {
Json(ConfigResponse {
allow_registration: true, // Default to true for template
})
}

16
api/src/routes/mod.rs Normal file
View File

@@ -0,0 +1,16 @@
//! API Routes
//!
//! Defines the API endpoints and maps them to handler functions.
use crate::state::AppState;
use axum::Router;
pub mod auth;
pub mod config;
/// Construct the API v1 router
pub fn api_v1_router() -> Router<AppState> {
Router::new()
.nest("/auth", auth::router())
.nest("/config", config::router())
}

36
api/src/state.rs Normal file
View File

@@ -0,0 +1,36 @@
//! Application State
//!
//! Holds shared state for the application.
use axum::extract::FromRef;
use std::sync::Arc;
use crate::config::Config;
use domain::UserService;
#[derive(Clone)]
pub struct AppState {
pub user_service: Arc<UserService>,
pub config: Arc<Config>,
}
impl AppState {
pub fn new(user_service: UserService, config: Config) -> Self {
Self {
user_service: Arc::new(user_service),
config: Arc::new(config),
}
}
}
impl FromRef<AppState> for Arc<UserService> {
fn from_ref(input: &AppState) -> Self {
input.user_service.clone()
}
}
impl FromRef<AppState> for Arc<Config> {
fn from_ref(input: &AppState) -> Self {
input.config.clone()
}
}