|
|
|
|
@ -14,6 +14,8 @@ use axum::response::{IntoResponse, Redirect, Response};
|
|
|
|
|
use axum::Json;
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
|
|
|
|
|
use email_address::EmailAddress;
|
|
|
|
|
|
|
|
|
|
use crate::app_state::AppState;
|
|
|
|
|
use crate::db;
|
|
|
|
|
use crate::errors::AppError;
|
|
|
|
|
@ -63,13 +65,12 @@ pub async fn register(
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
Json(body): Json<RegisterRequest>,
|
|
|
|
|
) -> Result<impl IntoResponse, AppError> {
|
|
|
|
|
// Validate email format (basic check)
|
|
|
|
|
if !body.email.contains('@') || body.email.len() > 254 {
|
|
|
|
|
// Validate email format using RFC 5321 validation
|
|
|
|
|
let email_lower = body.email.trim().to_lowercase();
|
|
|
|
|
if !EmailAddress::is_valid(&email_lower) || email_lower.len() > 254 {
|
|
|
|
|
return Err(AppError::BadRequest("Invalid email address".into()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let email_lower = body.email.trim().to_lowercase();
|
|
|
|
|
|
|
|
|
|
// Rate limit by email
|
|
|
|
|
if !state.auth_rate_limiter.check(&format!("register:{}", email_lower)) {
|
|
|
|
|
return Err(AppError::RateLimited(
|
|
|
|
|
@ -124,12 +125,12 @@ pub async fn login(
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
Json(body): Json<LoginRequest>,
|
|
|
|
|
) -> Result<impl IntoResponse, AppError> {
|
|
|
|
|
if !body.email.contains('@') || body.email.len() > 254 {
|
|
|
|
|
// Validate email format using RFC 5321 validation
|
|
|
|
|
let email_lower = body.email.trim().to_lowercase();
|
|
|
|
|
if !EmailAddress::is_valid(&email_lower) || email_lower.len() > 254 {
|
|
|
|
|
return Err(AppError::BadRequest("Invalid email address".into()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let email_lower = body.email.trim().to_lowercase();
|
|
|
|
|
|
|
|
|
|
// Rate limit by email
|
|
|
|
|
if !state.auth_rate_limiter.check(&format!("login:{}", email_lower)) {
|
|
|
|
|
return Err(AppError::RateLimited(
|
|
|
|
|
@ -174,26 +175,25 @@ pub async fn login(
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// `GET /api/v1/auth/verify?token=...`
|
|
|
|
|
///
|
|
|
|
|
/// Verifies the magic link token, creates a session, sets the session cookie,
|
|
|
|
|
/// and redirects to the app root.
|
|
|
|
|
pub async fn verify(
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
headers: HeaderMap,
|
|
|
|
|
Query(query): Query<VerifyQuery>,
|
|
|
|
|
) -> Result<Response, AppError> {
|
|
|
|
|
let email = auth::verify_magic_link(&state.pool, &query.token).await?;
|
|
|
|
|
/// Request body for `POST /api/v1/auth/verify`.
|
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
|
|
pub struct VerifyBody {
|
|
|
|
|
pub token: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Shared verification logic used by both GET and POST verify endpoints.
|
|
|
|
|
async fn verify_token(
|
|
|
|
|
state: &AppState,
|
|
|
|
|
headers: &HeaderMap,
|
|
|
|
|
raw_token: &str,
|
|
|
|
|
) -> Result<(uuid::Uuid, String, String), AppError> {
|
|
|
|
|
let email = auth::verify_magic_link(&state.pool, raw_token).await?;
|
|
|
|
|
|
|
|
|
|
let email = match email {
|
|
|
|
|
Some(e) => e,
|
|
|
|
|
None => {
|
|
|
|
|
tracing::warn!("Magic link verification failed (invalid, used, or expired)");
|
|
|
|
|
return Ok(Redirect::to(&format!(
|
|
|
|
|
"{}/?error=invalid_token",
|
|
|
|
|
state.config.app_url
|
|
|
|
|
))
|
|
|
|
|
.into_response());
|
|
|
|
|
return Err(AppError::BadRequest("Invalid or expired token".into()));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
@ -201,7 +201,7 @@ pub async fn verify(
|
|
|
|
|
let user = auth::ensure_user(&state.pool, &email, None).await?;
|
|
|
|
|
|
|
|
|
|
// Extract IP and User-Agent for session metadata
|
|
|
|
|
let ip_address = extract_client_ip(&headers);
|
|
|
|
|
let ip_address = extract_client_ip(headers);
|
|
|
|
|
let user_agent = headers
|
|
|
|
|
.get(header::USER_AGENT)
|
|
|
|
|
.and_then(|v| v.to_str().ok())
|
|
|
|
|
@ -216,22 +216,78 @@ pub async fn verify(
|
|
|
|
|
)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
// Build the Set-Cookie header
|
|
|
|
|
tracing::info!(user_id = %user.id, email = %email, "User authenticated via magic link");
|
|
|
|
|
Ok((user.id, email, session_token))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Build the Set-Cookie header value for a session.
|
|
|
|
|
fn build_session_cookie(state: &AppState, session_token: &str) -> Result<String, AppError> {
|
|
|
|
|
let secure_flag = if state.config.is_secure() {
|
|
|
|
|
"; Secure"
|
|
|
|
|
} else {
|
|
|
|
|
""
|
|
|
|
|
};
|
|
|
|
|
let cookie_value = format!(
|
|
|
|
|
Ok(format!(
|
|
|
|
|
"{}={}; HttpOnly; SameSite=Lax; Path=/; Max-Age={}{}",
|
|
|
|
|
auth::SESSION_COOKIE_NAME,
|
|
|
|
|
session_token,
|
|
|
|
|
auth::SESSION_COOKIE_MAX_AGE,
|
|
|
|
|
secure_flag,
|
|
|
|
|
);
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// `GET /api/v1/auth/verify?token=...`
|
|
|
|
|
///
|
|
|
|
|
/// Called when the user clicks the magic link in their email.
|
|
|
|
|
/// Verifies the token, creates a session, sets the session cookie,
|
|
|
|
|
/// and redirects to the app root.
|
|
|
|
|
pub async fn verify_get(
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
headers: HeaderMap,
|
|
|
|
|
Query(query): Query<VerifyQuery>,
|
|
|
|
|
) -> Result<Response, AppError> {
|
|
|
|
|
match verify_token(&state, &headers, &query.token).await {
|
|
|
|
|
Ok((_user_id, _email, session_token)) => {
|
|
|
|
|
let cookie_value = build_session_cookie(&state, &session_token)?;
|
|
|
|
|
let mut response = Redirect::to(&state.config.app_url).into_response();
|
|
|
|
|
response.headers_mut().insert(
|
|
|
|
|
header::SET_COOKIE,
|
|
|
|
|
cookie_value.parse().map_err(|_| {
|
|
|
|
|
AppError::Internal(anyhow::anyhow!("Failed to build cookie header"))
|
|
|
|
|
})?,
|
|
|
|
|
);
|
|
|
|
|
Ok(response)
|
|
|
|
|
}
|
|
|
|
|
Err(_) => Ok(Redirect::to(&format!(
|
|
|
|
|
"{}/?error=invalid_token",
|
|
|
|
|
state.config.app_url
|
|
|
|
|
))
|
|
|
|
|
.into_response()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// `POST /api/v1/auth/verify`
|
|
|
|
|
///
|
|
|
|
|
/// Called from the frontend to verify a magic link token.
|
|
|
|
|
/// Returns JSON with user info and sets the session cookie.
|
|
|
|
|
/// The token is sent in the request body (not URL) to avoid
|
|
|
|
|
/// exposure in browser history and server logs.
|
|
|
|
|
pub async fn verify_post(
|
|
|
|
|
State(state): State<AppState>,
|
|
|
|
|
headers: HeaderMap,
|
|
|
|
|
Json(body): Json<VerifyBody>,
|
|
|
|
|
) -> Result<Response, AppError> {
|
|
|
|
|
let (_user_id, _email, session_token) =
|
|
|
|
|
verify_token(&state, &headers, &body.token).await?;
|
|
|
|
|
|
|
|
|
|
let cookie_value = build_session_cookie(&state, &session_token)?;
|
|
|
|
|
|
|
|
|
|
let mut response = (
|
|
|
|
|
StatusCode::OK,
|
|
|
|
|
Json(serde_json::json!({ "message": "Verified" })),
|
|
|
|
|
)
|
|
|
|
|
.into_response();
|
|
|
|
|
|
|
|
|
|
// Redirect to app root with the session cookie set
|
|
|
|
|
let mut response = Redirect::to(&state.config.app_url).into_response();
|
|
|
|
|
response.headers_mut().insert(
|
|
|
|
|
header::SET_COOKIE,
|
|
|
|
|
cookie_value
|
|
|
|
|
@ -239,7 +295,6 @@ pub async fn verify(
|
|
|
|
|
.map_err(|_| AppError::Internal(anyhow::anyhow!("Failed to build cookie header")))?,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
tracing::info!(user_id = %user.id, email = %email, "User authenticated via magic link");
|
|
|
|
|
Ok(response)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|