app: refresh/logout use cases, update login with refresh token
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
7
crates/application/src/auth/logout.rs
Normal file
7
crates/application/src/auth/logout.rs
Normal 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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
59
crates/application/src/auth/refresh.rs
Normal file
59
crates/application/src/auth/refresh.rs
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user