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

138
Cargo.lock generated
View File

@@ -32,6 +32,37 @@ version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "api"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"axum",
"axum-login",
"chrono",
"config",
"domain",
"dotenvy",
"infra",
"k-core",
"password-auth",
"serde",
"serde_json",
"sqlx",
"thiserror 2.0.17",
"time",
"tokio",
"tower",
"tower-http",
"tower-sessions",
"tower-sessions-sqlx-store",
"tracing",
"tracing-subscriber",
"uuid",
"validator",
]
[[package]] [[package]]
name = "argon2" name = "argon2"
version = "0.5.3" version = "0.5.3"
@@ -558,6 +589,22 @@ dependencies = [
"const-random", "const-random",
] ]
[[package]]
name = "domain"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"chrono",
"futures-core",
"serde",
"serde_json",
"thiserror 2.0.17",
"tokio",
"tracing",
"uuid",
]
[[package]] [[package]]
name = "dotenvy" name = "dotenvy"
version = "0.15.7" version = "0.15.7"
@@ -1125,6 +1172,28 @@ dependencies = [
"hashbrown 0.16.1", "hashbrown 0.16.1",
] ]
[[package]]
name = "infra"
version = "0.1.0"
dependencies = [
"async-nats",
"async-trait",
"chrono",
"domain",
"futures-core",
"futures-util",
"k-core",
"serde",
"serde_json",
"sqlx",
"thiserror 2.0.17",
"tokio",
"tower-sessions",
"tower-sessions-sqlx-store",
"tracing",
"uuid",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.17" version = "1.0.17"
@@ -2401,75 +2470,6 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "template-api"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"axum",
"axum-login",
"chrono",
"config",
"dotenvy",
"k-core",
"password-auth",
"serde",
"serde_json",
"sqlx",
"template-domain",
"template-infra",
"thiserror 2.0.17",
"time",
"tokio",
"tower",
"tower-http",
"tower-sessions",
"tower-sessions-sqlx-store",
"tracing",
"tracing-subscriber",
"uuid",
"validator",
]
[[package]]
name = "template-domain"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"chrono",
"futures-core",
"serde",
"serde_json",
"thiserror 2.0.17",
"tokio",
"tracing",
"uuid",
]
[[package]]
name = "template-infra"
version = "0.1.0"
dependencies = [
"async-nats",
"async-trait",
"chrono",
"futures-core",
"futures-util",
"k-core",
"serde",
"serde_json",
"sqlx",
"template-domain",
"thiserror 2.0.17",
"tokio",
"tower-sessions",
"tower-sessions-sqlx-store",
"tracing",
"uuid",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.69" version = "1.0.69"

View File

@@ -1,3 +1,3 @@
[workspace] [workspace]
members = ["template-domain", "template-infra", "template-api"] members = ["domain", "infra", "api"]
resolver = "2" resolver = "2"

View File

@@ -4,7 +4,7 @@ WORKDIR /app
COPY . . COPY . .
# Build the release binary # Build the release binary
RUN cargo build --release -p template-api RUN cargo build --release -p api
FROM debian:bookworm-slim FROM debian:bookworm-slim
@@ -13,7 +13,7 @@ WORKDIR /app
# Install OpenSSL (required for many Rust networking crates) and CA certificates # Install OpenSSL (required for many Rust networking crates) and CA certificates
RUN apt-get update && apt-get install -y libssl3 ca-certificates && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y libssl3 ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/template-api . COPY --from=builder /app/target/release/api .
# Create data directory for SQLite # Create data directory for SQLite
@@ -24,4 +24,4 @@ ENV SESSION_SECRET=supersecretchangeinproduction
EXPOSE 3000 EXPOSE 3000
CMD ["./template-api"] CMD ["./api"]

View File

@@ -1,31 +1,31 @@
[package] [package]
name = "template-api" name = "api"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
default-run = "template-api" default-run = "api"
[features] [features]
default = ["sqlite"] default = ["sqlite"]
sqlite = [ sqlite = [
"template-infra/sqlite", "infra/sqlite",
"tower-sessions-sqlx-store/sqlite", "tower-sessions-sqlx-store/sqlite",
"sqlx/sqlite", "sqlx/sqlite",
] ]
postgres = [ postgres = [
"template-infra/postgres", "infra/postgres",
"tower-sessions-sqlx-store/postgres", "tower-sessions-sqlx-store/postgres",
"sqlx/postgres", "sqlx/postgres",
"k-core/postgres", "k-core/postgres",
] ]
broker-nats = ["template-infra/broker-nats"] broker-nats = ["infra/broker-nats"]
[dependencies] [dependencies]
k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [ k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [
"logging", "logging",
"db-sqlx", "db-sqlx",
] } ] }
template-domain = { path = "../template-domain" } domain = { path = "../domain" }
template-infra = { path = "../template-infra", default-features = false, features = [ infra = { path = "../infra", default-features = false, features = [
"sqlite", "sqlite",
] } ] }

View File

@@ -3,14 +3,14 @@
use std::sync::Arc; use std::sync::Arc;
use axum_login::{AuthnBackend, UserId}; use axum_login::{AuthnBackend, UserId};
use infra::session_store::InfraSessionStore;
use password_auth::verify_password; use password_auth::verify_password;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid;
use template_infra::session_store::InfraSessionStore;
use tower_sessions::SessionManagerLayer; use tower_sessions::SessionManagerLayer;
use uuid::Uuid;
use crate::error::ApiError; use crate::error::ApiError;
use template_domain::{User, UserRepository}; use domain::{User, UserRepository};
/// Wrapper around domain User to implement AuthUser /// Wrapper around domain User to implement AuthUser
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -95,7 +95,7 @@ pub async fn setup_auth_layer(
user_repo: Arc<dyn UserRepository>, user_repo: Arc<dyn UserRepository>,
) -> Result<axum_login::AuthManagerLayer<AuthBackend, InfraSessionStore>, ApiError> { ) -> Result<axum_login::AuthManagerLayer<AuthBackend, InfraSessionStore>, ApiError> {
let backend = AuthBackend::new(user_repo); let backend = AuthBackend::new(user_repo);
let auth_layer = axum_login::AuthManagerLayerBuilder::new(backend, session_layer).build(); let auth_layer = axum_login::AuthManagerLayerBuilder::new(backend, session_layer).build();
Ok(auth_layer) Ok(auth_layer)
} }

View File

@@ -3,14 +3,14 @@
//! Maps domain errors to HTTP responses //! Maps domain errors to HTTP responses
use axum::{ use axum::{
Json,
http::StatusCode, http::StatusCode,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
Json,
}; };
use serde::Serialize; use serde::Serialize;
use thiserror::Error; use thiserror::Error;
use template_domain::DomainError; use domain::DomainError;
/// API-level errors /// API-level errors
#[derive(Debug, Error)] #[derive(Debug, Error)]

View File

@@ -1,10 +1,10 @@
use std::net::SocketAddr; use std::net::SocketAddr;
use std::time::Duration as StdDuration; 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 k_core::logging;
use template_domain::UserService;
use template_infra::factory::build_session_store;
use template_infra::factory::build_user_repository;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tower_sessions::{Expiry, SessionManagerLayer}; use tower_sessions::{Expiry, SessionManagerLayer};
use tracing::info; use tracing::info;
@@ -44,7 +44,7 @@ async fn main() -> anyhow::Result<()> {
let db_pool = k_core::db::connect(&db_config).await?; 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) // 4. Run migrations (using the re-export if you kept it, or direct k_core)
template_infra::db::run_migrations(&db_pool).await?; infra::db::run_migrations(&db_pool).await?;
// 5. Initialize Services // 5. Initialize Services
let user_repo = build_user_repository(&db_pool).await?; let user_repo = build_user_repository(&db_pool).await?;

View File

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

View File

@@ -6,7 +6,7 @@ use axum::extract::FromRef;
use std::sync::Arc; use std::sync::Arc;
use crate::config::Config; use crate::config::Config;
use template_domain::UserService; use domain::UserService;
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {

50
compose.yml Normal file
View File

@@ -0,0 +1,50 @@
services:
backend:
build: .
ports:
- "3000:3000"
environment:
# In production, use a secure secret
- SESSION_SECRET=dev_secret_key_12345
- DATABASE_URL=sqlite:///app/data/notes.db
- CORS_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5173
- HOST=0.0.0.0
- PORT=3000
volumes:
- ./data:/app/data
worker:
build: .
command: ["./notes-worker"]
environment:
- DATABASE_URL=sqlite:///app/data/notes.db
- BROKER_URL=nats://nats:4222
- QDRANT_URL=http://qdrant:6334
- EMBEDDING_PROVIDER=fastembed
depends_on:
- backend
- nats
- qdrant
volumes:
- ./data:/app/data
nats:
image: nats:alpine
ports:
- "4222:4222"
- "6222:6222"
- "8222:8222"
restart: unless-stopped
db:
image: postgres:15-alpine
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: password
POSTGRES_DB: k_template_db
ports:
- "5432:5432"
volumes:
- db_data:/var/lib/postgresql/data
volumes:
db_data:

View File

@@ -1,5 +1,5 @@
[package] [package]
name = "template-domain" name = "domain"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"

View File

@@ -1,5 +1,5 @@
[package] [package]
name = "template-infra" name = "infra"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
@@ -17,7 +17,7 @@ broker-nats = ["dep:async-nats", "dep:futures-util"]
k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [ k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [
"db-sqlx", "db-sqlx",
] } ] }
template-domain = { path = "../template-domain" } domain = { path = "../domain" }
async-trait = "0.1.89" async-trait = "0.1.89"
chrono = { version = "0.4.42", features = ["serde"] } chrono = { version = "0.4.42", features = ["serde"] }
sqlx = { version = "0.8.6", features = ["runtime-tokio", "chrono", "migrate"] } sqlx = { version = "0.8.6", features = ["runtime-tokio", "chrono", "migrate"] }

View File

@@ -3,7 +3,7 @@ use std::sync::Arc;
#[cfg(feature = "sqlite")] #[cfg(feature = "sqlite")]
use crate::SqliteUserRepository; use crate::SqliteUserRepository;
use crate::db::DatabasePool; use crate::db::DatabasePool;
use template_domain::UserRepository; use domain::UserRepository;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum FactoryError { pub enum FactoryError {
@@ -12,7 +12,7 @@ pub enum FactoryError {
#[error("Not implemented: {0}")] #[error("Not implemented: {0}")]
NotImplemented(String), NotImplemented(String),
#[error("Infrastructure error: {0}")] #[error("Infrastructure error: {0}")]
Infrastructure(#[from] template_domain::DomainError), Infrastructure(#[from] domain::DomainError),
} }
pub type FactoryResult<T> = Result<T, FactoryError>; pub type FactoryResult<T> = Result<T, FactoryError>;

View File

@@ -5,7 +5,7 @@ use chrono::{DateTime, Utc};
use sqlx::{FromRow, SqlitePool}; use sqlx::{FromRow, SqlitePool};
use uuid::Uuid; use uuid::Uuid;
use template_domain::{DomainError, DomainResult, Email, User, UserRepository}; use domain::{DomainError, DomainResult, Email, User, UserRepository};
/// SQLite adapter for UserRepository /// SQLite adapter for UserRepository
#[cfg(feature = "sqlite")] #[cfg(feature = "sqlite")]
@@ -145,7 +145,7 @@ mod tests {
use k_core::db::connect; // Import k_core::db::connect use k_core::db::connect; // Import k_core::db::connect
async fn setup_test_db() -> SqlitePool { async fn setup_test_db() -> SqlitePool {
let config = DatabaseConfig::in_memory(); let config = DatabaseConfig::default();
// connect returns DatabasePool directly now // connect returns DatabasePool directly now
let db_pool = connect(&config).await.expect("Failed to create pool"); let db_pool = connect(&config).await.expect("Failed to create pool");
run_migrations(&db_pool).await.unwrap(); run_migrations(&db_pool).await.unwrap();