app: refresh/logout use cases, update login with refresh token

This commit is contained in:
2026-06-11 14:35:53 +02:00
parent 3a3f3b3889
commit 55feaa353f
12 changed files with 184 additions and 12 deletions

View File

@@ -30,7 +30,7 @@ impl AuthConfig {
let ttl_seconds = std::env::var("JWT_TTL_SECONDS") let ttl_seconds = std::env::var("JWT_TTL_SECONDS")
.ok() .ok()
.and_then(|v| v.parse().ok()) .and_then(|v| v.parse().ok())
.unwrap_or(86400u64); .unwrap_or(900u64);
Ok(Self { Ok(Self {
secret, secret,
ttl_seconds, ttl_seconds,

View File

@@ -1,12 +1,13 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Duration, Utc};
use uuid::Uuid; use uuid::Uuid;
use domain::{errors::DomainError, value_objects::Email}; use domain::{errors::DomainError, models::RefreshSession, value_objects::Email};
use crate::{auth::queries::LoginQuery, context::AppContext}; use crate::{auth::queries::LoginQuery, context::AppContext};
pub struct LoginResult { pub struct LoginResult {
pub token: String, pub token: String,
pub refresh_token: String,
pub user_id: Uuid, pub user_id: Uuid,
pub email: String, pub email: String,
pub expires_at: DateTime<Utc>, pub expires_at: DateTime<Utc>,
@@ -33,8 +34,20 @@ pub async fn execute(ctx: &AppContext, query: LoginQuery) -> Result<LoginResult,
let generated = ctx.services.auth.generate_token(user.id()).await?; let generated = ctx.services.auth.generate_token(user.id()).await?;
let refresh_token = Uuid::new_v4().to_string();
let refresh_expires = Utc::now() + Duration::seconds(ctx.config.refresh_ttl_seconds as i64);
let session = RefreshSession {
id: Uuid::new_v4(),
user_id: user.id().clone(),
token: refresh_token.clone(),
expires_at: refresh_expires,
created_at: Utc::now(),
};
ctx.repos.refresh_session.create(&session).await?;
Ok(LoginResult { Ok(LoginResult {
token: generated.token, token: generated.token,
refresh_token,
user_id: user.id().value(), user_id: user.id().value(),
email: user.email().value().to_string(), email: user.email().value().to_string(),
expires_at: generated.expires_at, expires_at: generated.expires_at,

View File

@@ -0,0 +1,7 @@
use domain::errors::DomainError;
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, refresh_token: &str) -> Result<(), DomainError> {
ctx.repos.refresh_session.revoke(refresh_token).await
}

View File

@@ -1,5 +1,7 @@
pub mod commands; pub mod commands;
pub mod login; pub mod login;
pub mod logout;
pub mod queries; pub mod queries;
pub mod refresh;
pub mod register; pub mod register;
pub mod register_and_login; pub mod register_and_login;

View File

@@ -0,0 +1,59 @@
use chrono::{Duration, Utc};
use uuid::Uuid;
use domain::{errors::DomainError, models::RefreshSession};
use crate::context::AppContext;
pub struct RefreshResult {
pub token: String,
pub refresh_token: String,
pub expires_at: chrono::DateTime<Utc>,
}
pub async fn execute(
ctx: &AppContext,
old_refresh_token: &str,
) -> Result<RefreshResult, DomainError> {
let session = ctx
.repos
.refresh_session
.get_by_token(old_refresh_token)
.await?
.ok_or_else(|| DomainError::Unauthorized("Invalid refresh token".into()))?;
if session.expires_at < Utc::now() {
ctx.repos
.refresh_session
.revoke(old_refresh_token)
.await?;
return Err(DomainError::Unauthorized("Refresh token expired".into()));
}
// Revoke old token (rotation)
ctx.repos
.refresh_session
.revoke(old_refresh_token)
.await?;
// Generate new access token
let generated = ctx.services.auth.generate_token(&session.user_id).await?;
// Create new refresh session
let new_refresh_token = Uuid::new_v4().to_string();
let refresh_expires = Utc::now() + Duration::seconds(ctx.config.refresh_ttl_seconds as i64);
let new_session = RefreshSession {
id: Uuid::new_v4(),
user_id: session.user_id,
token: new_refresh_token.clone(),
expires_at: refresh_expires,
created_at: Utc::now(),
};
ctx.repos.refresh_session.create(&new_session).await?;
Ok(RefreshResult {
token: generated.token,
refresh_token: new_refresh_token,
expires_at: generated.expires_at,
})
}

View File

@@ -3,6 +3,7 @@ pub struct AppConfig {
pub allow_registration: bool, pub allow_registration: bool,
pub base_url: String, pub base_url: String,
pub rate_limit: u64, pub rate_limit: u64,
pub refresh_ttl_seconds: u64,
pub wrapup: WrapUpConfig, pub wrapup: WrapUpConfig,
} }
@@ -24,10 +25,15 @@ impl AppConfig {
.ok() .ok()
.and_then(|v| v.parse().ok()) .and_then(|v| v.parse().ok())
.unwrap_or(60); .unwrap_or(60);
let refresh_ttl_seconds = std::env::var("REFRESH_TTL_SECONDS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(2_592_000u64);
Self { Self {
allow_registration, allow_registration,
base_url, base_url,
rate_limit, rate_limit,
refresh_ttl_seconds,
wrapup: WrapUpConfig::from_env(), wrapup: WrapUpConfig::from_env(),
} }
} }

View File

@@ -162,6 +162,31 @@ impl PeriodicJob for WrapUpAutoGenerateJob {
} }
} }
pub struct RefreshSessionCleanupJob {
ctx: AppContext,
}
impl RefreshSessionCleanupJob {
pub fn new(ctx: AppContext) -> Self {
Self { ctx }
}
}
#[async_trait]
impl PeriodicJob for RefreshSessionCleanupJob {
fn interval(&self) -> Duration {
Duration::from_secs(86400)
}
async fn run(&self) -> Result<(), DomainError> {
let n = self.ctx.repos.refresh_session.delete_expired().await?;
if n > 0 {
tracing::info!("refresh session cleanup: removed {n} expired sessions");
}
Ok(())
}
}
pub struct WrapUpCleanupJob { pub struct WrapUpCleanupJob {
ctx: AppContext, ctx: AppContext,
} }

View File

@@ -19,10 +19,10 @@ use domain::{
FakePasswordHasher, FakePersonQuery, FakePosterFetcher, FakeSearchCommand, FakeSearchPort, FakePasswordHasher, FakePersonQuery, FakePosterFetcher, FakeSearchCommand, FakeSearchPort,
FakeStatsRepository, InMemoryImportProfileRepository, InMemoryImportSessionRepository, FakeStatsRepository, InMemoryImportProfileRepository, InMemoryImportSessionRepository,
InMemoryMovieProfileRepository, InMemoryMovieRepository, InMemoryProfileFieldsRepo, InMemoryMovieProfileRepository, InMemoryMovieRepository, InMemoryProfileFieldsRepo,
InMemoryReviewRepository, InMemoryUserRepository, InMemoryUserSettingsRepository, InMemoryRefreshSessionRepository, InMemoryReviewRepository, InMemoryUserRepository,
InMemoryWatchEventRepository, InMemoryWatchlistRepository, InMemoryWebhookTokenRepository, InMemoryUserSettingsRepository, InMemoryWatchEventRepository, InMemoryWatchlistRepository,
NoopEventPublisher, NoopObjectStorage, PanicDiaryExporter, PanicPersonCommand, InMemoryWebhookTokenRepository, NoopEventPublisher, NoopObjectStorage,
PanicRefreshSessionRepository, PanicDiaryExporter, PanicPersonCommand,
}, },
}; };
@@ -119,11 +119,12 @@ impl TestContextBuilder {
user_settings_repo: InMemoryUserSettingsRepository::new(), user_settings_repo: InMemoryUserSettingsRepository::new(),
review_logger: Arc::new(NoopReviewLogger), review_logger: Arc::new(NoopReviewLogger),
social_query: Arc::new(NoopSocialQueryPort), social_query: Arc::new(NoopSocialQueryPort),
refresh_session_repo: Arc::new(PanicRefreshSessionRepository), refresh_session_repo: InMemoryRefreshSessionRepository::new(),
config: AppConfig { config: AppConfig {
allow_registration: true, allow_registration: true,
base_url: "http://localhost:3000".into(), base_url: "http://localhost:3000".into(),
rate_limit: 20, rate_limit: 20,
refresh_ttl_seconds: 2_592_000,
wrapup: crate::config::WrapUpConfig { wrapup: crate::config::WrapUpConfig {
font_path: None, font_path: None,
logo_path: None, logo_path: None,

View File

@@ -6,19 +6,22 @@ use uuid::Uuid;
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use chrono::Utc;
use crate::{ use crate::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
models::{ models::{
Goal, ImportProfile, ImportSession, Movie, MovieFilter, MovieProfile, MovieSummary, Goal, ImportProfile, ImportSession, Movie, MovieFilter, MovieProfile, MovieSummary,
ProfileField, Review, User, UserSettings, UserSummary, WatchEvent, WatchEventStatus, ProfileField, RefreshSession, Review, User, UserSettings, UserSummary, WatchEvent,
WatchlistEntry, WatchlistWithMovie, WebhookToken, WatchEventStatus, WatchlistEntry, WatchlistWithMovie, WebhookToken,
collections::{PageParams, Paginated}, collections::{PageParams, Paginated},
}, },
ports::{ ports::{
GoalRepository, ImportProfileRepository, ImportSessionRepository, MovieProfileRepository, GoalRepository, ImportProfileRepository, ImportSessionRepository, MovieProfileRepository,
MovieRepository, ReviewRepository, UserProfileFieldsRepository, UserRepository, MovieRepository, RefreshSessionRepository, ReviewRepository, UserProfileFieldsRepository,
UserSettingsRepository, WatchEventRepository, WatchlistRepository, WebhookTokenRepository, UserRepository, UserSettingsRepository, WatchEventRepository, WatchlistRepository,
WebhookTokenRepository,
}, },
value_objects::{ value_objects::{
Email, ExternalMetadataId, GoalId, ImportProfileId, ImportSessionId, MovieId, MovieTitle, Email, ExternalMetadataId, GoalId, ImportProfileId, ImportSessionId, MovieId, MovieTitle,
@@ -777,3 +780,56 @@ impl UserProfileFieldsRepository for InMemoryProfileFieldsRepo {
Ok(()) Ok(())
} }
} }
// ── InMemoryRefreshSessionRepository ────────────────────────────────────────
pub struct InMemoryRefreshSessionRepository {
pub store: Mutex<Vec<RefreshSession>>,
}
impl InMemoryRefreshSessionRepository {
pub fn new() -> Arc<Self> {
Arc::new(Self {
store: Mutex::new(Vec::new()),
})
}
}
#[async_trait]
impl RefreshSessionRepository for InMemoryRefreshSessionRepository {
async fn create(&self, session: &RefreshSession) -> Result<(), DomainError> {
self.store.lock().unwrap().push(session.clone());
Ok(())
}
async fn get_by_token(&self, token: &str) -> Result<Option<RefreshSession>, DomainError> {
Ok(self
.store
.lock()
.unwrap()
.iter()
.find(|s| s.token == token)
.cloned())
}
async fn revoke(&self, token: &str) -> Result<(), DomainError> {
self.store.lock().unwrap().retain(|s| s.token != token);
Ok(())
}
async fn revoke_all_for_user(&self, user_id: &UserId) -> Result<(), DomainError> {
self.store
.lock()
.unwrap()
.retain(|s| s.user_id != *user_id);
Ok(())
}
async fn delete_expired(&self) -> Result<u64, DomainError> {
let mut store = self.store.lock().unwrap();
let before = store.len();
let now = Utc::now();
store.retain(|s| s.expires_at >= now);
Ok((before - store.len()) as u64)
}
}

View File

@@ -812,6 +812,7 @@ pub fn make_test_state(auth_service: Arc<dyn AuthService>) -> crate::state::AppS
allow_registration: false, allow_registration: false,
base_url: "http://localhost:3000".to_string(), base_url: "http://localhost:3000".to_string(),
rate_limit: 20, rate_limit: 20,
refresh_ttl_seconds: 2_592_000,
wrapup: application::config::WrapUpConfig { wrapup: application::config::WrapUpConfig {
font_path: None, font_path: None,
logo_path: None, logo_path: None,

View File

@@ -476,6 +476,7 @@ async fn test_app() -> Router {
allow_registration: false, allow_registration: false,
base_url: "http://localhost:3000".to_string(), base_url: "http://localhost:3000".to_string(),
rate_limit: 20, rate_limit: 20,
refresh_ttl_seconds: 2_592_000,
wrapup: application::config::WrapUpConfig { wrapup: application::config::WrapUpConfig {
font_path: None, font_path: None,
logo_path: None, logo_path: None,

View File

@@ -178,6 +178,7 @@ async fn main() -> anyhow::Result<()> {
Arc::new(application::jobs::WatchEventCleanupJob::new(ctx.clone())), Arc::new(application::jobs::WatchEventCleanupJob::new(ctx.clone())),
Arc::new(application::jobs::WrapUpAutoGenerateJob::new(ctx.clone())), Arc::new(application::jobs::WrapUpAutoGenerateJob::new(ctx.clone())),
Arc::new(application::jobs::WrapUpCleanupJob::new(ctx.clone())), Arc::new(application::jobs::WrapUpCleanupJob::new(ctx.clone())),
Arc::new(application::jobs::RefreshSessionCleanupJob::new(ctx.clone())),
]; ];
if let Some(job) = enrichment_job { if let Some(job) = enrichment_job {
periodic_jobs.push(job); periodic_jobs.push(job);