Refactor schedule and user repositories into modular structure

- Moved schedule repository logic into separate modules for SQLite and PostgreSQL implementations.
- Created a mapping module for shared data structures and mapping functions in the schedule repository.
- Added new mapping module for user repository to handle user data transformations.
- Implemented PostgreSQL and SQLite user repository adapters with necessary CRUD operations.
- Added tests for user repository functionality, including saving, finding, and deleting users.
This commit is contained in:
2026-03-13 01:35:14 +01:00
parent 79ced7b77b
commit eeb4e2cb41
39 changed files with 2288 additions and 2194 deletions

View File

@@ -0,0 +1,104 @@
use axum::{
Json,
extract::State,
http::StatusCode,
response::IntoResponse,
};
use crate::{
dto::{LoginRequest, RegisterRequest, TokenResponse, UserResponse},
error::ApiError,
extractors::CurrentUser,
state::AppState,
};
use super::create_jwt;
/// Login with email + password → JWT token
pub(super) async fn login(
State(state): State<AppState>,
Json(payload): Json<LoginRequest>,
) -> Result<impl IntoResponse, ApiError> {
let user = state
.user_service
.find_by_email(payload.email.as_ref())
.await?
.ok_or_else(|| ApiError::Unauthorized("Invalid credentials".to_string()))?;
let hash = user
.password_hash
.as_deref()
.ok_or_else(|| ApiError::Unauthorized("Invalid credentials".to_string()))?;
if !infra::auth::verify_password(payload.password.as_ref(), hash) {
return Err(ApiError::Unauthorized("Invalid credentials".to_string()));
}
let token = create_jwt(&user, &state)?;
Ok((
StatusCode::OK,
Json(TokenResponse {
access_token: token,
token_type: "Bearer".to_string(),
expires_in: state.config.jwt_expiry_hours * 3600,
}),
))
}
/// Register a new local user → JWT token
pub(super) async fn register(
State(state): State<AppState>,
Json(payload): Json<RegisterRequest>,
) -> Result<impl IntoResponse, ApiError> {
if !state.config.allow_registration {
return Err(ApiError::Forbidden("Registration is disabled".to_string()));
}
let password_hash = infra::auth::hash_password(payload.password.as_ref());
let user = state
.user_service
.create_local(payload.email.as_ref(), &password_hash)
.await?;
let token = create_jwt(&user, &state)?;
Ok((
StatusCode::CREATED,
Json(TokenResponse {
access_token: token,
token_type: "Bearer".to_string(),
expires_in: state.config.jwt_expiry_hours * 3600,
}),
))
}
/// Logout — JWT is stateless; instruct the client to drop the token
pub(super) async fn logout() -> impl IntoResponse {
StatusCode::OK
}
/// Get current user info from JWT
pub(super) async fn me(CurrentUser(user): CurrentUser) -> Result<impl IntoResponse, ApiError> {
Ok(Json(UserResponse {
id: user.id,
email: user.email.into_inner(),
created_at: user.created_at,
}))
}
/// Issue a new JWT for the currently authenticated user (OIDC→JWT exchange or token refresh)
#[cfg(feature = "auth-jwt")]
pub(super) async fn get_token(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
) -> Result<impl IntoResponse, ApiError> {
let token = create_jwt(&user, &state)?;
Ok(Json(TokenResponse {
access_token: token,
token_type: "Bearer".to_string(),
expires_in: state.config.jwt_expiry_hours * 3600,
}))
}

View File

@@ -0,0 +1,47 @@
//! Authentication routes
//!
//! Provides login, register, logout, token, and OIDC endpoints.
//! All authentication is JWT-based. OIDC state is stored in an encrypted cookie.
use axum::{Router, routing::{get, post}};
use crate::{error::ApiError, state::AppState};
mod local;
mod oidc;
pub fn router() -> Router<AppState> {
let r = Router::new()
.route("/login", post(local::login))
.route("/register", post(local::register))
.route("/logout", post(local::logout))
.route("/me", get(local::me));
#[cfg(feature = "auth-jwt")]
let r = r.route("/token", post(local::get_token));
#[cfg(feature = "auth-oidc")]
let r = r
.route("/login/oidc", get(oidc::oidc_login))
.route("/callback", get(oidc::oidc_callback));
r
}
/// Helper: create JWT for a user
#[cfg(feature = "auth-jwt")]
pub(super) fn create_jwt(user: &domain::User, state: &AppState) -> Result<String, ApiError> {
let validator = state
.jwt_validator
.as_ref()
.ok_or_else(|| ApiError::Internal("JWT not configured".to_string()))?;
validator
.create_token(user)
.map_err(|e| ApiError::Internal(format!("Failed to create token: {}", e)))
}
#[cfg(not(feature = "auth-jwt"))]
pub(super) fn create_jwt(_user: &domain::User, _state: &AppState) -> Result<String, ApiError> {
Err(ApiError::Internal("JWT feature not enabled".to_string()))
}

View File

@@ -0,0 +1,124 @@
#[cfg(feature = "auth-oidc")]
use axum::{
Json,
extract::State,
http::header,
response::{IntoResponse, Response},
};
#[cfg(feature = "auth-oidc")]
use crate::{
dto::TokenResponse,
error::ApiError,
state::AppState,
};
#[cfg(feature = "auth-oidc")]
use super::create_jwt;
#[cfg(feature = "auth-oidc")]
#[derive(serde::Deserialize)]
pub(super) struct CallbackParams {
pub code: String,
pub state: String,
}
/// Start OIDC login: generate authorization URL and store state in encrypted cookie
#[cfg(feature = "auth-oidc")]
pub(super) async fn oidc_login(
State(state): State<AppState>,
jar: axum_extra::extract::PrivateCookieJar,
) -> Result<impl IntoResponse, ApiError> {
use axum_extra::extract::cookie::{Cookie, SameSite};
let service = state
.oidc_service
.as_ref()
.ok_or(ApiError::Internal("OIDC not configured".into()))?;
let (auth_data, oidc_state) = service.get_authorization_url();
let state_json = serde_json::to_string(&oidc_state)
.map_err(|e| ApiError::Internal(format!("Failed to serialize OIDC state: {}", e)))?;
let cookie = Cookie::build(("oidc_state", state_json))
.max_age(time::Duration::minutes(5))
.http_only(true)
.same_site(SameSite::Lax)
.secure(state.config.secure_cookie)
.path("/")
.build();
let updated_jar = jar.add(cookie);
let redirect = axum::response::Redirect::to(auth_data.url.as_str()).into_response();
let (mut parts, body) = redirect.into_parts();
parts.headers.insert(
header::CACHE_CONTROL,
"no-cache, no-store, must-revalidate".parse().unwrap(),
);
parts
.headers
.insert(header::PRAGMA, "no-cache".parse().unwrap());
parts.headers.insert(header::EXPIRES, "0".parse().unwrap());
Ok((updated_jar, Response::from_parts(parts, body)))
}
/// Handle OIDC callback: verify state cookie, complete exchange, issue JWT, clear cookie
#[cfg(feature = "auth-oidc")]
pub(super) async fn oidc_callback(
State(state): State<AppState>,
jar: axum_extra::extract::PrivateCookieJar,
axum::extract::Query(params): axum::extract::Query<CallbackParams>,
) -> Result<impl IntoResponse, ApiError> {
use infra::auth::oidc::OidcState;
let service = state
.oidc_service
.as_ref()
.ok_or(ApiError::Internal("OIDC not configured".into()))?;
// Read and decrypt OIDC state from cookie
let cookie = jar
.get("oidc_state")
.ok_or(ApiError::Validation("Missing OIDC state cookie".into()))?;
let oidc_state: OidcState = serde_json::from_str(cookie.value())
.map_err(|_| ApiError::Validation("Invalid OIDC state cookie".into()))?;
// Verify CSRF token
if params.state != oidc_state.csrf_token.as_ref() {
return Err(ApiError::Validation("Invalid CSRF token".into()));
}
// Complete OIDC exchange
let oidc_user = service
.resolve_callback(
domain::AuthorizationCode::new(params.code),
oidc_state.nonce,
oidc_state.pkce_verifier,
)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let user = state
.user_service
.find_or_create(&oidc_user.subject, &oidc_user.email)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
// Clear the OIDC state cookie
let cleared_jar = jar.remove(axum_extra::extract::cookie::Cookie::from("oidc_state"));
let token = create_jwt(&user, &state)?;
Ok((
cleared_jar,
Json(TokenResponse {
access_token: token,
token_type: "Bearer".to_string(),
expires_in: state.config.jwt_expiry_hours * 3600,
}),
))
}