app: refresh/logout use cases, update login with refresh token
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
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};
|
||||
|
||||
pub struct LoginResult {
|
||||
pub token: String,
|
||||
pub refresh_token: String,
|
||||
pub user_id: Uuid,
|
||||
pub email: String,
|
||||
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 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 {
|
||||
token: generated.token,
|
||||
refresh_token,
|
||||
user_id: user.id().value(),
|
||||
email: user.email().value().to_string(),
|
||||
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 login;
|
||||
pub mod logout;
|
||||
pub mod queries;
|
||||
pub mod refresh;
|
||||
pub mod register;
|
||||
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 base_url: String,
|
||||
pub rate_limit: u64,
|
||||
pub refresh_ttl_seconds: u64,
|
||||
pub wrapup: WrapUpConfig,
|
||||
}
|
||||
|
||||
@@ -24,10 +25,15 @@ impl AppConfig {
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.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 {
|
||||
allow_registration,
|
||||
base_url,
|
||||
rate_limit,
|
||||
refresh_ttl_seconds,
|
||||
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 {
|
||||
ctx: AppContext,
|
||||
}
|
||||
|
||||
@@ -19,10 +19,10 @@ use domain::{
|
||||
FakePasswordHasher, FakePersonQuery, FakePosterFetcher, FakeSearchCommand, FakeSearchPort,
|
||||
FakeStatsRepository, InMemoryImportProfileRepository, InMemoryImportSessionRepository,
|
||||
InMemoryMovieProfileRepository, InMemoryMovieRepository, InMemoryProfileFieldsRepo,
|
||||
InMemoryReviewRepository, InMemoryUserRepository, InMemoryUserSettingsRepository,
|
||||
InMemoryWatchEventRepository, InMemoryWatchlistRepository, InMemoryWebhookTokenRepository,
|
||||
NoopEventPublisher, NoopObjectStorage, PanicDiaryExporter, PanicPersonCommand,
|
||||
PanicRefreshSessionRepository,
|
||||
InMemoryRefreshSessionRepository, InMemoryReviewRepository, InMemoryUserRepository,
|
||||
InMemoryUserSettingsRepository, InMemoryWatchEventRepository, InMemoryWatchlistRepository,
|
||||
InMemoryWebhookTokenRepository, NoopEventPublisher, NoopObjectStorage,
|
||||
PanicDiaryExporter, PanicPersonCommand,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -119,11 +119,12 @@ impl TestContextBuilder {
|
||||
user_settings_repo: InMemoryUserSettingsRepository::new(),
|
||||
review_logger: Arc::new(NoopReviewLogger),
|
||||
social_query: Arc::new(NoopSocialQueryPort),
|
||||
refresh_session_repo: Arc::new(PanicRefreshSessionRepository),
|
||||
refresh_session_repo: InMemoryRefreshSessionRepository::new(),
|
||||
config: AppConfig {
|
||||
allow_registration: true,
|
||||
base_url: "http://localhost:3000".into(),
|
||||
rate_limit: 20,
|
||||
refresh_ttl_seconds: 2_592_000,
|
||||
wrapup: crate::config::WrapUpConfig {
|
||||
font_path: None,
|
||||
logo_path: None,
|
||||
|
||||
Reference in New Issue
Block a user