You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
137 lines
4.7 KiB
Rust
137 lines
4.7 KiB
Rust
//! Authentication service: magic link token generation and session management.
|
|
//!
|
|
//! This module coordinates the auth flow between the token utilities,
|
|
//! database queries, and email service.
|
|
|
|
use chrono::{TimeDelta, Utc};
|
|
use sqlx::PgPool;
|
|
use uuid::Uuid;
|
|
|
|
use crate::db;
|
|
use crate::errors::AppError;
|
|
use crate::models::user::{User, UserRole};
|
|
use crate::services::email;
|
|
use crate::util::token;
|
|
|
|
/// Magic link token expiry: 15 minutes.
|
|
const MAGIC_LINK_EXPIRY_MINUTES: i64 = 15;
|
|
|
|
/// Session expiry: 30 days.
|
|
const SESSION_EXPIRY_DAYS: i64 = 30;
|
|
|
|
/// Maximum active (unused, non-expired) magic link tokens per email address.
|
|
const MAX_ACTIVE_TOKENS_PER_EMAIL: i64 = 5;
|
|
|
|
/// Generate a magic link token for the given email.
|
|
///
|
|
/// Creates the token, stores its hash in the database, and returns the raw
|
|
/// token (to be included in the magic link URL). The raw token is never stored.
|
|
///
|
|
/// Returns `None` if the rate limit for this email has been exceeded.
|
|
pub async fn create_magic_link(pool: &PgPool, email: &str) -> Result<Option<String>, AppError> {
|
|
// Check rate limit: max active tokens per email
|
|
let active_count = db::magic_links::count_active_for_email(pool, email).await?;
|
|
if active_count >= MAX_ACTIVE_TOKENS_PER_EMAIL {
|
|
tracing::warn!(
|
|
email = email,
|
|
active_count = active_count,
|
|
"Too many active magic link tokens for email"
|
|
);
|
|
return Ok(None);
|
|
}
|
|
|
|
let raw_token = token::generate_token();
|
|
let token_hash = token::hash_token(&raw_token);
|
|
let expires_at = Utc::now()
|
|
+ TimeDelta::try_minutes(MAGIC_LINK_EXPIRY_MINUTES)
|
|
.expect("valid minutes value");
|
|
|
|
db::magic_links::create(pool, email, &token_hash, expires_at).await?;
|
|
|
|
tracing::info!(email = email, "Magic link token created");
|
|
Ok(Some(raw_token))
|
|
}
|
|
|
|
/// Create a magic link token, send it by email, and roll back if delivery fails.
|
|
///
|
|
/// Combines token creation and email sending with automatic cleanup: if the
|
|
/// email fails to send, the token is deleted so it doesn't consume a quota slot.
|
|
pub async fn create_and_send_magic_link(
|
|
pool: &PgPool,
|
|
http_client: &reqwest::Client,
|
|
resend_api_key: &str,
|
|
email_from: &str,
|
|
app_url: &str,
|
|
to_email: &str,
|
|
) -> Result<(), AppError> {
|
|
let Some(raw_token) = create_magic_link(pool, to_email).await? else {
|
|
return Ok(());
|
|
};
|
|
|
|
if let Err(e) =
|
|
email::send_magic_link(http_client, resend_api_key, email_from, to_email, app_url, &raw_token).await
|
|
{
|
|
let token_hash = token::hash_token(&raw_token);
|
|
db::magic_links::delete_by_hash(pool, &token_hash).await.ok();
|
|
return Err(e);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Verify a magic link token and return the associated email.
|
|
///
|
|
/// The token is consumed atomically (marked as used) to enforce single-use.
|
|
/// Returns `None` if the token is invalid, already used, or expired.
|
|
pub async fn verify_magic_link(pool: &PgPool, raw_token: &str) -> Result<Option<String>, AppError> {
|
|
let token_hash = token::hash_token(raw_token);
|
|
db::magic_links::consume(pool, &token_hash).await
|
|
}
|
|
|
|
/// Create a new session for the given user and return the raw session token.
|
|
///
|
|
/// The raw token is sent to the client as a cookie; only its SHA-256 hash
|
|
/// is stored in the database.
|
|
pub async fn create_session(
|
|
pool: &PgPool,
|
|
user_id: Uuid,
|
|
ip_address: Option<&str>,
|
|
user_agent: Option<&str>,
|
|
) -> Result<String, AppError> {
|
|
let raw_token = token::generate_token();
|
|
let session_hash = token::hash_token(&raw_token);
|
|
let expires_at = Utc::now()
|
|
+ TimeDelta::try_days(SESSION_EXPIRY_DAYS)
|
|
.expect("valid days value");
|
|
|
|
db::sessions::create(pool, &session_hash, user_id, expires_at, ip_address, user_agent).await?;
|
|
|
|
tracing::info!(user_id = %user_id, "Session created");
|
|
Ok(raw_token)
|
|
}
|
|
|
|
/// Ensure a user exists for the given email, creating one if necessary.
|
|
///
|
|
/// Used during magic link verification: the user may have registered
|
|
/// and verified in a single flow, so we create the user on first verification
|
|
/// if they don't already exist.
|
|
pub async fn ensure_user(
|
|
pool: &PgPool,
|
|
email: &str,
|
|
display_name: Option<&str>,
|
|
) -> Result<User, AppError> {
|
|
match db::users::find_by_email(pool, email).await? {
|
|
Some(user) => Ok(user),
|
|
None => {
|
|
let user = db::users::create(pool, email, display_name, UserRole::User).await?;
|
|
tracing::info!(email = email, user_id = %user.id, "New user created during verification");
|
|
Ok(user)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Session cookie name.
|
|
pub const SESSION_COOKIE_NAME: &str = "ai_synth_session";
|
|
|
|
/// Session cookie max age in seconds (30 days).
|
|
pub const SESSION_COOKIE_MAX_AGE: i64 = 30 * 24 * 60 * 60;
|