feat(magiclink) prepare magic link support (login via email)
imortant on security side: magic link will be enabled only for users who don't have password nor OIDC
This commit is contained in:
@@ -7,6 +7,8 @@ use crate::application::services::auth_application_service::AuthApplicationServi
|
||||
use crate::application::services::user_lifecycle_service::UserLifecycleService;
|
||||
use crate::common::config::AppConfig;
|
||||
use crate::common::di::AuthServices;
|
||||
use crate::domain::repositories::magic_link_token_repository::MagicLinkTokenRepository;
|
||||
use crate::infrastructure::repositories::pg::MagicLinkTokenPgRepository;
|
||||
use crate::infrastructure::repositories::{SessionPgRepository, UserPgRepository};
|
||||
use crate::infrastructure::services::jwt_service::JwtTokenService;
|
||||
use crate::infrastructure::services::oidc_service::OidcService;
|
||||
@@ -50,6 +52,16 @@ pub async fn create_auth_services(
|
||||
// direct FolderService dependency for that path.
|
||||
auth_app_service = auth_app_service.with_user_lifecycle(user_lifecycle);
|
||||
|
||||
// Wire the magic-link token repo. Enables `GET /magic/v1/{token}`
|
||||
// and the future `POST /api/auth/magic-link/send` endpoint to mint
|
||||
// and consume tokens. The repo is unconditional (it's just SQL on
|
||||
// an empty table when the feature is dormant); the feature kill
|
||||
// switch lives in `config.magic_link.allow_external_users`, checked
|
||||
// by the issuance side, not by the redemption side.
|
||||
let magic_link_repo: Arc<dyn MagicLinkTokenRepository> =
|
||||
Arc::new(MagicLinkTokenPgRepository::new(pool.clone()));
|
||||
auth_app_service = auth_app_service.with_magic_link_repo(magic_link_repo);
|
||||
|
||||
// Configure OIDC service if enabled
|
||||
if config.oidc.enabled {
|
||||
tracing::info!(
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
//! PostgreSQL implementation of [`MagicLinkTokenRepository`].
|
||||
//!
|
||||
//! Mirrors the layout of `device_code_pg_repository.rs` — same crate
|
||||
//! conventions (handcrafted SQL, `Row` extraction in a `map_row` helper,
|
||||
//! enum cast in the INSERT statement).
|
||||
|
||||
use async_trait::async_trait;
|
||||
use sqlx::{PgPool, Postgres, Row, Transaction};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
use crate::domain::entities::magic_link_token::{
|
||||
MagicLinkResourceKind, MagicLinkStatus, MagicLinkToken,
|
||||
};
|
||||
use crate::domain::repositories::magic_link_token_repository::MagicLinkTokenRepository;
|
||||
|
||||
pub struct MagicLinkTokenPgRepository {
|
||||
pool: Arc<PgPool>,
|
||||
}
|
||||
|
||||
impl MagicLinkTokenPgRepository {
|
||||
pub fn new(pool: Arc<PgPool>) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
fn map_row(row: &sqlx::postgres::PgRow) -> Result<MagicLinkToken, DomainError> {
|
||||
let status_str: String = row.try_get("status").map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::DatabaseError,
|
||||
"MagicLinkToken",
|
||||
format!("read status: {}", e),
|
||||
)
|
||||
})?;
|
||||
let status = MagicLinkStatus::parse(&status_str).unwrap_or(MagicLinkStatus::Expired);
|
||||
|
||||
let resource_type: Option<String> = row.try_get("resource_type").ok();
|
||||
let resource_kind = resource_type.and_then(|s| MagicLinkResourceKind::parse(&s));
|
||||
let resource_id: Option<Uuid> = row.try_get("resource_id").ok();
|
||||
|
||||
Ok(MagicLinkToken::from_raw(
|
||||
row.try_get("id").unwrap(),
|
||||
row.try_get("token").unwrap_or_default(),
|
||||
row.try_get("user_id").unwrap(),
|
||||
status,
|
||||
row.try_get("issued_at").unwrap_or_default(),
|
||||
row.try_get("expires_at").unwrap_or_default(),
|
||||
row.try_get("used_at").ok(),
|
||||
resource_kind,
|
||||
resource_id,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MagicLinkTokenRepository for MagicLinkTokenPgRepository {
|
||||
async fn create(&self, token: &MagicLinkToken) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO auth.magic_link_tokens (
|
||||
id, token, user_id, status,
|
||||
issued_at, expires_at, used_at,
|
||||
resource_type, resource_id
|
||||
) VALUES (
|
||||
$1, $2, $3, $4::auth.magic_link_status,
|
||||
$5, $6, $7,
|
||||
$8, $9
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.bind(token.id())
|
||||
.bind(token.token())
|
||||
.bind(token.user_id())
|
||||
.bind(token.status().as_str())
|
||||
.bind(token.issued_at())
|
||||
.bind(token.expires_at())
|
||||
.bind(token.used_at())
|
||||
.bind(token.resource_kind().map(|k| k.as_str()))
|
||||
.bind(token.resource_id())
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("MagicLinkToken", format!("insert: {}", e)))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find_by_token(&self, token: &str) -> Result<Option<MagicLinkToken>, DomainError> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT id, token, user_id, status::text AS status,
|
||||
issued_at, expires_at, used_at,
|
||||
resource_type, resource_id
|
||||
FROM auth.magic_link_tokens
|
||||
WHERE token = $1
|
||||
"#,
|
||||
)
|
||||
.bind(token)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("MagicLinkToken", format!("find_by_token: {}", e))
|
||||
})?;
|
||||
|
||||
row.map(|r| Self::map_row(&r)).transpose()
|
||||
}
|
||||
|
||||
async fn mark_used(&self, id: Uuid) -> Result<bool, DomainError> {
|
||||
// The `status = 'pending'` predicate is what makes single-use
|
||||
// race-free: a concurrent redemption attempt sees the row
|
||||
// already updated (or in the middle of being updated, blocking
|
||||
// on Postgres' row lock) and gets `rows_affected = 0`.
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE auth.magic_link_tokens
|
||||
SET status = 'used'::auth.magic_link_status,
|
||||
used_at = NOW()
|
||||
WHERE id = $1
|
||||
AND status = 'pending'::auth.magic_link_status
|
||||
AND expires_at > NOW()
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("MagicLinkToken", format!("mark_used: {}", e)))?;
|
||||
|
||||
Ok(result.rows_affected() == 1)
|
||||
}
|
||||
|
||||
async fn delete_expired(&self) -> Result<u64, DomainError> {
|
||||
// Hard-delete: the audit trail lives in the `tracing` log, not
|
||||
// the table. Keeping expired rows around would just bloat the
|
||||
// index without adding security value.
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
DELETE FROM auth.magic_link_tokens
|
||||
WHERE status = 'pending'::auth.magic_link_status
|
||||
AND expires_at < NOW()
|
||||
"#,
|
||||
)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("MagicLinkToken", format!("delete_expired: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
async fn delete_all_for_user_tx(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
) -> Result<u64, DomainError> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
DELETE FROM auth.magic_link_tokens
|
||||
WHERE user_id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("MagicLinkToken", format!("delete_all_for_user_tx: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ mod contact_pg_repository;
|
||||
mod device_code_pg_repository;
|
||||
mod favorites_pg_repository;
|
||||
pub mod file_metadata_repository;
|
||||
mod magic_link_token_pg_repository;
|
||||
mod nextcloud_object_id_repository;
|
||||
pub mod playlist_pg_repository;
|
||||
mod recent_items_pg_repository;
|
||||
@@ -37,6 +38,7 @@ pub use file_blob_read_repository::FileBlobReadRepository;
|
||||
pub use file_blob_write_repository::FileBlobWriteRepository;
|
||||
pub use file_metadata_repository::FileMetadataRepository;
|
||||
pub use folder_db_repository::FolderDbRepository;
|
||||
pub use magic_link_token_pg_repository::MagicLinkTokenPgRepository;
|
||||
pub use nextcloud_object_id_repository::NextcloudObjectIdRepository;
|
||||
pub use playlist_pg_repository::{
|
||||
AudioMetadataPgRepository, PlaylistItemPgRepository, PlaylistPgRepository,
|
||||
|
||||
Reference in New Issue
Block a user