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

//! 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;