pub(crate) mod ldap; mod local; use argon2::{ Argon2, password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, }; use axum::{ Json, extract::{Path as AxumPath, State}, http::{HeaderMap, StatusCode}, response::Redirect, }; use chrono::{Duration, Utc}; use lettre::{ AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, message::{Mailbox, MultiPart, SinglePart, header::ContentType}, transport::smtp::authentication::Credentials, }; use rand_core::{OsRng, RngCore}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use sqlx::FromRow; use tracing::{debug, info, warn}; use crate::{ queries, state::{SharedState, SmtpConfig}, }; const MIN_PASSWORD: usize = 8; const MAX_PASSWORD: usize = 128; const MAX_NICKNAME: usize = 40; #[derive(Debug, Clone, FromRow)] pub struct User { pub id: i64, pub nickname: String, pub email: String, pub password_hash: String, pub confirmed_at: Option, } #[derive(Deserialize)] pub struct IdentityRequest { nickname: String, #[serde(default)] session_token: Option, } #[derive(Deserialize)] pub struct RegisterRequest { nickname: String, email: String, password: String, } #[derive(Deserialize)] pub struct LoginRequest { email: String, password: String, } #[derive(Deserialize)] pub struct ConfirmAccountRequest { token: String, } #[derive(Deserialize)] pub struct ResendConfirmationRequest { email: String, } #[derive(Deserialize)] pub struct ResetRequest { email: String, } #[derive(Deserialize)] pub struct ResetConfirmRequest { token: String, password: String, } #[derive(Deserialize)] pub struct ProfileUpdateRequest { #[serde(default)] nickname: Option, #[serde(default)] new_email: Option, #[serde(default)] new_password: Option, #[serde(default)] password: String, #[serde(default)] editor_color: Option, } #[derive(Deserialize)] pub struct DeleteAccountRequest { password: String, } #[derive(Deserialize)] pub struct AccountActionConfirmRequest { token: String, } #[derive(Deserialize)] pub struct ResourceActionRequest { kind: String, slug: String, #[serde(default)] password: Option, } #[derive(Serialize, FromRow)] pub struct ResourceItem { slug: String, title: String, protected: i64, updated_at: String, #[sqlx(rename = "private")] #[serde(rename = "private")] private_resource: i64, owned: i64, permission: String, shared_by: String, } #[derive(Deserialize)] pub struct PrivacyRequest { kind: String, slug: String, private: bool, } #[derive(Deserialize)] pub struct ShareUsersRequest { kind: String, slug: String, emails: String, permission: String, } #[derive(Deserialize)] pub struct RemoveShareRequest { kind: String, slug: String, email: String, } #[derive(Deserialize)] pub struct CreateShareLinkRequest { kind: String, slug: String, permission: String, expires_at: Option, } #[derive(Deserialize)] pub struct UpdateShareLinkRequest { kind: String, slug: String, token: String, permission: String, expires_at: Option, } #[derive(Deserialize)] pub struct RevokeShareLinkRequest { kind: String, slug: String, token: String, } #[derive(Serialize)] pub struct ResourceList { workspaces: Vec, pads: Vec, } #[derive(Serialize)] pub struct SessionResponse { token: String, nickname: String, email: String, expires_at: String, directory_managed: bool, directory_display_name: Option, directory_organization: Option, suggested_nickname: Option, editor_color: Option, } #[derive(Serialize)] pub struct IdentityResponse { nickname: String, registered: bool, } #[derive(Serialize)] pub struct RegisterResponse { token: Option, nickname: String, email: String, expires_at: Option, confirmation_required: bool, message: String, } pub async fn identity( State(state): State, Json(req): Json, ) -> Result, AuthError> { if state.ldap.is_some() && req.session_token.is_none() { return Err(AuthError::unauthorized( "Log in with your organization account.", )); } let nickname = validate_nickname(&req.nickname)?; debug!(nickname = %nickname, has_session = req.session_token.is_some(), "identity check requested"); match find_user_by_nickname(&state, &nickname).await? { None => { debug!(nickname = %nickname, "nickname is available for guest use"); Ok(Json(IdentityResponse { nickname, registered: false, })) } Some(user) => { let token = req.session_token.as_deref().ok_or_else(|| { AuthError::unauthorized("This nickname is registered. Log in to use it.") })?; let current = user_from_token(&state, token).await?.ok_or_else(|| { AuthError::unauthorized("Your session has expired. Log in again.") })?; if current.id != user.id { return Err(AuthError::unauthorized( "This nickname belongs to another account.", )); } info!(user_id = user.id, nickname = %user.nickname, "registered identity authorized"); Ok(Json(IdentityResponse { nickname: user.nickname, registered: true, })) } } } pub async fn register( State(state): State, Json(req): Json, ) -> Result<(StatusCode, Json), AuthError> { if state.ldap.is_some() { return Err(AuthError::forbidden( "Local registration is disabled while LDAP authentication is enabled.", )); } if !state.registration_enabled { warn!("registration attempt rejected because registration is disabled"); return Err(AuthError::forbidden("Registration is disabled.")); } if state.account_confirmation_required && state.smtp.is_none() { return Err(AuthError::service_unavailable( "Account confirmation requires SMTP configuration.", )); } let nickname = validate_nickname(&req.nickname)?; let email = validate_email(&req.email)?; info!(nickname = %nickname, email_domain = %email_domain(&email), "registration requested"); validate_password(&req.password)?; let nickname_key = normalize(&nickname); let email_key = normalize(&email); if find_user_by_nickname(&state, &nickname).await?.is_some() { return Err(AuthError::conflict("This nickname is already registered.")); } if find_user_by_email(&state, &email).await?.is_some() { return Err(AuthError::conflict( "This e-mail address is already registered.", )); } let hash = hash_password(&req.password)?; let confirmed_at = (!state.account_confirmation_required).then(|| Utc::now().to_rfc3339()); sqlx::query(queries::get(state.db.kind(), queries::AUTH_INSERT_USER)) .bind(&nickname) .bind(nickname_key) .bind(&email) .bind(email_key) .bind(hash) .bind(confirmed_at) .execute(state.db.pool()) .await .map_err(AuthError::database)?; let user = find_user_by_nickname(&state, &nickname) .await? .ok_or_else(|| AuthError::internal("Failed to create the account."))?; let mut confirmation_token = None; if state.smtp.is_some() { let token = random_confirmation_token(); if state.account_confirmation_required { let expires = (Utc::now() + Duration::hours(24)).to_rfc3339(); let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?; sqlx::query(queries::get( state.db.kind(), queries::AUTH_DELETE_CONFIRMATION_TOKENS_BY_USER, )) .bind(user.id) .execute(&mut *tx) .await .map_err(AuthError::database)?; sqlx::query(queries::get( state.db.kind(), queries::AUTH_INSERT_CONFIRMATION_TOKEN, )) .bind(hash_token(&token)) .bind(user.id) .bind(expires) .execute(&mut *tx) .await .map_err(AuthError::database)?; tx.commit().await.map_err(AuthError::database)?; confirmation_token = Some(token.as_str()); } if let Err(error) = send_registration_email(state.smtp.as_ref().unwrap(), &user, confirmation_token).await { if state.account_confirmation_required { if let Err(delete_error) = sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_USER)) .bind(user.id) .execute(state.db.pool()) .await { tracing::error!(error=%delete_error, user_id=user.id, "failed to roll back account after confirmation e-mail error"); } return Err(error); } warn!( user_id = user.id, "account created, but registration e-mail could not be sent" ); } } else { warn!( user_id = user.id, "account created without registration e-mail because SMTP is not configured" ); } if state.account_confirmation_required { info!(user_id = user.id, nickname = %user.nickname, "account registered; confirmation required"); return Ok(( StatusCode::CREATED, Json(RegisterResponse { token: None, nickname: user.nickname, email: user.email, expires_at: None, confirmation_required: true, message: "Account created. Check your e-mail and confirm the account before logging in." .into(), }), )); } let session = create_session(&state, &user).await?; info!(user_id = user.id, nickname = %user.nickname, "account registered and session created"); Ok(( StatusCode::CREATED, Json(RegisterResponse { token: Some(session.token), nickname: session.nickname, email: session.email, expires_at: Some(session.expires_at), confirmation_required: false, message: "Account created.".into(), }), )) } pub async fn login( State(state): State, Json(req): Json, ) -> Result, AuthError> { if state.ldap.is_some() { ldap::login(&state, &req.email, &req.password) .await .map(Json) } else { local::login(&state, &req.email, &req.password) .await .map(Json) } } pub async fn resend_confirmation( State(state): State, Json(req): Json, ) -> Result, AuthError> { if !state.account_confirmation_required { return Err(AuthError::bad_request( "Account confirmation is not enabled.", )); } let smtp = state .smtp .as_ref() .ok_or_else(|| AuthError::service_unavailable("SMTP is not configured."))?; let email = validate_email(&req.email)?; let user = find_user_by_email(&state, &email).await?.ok_or_else(|| { AuthError::bad_request("No unconfirmed account exists for this e-mail address.") })?; if user.confirmed_at.is_some() { return Err(AuthError::bad_request("This account is already confirmed.")); } let last_created: Option = sqlx::query_scalar(queries::get( state.db.kind(), queries::AUTH_LATEST_CONFIRMATION_CREATED_AT, )) .bind(user.id) .fetch_optional(state.db.pool()) .await .map_err(AuthError::database)?; if let Some(value) = last_created { if let Ok(created) = chrono::DateTime::parse_from_rfc3339(&value) { let available = created.with_timezone(&Utc) + Duration::minutes(10); if available > Utc::now() { let seconds = (available - Utc::now()).num_seconds().max(1); return Err(AuthError::bad_request(&format!( "A new confirmation e-mail can be sent in {} minute(s).", (seconds + 59) / 60 ))); } } } sqlx::query(queries::get( state.db.kind(), queries::AUTH_DELETE_CONFIRMATION_TOKENS_BY_USER, )) .bind(user.id) .execute(state.db.pool()) .await .map_err(AuthError::database)?; let token = random_confirmation_token(); let token_hash = hash_token(&token); let expires_at = (Utc::now() + Duration::hours(24)).to_rfc3339(); sqlx::query(queries::get( state.db.kind(), queries::AUTH_INSERT_CONFIRMATION_TOKEN, )) .bind(token_hash) .bind(user.id) .bind(expires_at) .execute(state.db.pool()) .await .map_err(AuthError::database)?; send_registration_email(smtp, &user, Some(&token)).await?; Ok(Json( serde_json::json!({"ok":true,"message":"A new confirmation e-mail has been sent."}), )) } pub async fn confirm_account( State(state): State, Json(req): Json, ) -> Result, AuthError> { let now_time = Utc::now(); let now = now_time.to_rfc3339(); let token_hash = hash_token(req.token.trim()); let row: Option<(i64, String, Option)> = sqlx::query_as(queries::get( state.db.kind(), queries::AUTH_FIND_CONFIRMATION_TOKEN, )) .bind(&token_hash) .fetch_optional(state.db.pool()) .await .map_err(AuthError::database)?; let (user_id, expires_at, used_at) = row.ok_or_else(|| { AuthError::bad_request("The confirmation link is invalid or has expired.") })?; let expires_at = chrono::DateTime::parse_from_rfc3339(&expires_at) .map_err(|_| AuthError::bad_request("The confirmation link is invalid or has expired."))? .with_timezone(&Utc); if used_at.is_some() || expires_at <= now_time { return Err(AuthError::bad_request( "The confirmation link is invalid or has expired.", )); } let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?; let consumed = sqlx::query(queries::get( state.db.kind(), queries::AUTH_CONSUME_CONFIRMATION_TOKEN, )) .bind(&now) .bind(&token_hash) .bind(&now) .execute(&mut *tx) .await .map_err(AuthError::database)?; if consumed.rows_affected() != 1 { return Err(AuthError::bad_request( "The confirmation link is invalid or has expired.", )); } let confirmed = sqlx::query(queries::get(state.db.kind(), queries::AUTH_CONFIRM_USER)) .bind(&now) .bind(&now) .bind(user_id) .execute(&mut *tx) .await .map_err(AuthError::database)?; if confirmed.rows_affected() != 1 { return Err(AuthError::internal("The account could not be confirmed.")); } sqlx::query(queries::get( state.db.kind(), queries::AUTH_DELETE_CONFIRMATION_TOKENS_BY_USER, )) .bind(user_id) .execute(&mut *tx) .await .map_err(AuthError::database)?; tx.commit().await.map_err(AuthError::database)?; info!(user_id, "account confirmed"); Ok(Json( serde_json::json!({"ok": true, "message": "Account confirmed. You can now log in."}), )) } async fn directory_profile_metadata( state: &SharedState, user: &User, ) -> Result<(bool, Option, Option, Option), AuthError> { let row: Option<(String, Option)> = sqlx::query_as(queries::get( state.db.kind(), queries::AUTH_DIRECTORY_PROFILE_BY_USER, )) .bind(user.id) .fetch_optional(state.db.pool()) .await .map_err(AuthError::database)?; let Some((provider, display_name)) = row else { return Ok((false, None, None, None)); }; if provider == "local" { return Ok((false, None, None, None)); } let display_name = display_name.filter(|value| !value.trim().is_empty()); let organization = state .ldap .as_ref() .map(|config| config.organization.trim().to_owned()) .filter(|value| !value.is_empty()); let suggested = suggested_directory_nickname(display_name.as_deref(), &user.email); Ok((true, display_name, organization, suggested)) } fn suggested_directory_nickname(display_name: Option<&str>, email: &str) -> Option { if let Some(display_name) = display_name { let words: Vec<&str> = display_name .split_whitespace() .filter(|word| !word.is_empty()) .collect(); if words.len() >= 2 { let first = words.first().copied().unwrap_or_default(); let last = words.last().copied().unwrap_or_default(); let candidate = format!("{}.{}", first, last).to_lowercase(); if let Ok(value) = validate_nickname(&candidate) { return Some(value); } } } email .split('@') .next() .filter(|value| !value.trim().is_empty()) .and_then(|value| validate_nickname(&value.to_lowercase()).ok()) } pub async fn me( State(state): State, headers: HeaderMap, ) -> Result, AuthError> { let token = bearer(&headers).ok_or_else(|| AuthError::unauthorized("Not logged in."))?; let user = user_from_token(&state, token) .await? .ok_or_else(|| AuthError::unauthorized("Your session has expired."))?; debug!(user_id = user.id, "session validation successful"); let expires_at: String = sqlx::query_scalar(queries::get( state.db.kind(), queries::AUTH_SESSION_EXPIRES_AT, )) .bind(token) .fetch_one(state.db.pool()) .await .map_err(AuthError::database)?; let (directory_managed, directory_display_name, directory_organization, suggested_nickname) = directory_profile_metadata(&state, &user).await?; let editor_color: Option = sqlx::query_scalar(queries::get( state.db.kind(), queries::AUTH_EDITOR_COLOR_BY_USER, )) .bind(user.id) .fetch_one(state.db.pool()) .await .map_err(AuthError::database)?; Ok(Json(SessionResponse { token: token.into(), nickname: user.nickname, email: user.email, expires_at, directory_managed, directory_display_name, directory_organization, suggested_nickname, editor_color, })) } pub async fn update_profile( State(state): State, headers: HeaderMap, Json(req): Json, ) -> Result, AuthError> { let user = require_user(&state, &headers).await?; let (directory_managed, _, _, _) = directory_profile_metadata(&state, &user).await?; if directory_managed { if req .new_email .as_deref() .is_some_and(|value| !value.trim().is_empty()) || req .new_password .as_deref() .is_some_and(|value| !value.is_empty()) { return Err(AuthError::forbidden( "E-mail and password are managed by LDAP/AD.", )); } } else if !verify_password(&user.password_hash, &req.password) { return Err(AuthError::unauthorized( "The current password is incorrect.", )); } let mut nickname = user.nickname.clone(); if let Some(value) = req.nickname.as_deref() { nickname = validate_nickname(value)?; if normalize(&nickname) != normalize(&user.nickname) && find_user_by_nickname(&state, &nickname).await?.is_some() { return Err(AuthError::conflict("This nickname is already registered.")); } sqlx::query(queries::get(state.db.kind(), queries::AUTH_UPDATE_NICKNAME)) .bind(&nickname) .bind(normalize(&nickname)) .bind(Utc::now().to_rfc3339()) .bind(user.id) .execute(state.db.pool()) .await .map_err(AuthError::database)?; } let mut email_pending = false; if !directory_managed { if let Some(value) = req .new_password .as_deref() .filter(|value| !value.is_empty()) { validate_password(value)?; let hash = hash_password(value)?; sqlx::query(queries::get(state.db.kind(), queries::AUTH_UPDATE_PASSWORD)) .bind(hash) .bind(Utc::now().to_rfc3339()) .bind(user.id) .execute(state.db.pool()) .await .map_err(AuthError::database)?; } if let Some(value) = req .new_email .as_deref() .filter(|value| !value.trim().is_empty()) { let email = validate_email(value)?; if normalize(&email) != normalize(&user.email) { if find_user_by_email(&state, &email).await?.is_some() { return Err(AuthError::conflict( "This e-mail address is already registered.", )); } let smtp = state .smtp .as_ref() .ok_or_else(|| AuthError::service_unavailable("SMTP is not configured."))?; create_account_action(&state, &user, "email", Some(&email), smtp).await?; email_pending = true; } } } if let Some(value) = req.editor_color.as_deref() { let color = validate_editor_color(value)?; sqlx::query(queries::get( state.db.kind(), queries::AUTH_UPDATE_EDITOR_COLOR, )) .bind(color) .bind(Utc::now().to_rfc3339()) .bind(user.id) .execute(state.db.pool()) .await .map_err(AuthError::database)?; } Ok(Json(serde_json::json!({ "ok": true, "nickname": nickname, "editor_color": req.editor_color.as_deref(), "email_pending": email_pending, "message": if email_pending { "Profile updated. Confirm the new e-mail address using the link sent to it." } else { "Profile updated." } }))) } pub async fn request_account_deletion( State(state): State, headers: HeaderMap, Json(req): Json, ) -> Result, AuthError> { if state.ldap.is_some() { return Err(AuthError::forbidden( "LDAP accounts cannot be deleted here.", )); } let user = require_user(&state, &headers).await?; if !verify_password(&user.password_hash, &req.password) { return Err(AuthError::unauthorized( "The current password is incorrect.", )); } let smtp = state .smtp .as_ref() .ok_or_else(|| AuthError::service_unavailable("SMTP is not configured."))?; create_account_action(&state, &user, "delete", None, smtp).await?; Ok(Json( serde_json::json!({"ok":true,"message":"A confirmation link has been sent to your e-mail address."}), )) } pub async fn confirm_account_action( State(state): State, Json(req): Json, ) -> Result, AuthError> { let now = Utc::now(); let hash = hash_token(req.token.trim()); let row: Option<(i64, String, Option, String, Option)> = sqlx::query_as( queries::get(state.db.kind(), queries::AUTH_ACCOUNT_ACTION_BY_TOKEN), ) .bind(&hash) .fetch_optional(state.db.pool()) .await .map_err(AuthError::database)?; let (user_id, action, payload, expires_at, used_at) = row.ok_or_else(|| { AuthError::bad_request("The confirmation link is invalid or has expired.") })?; let expires = chrono::DateTime::parse_from_rfc3339(&expires_at) .map_err(|_| AuthError::bad_request("The confirmation link is invalid or has expired."))? .with_timezone(&Utc); if used_at.is_some() || expires <= now { return Err(AuthError::bad_request( "The confirmation link is invalid or has expired.", )); } let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?; sqlx::query(queries::get( state.db.kind(), queries::AUTH_CONSUME_ACCOUNT_ACTION, )) .bind(now.to_rfc3339()) .bind(&hash) .execute(&mut *tx) .await .map_err(AuthError::database)?; let message = if action == "email" { let email = payload.ok_or_else(|| AuthError::internal("Missing e-mail change payload."))?; if find_user_by_email(&state, &email).await?.is_some() { return Err(AuthError::conflict( "This e-mail address is already registered.", )); } sqlx::query(queries::get(state.db.kind(), queries::AUTH_UPDATE_EMAIL)) .bind(&email) .bind(normalize(&email)) .bind(now.to_rfc3339()) .bind(user_id) .execute(&mut *tx) .await .map_err(AuthError::database)?; "E-mail address changed." } else if action == "delete" { sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_USER)) .bind(user_id) .execute(&mut *tx) .await .map_err(AuthError::database)?; "Account deleted." } else { return Err(AuthError::bad_request("Unknown account action.")); }; tx.commit().await.map_err(AuthError::database)?; Ok(Json(serde_json::json!({"ok":true,"message":message}))) } async fn create_account_action( state: &SharedState, user: &User, action: &str, payload: Option<&str>, smtp: &SmtpConfig, ) -> Result<(), AuthError> { sqlx::query(queries::get( state.db.kind(), queries::AUTH_DELETE_ACCOUNT_ACTIONS, )) .bind(user.id) .bind(action) .execute(state.db.pool()) .await .map_err(AuthError::database)?; let token = random_token(); let expires = (Utc::now() + Duration::hours(1)).to_rfc3339(); sqlx::query(queries::get( state.db.kind(), queries::AUTH_INSERT_ACCOUNT_ACTION, )) .bind(hash_token(&token)) .bind(user.id) .bind(action) .bind(payload) .bind(expires) .bind(Utc::now().to_rfc3339()) .execute(state.db.pool()) .await .map_err(AuthError::database)?; send_account_action(smtp, user, action, payload, &token).await } async fn send_account_action( smtp: &SmtpConfig, user: &User, action: &str, payload: Option<&str>, token: &str, ) -> Result<(), AuthError> { let site = smtp.public_url.trim_end_matches('/'); let url = format!("{site}/?account_action_token={token}"); let sender = smtp .from .parse::() .map_err(|_| AuthError::internal("Invalid SMTP_FROM."))?; let target = if action == "email" { payload.unwrap_or(&user.email) } else { &user.email }; let recipient = target .parse::() .map_err(|_| AuthError::internal("Invalid recipient address."))?; let (subject, title, copy) = if action == "email" { ( "Confirm your new RustPad e-mail", "Confirm e-mail change", "Confirm the new e-mail address within one hour.", ) } else { ( "Confirm RustPad account deletion", "Confirm account deletion", "Confirm permanent account deletion within one hour.", ) }; let text = format!( "Hello {},\n\n{}\n{}\n\nIf you did not request this, ignore this message.", user.nickname, copy, url ); let html = format!( r#"

{}

Hello {},

{}

Confirm action

{}

If you did not request this, ignore this message.

"#, title, user.nickname, copy, url, url, url ); let message = Message::builder() .from(sender) .to(recipient) .subject(subject) .multipart( MultiPart::alternative() .singlepart( SinglePart::builder() .header(ContentType::TEXT_PLAIN) .body(text), ) .singlepart( SinglePart::builder() .header(ContentType::TEXT_HTML) .body(html), ), ) .map_err(|_| AuthError::internal("Failed to build account confirmation e-mail."))?; send_message(smtp, message, "account action e-mail").await } pub async fn resources( State(state): State, headers: HeaderMap, ) -> Result, AuthError> { let user = require_user(&state, &headers).await?; let workspaces = sqlx::query_as::<_, ResourceItem>(queries::get( state.db.kind(), queries::USER_LIST_WORKSPACES, )) .bind(user.id) .bind(user.id) .fetch_all(state.db.pool()) .await .map_err(AuthError::database)?; let pads = sqlx::query_as::<_, ResourceItem>(queries::get(state.db.kind(), queries::USER_LIST_PADS)) .bind(user.id) .bind(user.id) .fetch_all(state.db.pool()) .await .map_err(AuthError::database)?; Ok(Json(ResourceList { workspaces, pads })) } pub async fn update_resource( State(state): State, headers: HeaderMap, Json(req): Json, ) -> Result, AuthError> { let user = require_user(&state, &headers).await?; let hash = match req .password .as_deref() .map(str::trim) .filter(|v| !v.is_empty()) { Some(v) => { validate_password(v)?; Some(hash_password(v)?) } None => None, }; ensure_owner(&state, user.id, &req.kind, &req.slug).await?; let query = match req.kind.as_str() { "workspace" => queries::USER_SET_WORKSPACE_PASSWORD, "pad" => queries::USER_SET_PAD_PASSWORD, _ => return Err(AuthError::bad_request("Unknown resource type.")), }; sqlx::query(queries::get(state.db.kind(), query)) .bind(hash) .bind(req.slug.trim()) .execute(state.db.pool()) .await .map_err(AuthError::database)?; sqlx::query(queries::get( state.db.kind(), queries::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE, )) .bind(req.kind.as_str()) .bind(req.slug.trim()) .execute(state.db.pool()) .await .map_err(AuthError::database)?; Ok(Json(serde_json::json!({"ok":true}))) } pub async fn delete_resource( State(state): State, headers: HeaderMap, Json(req): Json, ) -> Result, AuthError> { let user = require_user(&state, &headers).await?; ensure_owner(&state, user.id, &req.kind, &req.slug).await?; let query = match req.kind.as_str() { "workspace" => { if let Some(workspace) = crate::db::find_workspace(&state.db, req.slug.trim()) .await .map_err(AuthError::database)? { let notes = crate::db::list_notes(&state.db, workspace.id) .await .map_err(AuthError::database)?; for note in notes { let files = crate::db::list_note_files(&state.db, note.id) .await .map_err(AuthError::database)?; for file in files { crate::storage::delete_url_file( &state.storage, "notes", note.id, &file.url, ) .await .map_err(|_| AuthError::internal("Failed to delete workspace files."))?; } } } queries::USER_DELETE_WORKSPACE } "pad" => { if let Some(pad) = crate::db::find_pad(&state.db, req.slug.trim()) .await .map_err(AuthError::database)? { let files = crate::db::list_pad_files(&state.db, pad.id) .await .map_err(AuthError::database)?; for file in files { crate::storage::delete_url_file(&state.storage, "pads", pad.id, &file.url) .await .map_err(|_| AuthError::internal("Failed to delete pad files."))?; } } queries::USER_DELETE_PAD } _ => return Err(AuthError::bad_request("Unknown resource type.")), }; sqlx::query(queries::get( state.db.kind(), queries::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE, )) .bind(req.kind.as_str()) .bind(req.slug.trim()) .execute(state.db.pool()) .await .map_err(AuthError::database)?; sqlx::query(queries::get(state.db.kind(), query)) .bind(req.slug.trim()) .execute(state.db.pool()) .await .map_err(AuthError::database)?; Ok(Json(serde_json::json!({"ok":true}))) } pub async fn optional_user( state: &SharedState, headers: &HeaderMap, ) -> Result, AuthError> { match bearer(headers) { Some(token) => user_from_token(state, token).await, None => Ok(None), } } async fn require_user(state: &SharedState, headers: &HeaderMap) -> Result { optional_user(state, headers) .await? .ok_or_else(|| AuthError::unauthorized("Log in first.")) } async fn ensure_owner( state: &SharedState, user_id: i64, kind: &str, slug: &str, ) -> Result<(), AuthError> { let query = match kind { "workspace" => queries::USER_OWNS_WORKSPACE, "pad" => queries::USER_OWNS_PAD, _ => return Err(AuthError::bad_request("Unknown resource type.")), }; let count: i64 = sqlx::query_scalar(queries::get(state.db.kind(), query)) .bind(user_id) .bind(slug.trim()) .fetch_one(state.db.pool()) .await .map_err(AuthError::database)?; if count == 0 { return Err(AuthError::forbidden( "This item does not belong to your account.", )); } Ok(()) } pub async fn set_resource_privacy( State(state): State, headers: HeaderMap, Json(req): Json, ) -> Result, AuthError> { let user = require_user(&state, &headers).await?; ensure_owner(&state, user.id, &req.kind, &req.slug).await?; let query = match req.kind.as_str() { "workspace" => queries::USER_SET_WORKSPACE_PRIVACY, "pad" => queries::USER_SET_PAD_PRIVACY, _ => return Err(AuthError::bad_request("Unknown resource type.")), }; sqlx::query(queries::get(state.db.kind(), query)) .bind(req.private) .bind(req.slug.trim()) .execute(state.db.pool()) .await .map_err(AuthError::database)?; Ok(Json(serde_json::json!({"ok":true}))) } pub async fn share_resource_users( State(state): State, headers: HeaderMap, Json(req): Json, ) -> Result, AuthError> { let owner = require_user(&state, &headers).await?; ensure_owner(&state, owner.id, &req.kind, &req.slug).await?; let permission = validate_permission(&req.permission)?; if state.share_confirmation_required && state.smtp.is_none() { return Err(AuthError::service_unavailable( "Share confirmation requires SMTP configuration.", )); } let emails: Vec = req .emails .split(',') .map(|v| normalize(v)) .filter(|v| !v.is_empty()) .collect(); if emails.is_empty() || emails.len() > 100 { return Err(AuthError::bad_request( "Enter between 1 and 100 registered e-mail addresses.", )); } let mut missing = Vec::new(); for email in emails { let user = find_user_by_email(&state, &email).await?; let Some(user) = user else { missing.push(email); continue; }; if user.confirmed_at.is_none() { missing.push(format!("{} (account not activated)", email)); continue; } if user.id == owner.id { continue; } sqlx::query(queries::get( state.db.kind(), queries::RESOURCE_PERMISSION_DELETE_USER, )) .bind(&req.kind) .bind(req.slug.trim()) .bind(user.id) .execute(state.db.pool()) .await .map_err(AuthError::database)?; sqlx::query(queries::get( state.db.kind(), queries::SHARE_INVITATION_DELETE_USER, )) .bind(&req.kind) .bind(req.slug.trim()) .bind(user.id) .execute(state.db.pool()) .await .map_err(AuthError::database)?; if state.share_confirmation_required { let token = random_token(); let token_hash = hash_token(&token); let expires_at = (Utc::now() + Duration::days(7)).to_rfc3339(); sqlx::query(queries::get( state.db.kind(), queries::SHARE_INVITATION_INSERT, )) .bind(&token_hash) .bind(&req.kind) .bind(req.slug.trim()) .bind(user.id) .bind(permission) .bind(owner.id) .bind(&expires_at) .execute(state.db.pool()) .await .map_err(AuthError::database)?; if let Err(error) = send_share_invitation( state.smtp.as_ref().unwrap(), &owner, &user, &req.kind, req.slug.trim(), permission, &token, ) .await { let _ = sqlx::query(queries::get( state.db.kind(), queries::SHARE_INVITATION_DELETE_TOKEN, )) .bind(&token_hash) .execute(state.db.pool()) .await; return Err(error); } } else { sqlx::query(queries::get( state.db.kind(), queries::RESOURCE_PERMISSION_INSERT, )) .bind(&req.kind) .bind(req.slug.trim()) .bind(user.id) .bind(permission) .execute(state.db.pool()) .await .map_err(AuthError::database)?; } } if !missing.is_empty() { return Err(AuthError::bad_request(&format!( "No registered account for: {}", missing.join(", ") ))); } Ok(Json( serde_json::json!({"ok":true,"confirmation_required":state.share_confirmation_required}), )) } pub async fn accept_share_invitation( State(state): State, AxumPath(token): AxumPath, ) -> Result { let token_hash = hash_token(token.trim()); let row: Option<(String, String, i64, String, String, Option)> = sqlx::query_as( queries::get(state.db.kind(), queries::SHARE_INVITATION_FIND_TOKEN), ) .bind(&token_hash) .fetch_optional(state.db.pool()) .await .map_err(AuthError::database)?; let (kind, slug, user_id, permission, expires_at, accepted_at) = row.ok_or_else(|| { AuthError::bad_request("The sharing invitation is invalid or has expired.") })?; let expires = chrono::DateTime::parse_from_rfc3339(&expires_at) .map_err(|_| AuthError::bad_request("The sharing invitation is invalid or has expired."))? .with_timezone(&Utc); if accepted_at.is_none() { if expires <= Utc::now() { return Err(AuthError::bad_request( "The sharing invitation is invalid or has expired.", )); } let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?; sqlx::query(queries::get( state.db.kind(), queries::RESOURCE_PERMISSION_DELETE_USER, )) .bind(&kind) .bind(&slug) .bind(user_id) .execute(&mut *tx) .await .map_err(AuthError::database)?; sqlx::query(queries::get( state.db.kind(), queries::RESOURCE_PERMISSION_INSERT, )) .bind(&kind) .bind(&slug) .bind(user_id) .bind(&permission) .execute(&mut *tx) .await .map_err(AuthError::database)?; sqlx::query(queries::get( state.db.kind(), queries::SHARE_INVITATION_ACCEPT, )) .bind(Utc::now().to_rfc3339()) .bind(&token_hash) .execute(&mut *tx) .await .map_err(AuthError::database)?; tx.commit().await.map_err(AuthError::database)?; } let target = if kind == "workspace" { format!("/w/{slug}") } else { format!("/p/{slug}") }; Ok(Redirect::to(&target)) } pub async fn remove_resource_user( State(state): State, headers: HeaderMap, Json(req): Json, ) -> Result, AuthError> { let owner = require_user(&state, &headers).await?; ensure_owner(&state, owner.id, &req.kind, &req.slug).await?; let email = normalize(&req.email); if let Some(user) = find_user_by_email(&state, &email).await? { sqlx::query(queries::get( state.db.kind(), queries::RESOURCE_PERMISSION_DELETE_USER, )) .bind(&req.kind) .bind(req.slug.trim()) .bind(user.id) .execute(state.db.pool()) .await .map_err(AuthError::database)?; sqlx::query(queries::get( state.db.kind(), queries::SHARE_INVITATION_DELETE_USER, )) .bind(&req.kind) .bind(req.slug.trim()) .bind(user.id) .execute(state.db.pool()) .await .map_err(AuthError::database)?; } Ok(Json(serde_json::json!({"ok":true}))) } pub async fn resource_sharing( State(state): State, headers: HeaderMap, axum::extract::Query(params): axum::extract::Query>, ) -> Result, AuthError> { let owner = require_user(&state, &headers).await?; let kind = params .get("kind") .ok_or_else(|| AuthError::bad_request("Missing kind."))?; let slug = params .get("slug") .ok_or_else(|| AuthError::bad_request("Missing slug."))?; ensure_owner(&state, owner.id, kind, slug).await?; let users: Vec<(String, String, String)> = sqlx::query_as(queries::get( state.db.kind(), queries::RESOURCE_SHARING_USERS, )) .bind(kind) .bind(slug) .fetch_all(state.db.pool()) .await .map_err(AuthError::database)?; let links: Vec<(String, Option, String, Option, String)> = sqlx::query_as( queries::get(state.db.kind(), queries::RESOURCE_SHARING_LINKS), ) .bind(kind) .bind(slug) .fetch_all(state.db.pool()) .await .map_err(AuthError::database)?; let pending: Vec<(String, String, String, String)> = sqlx::query_as(queries::get( state.db.kind(), queries::RESOURCE_SHARING_PENDING, )) .bind(kind) .bind(slug) .fetch_all(state.db.pool()) .await .map_err(AuthError::database)?; Ok(Json( serde_json::json!({"users":users.into_iter().map(|(email,nickname,permission)|serde_json::json!({"email":email,"nickname":nickname,"permission":permission})).collect::>(), "pending":pending.into_iter().map(|(email,nickname,permission,expires_at)|serde_json::json!({"email":email,"nickname":nickname,"permission":permission,"expires_at":expires_at})).collect::>(), "links":links.into_iter().map(|(token_hash,token,permission,expires_at,created_at)|serde_json::json!({"token_hash":token_hash,"token":token,"permission":permission,"expires_at":expires_at,"created_at":created_at})).collect::>() }), )) } pub async fn create_share_link( State(state): State, headers: HeaderMap, Json(req): Json, ) -> Result, AuthError> { let owner = require_user(&state, &headers).await?; ensure_owner(&state, owner.id, &req.kind, &req.slug).await?; let permission = validate_permission(&req.permission)?; validate_share_expiration(req.expires_at.as_deref())?; let token = random_token(); let token_hash = hash_token(&token); sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_INSERT)) .bind(token_hash) .bind(&token) .bind(&req.kind) .bind(req.slug.trim()) .bind(permission) .bind(&req.expires_at) .bind(owner.id) .execute(state.db.pool()) .await .map_err(AuthError::database)?; let base = if req.kind == "workspace" { format!("/w/{}", req.slug.trim()) } else { format!("/p/{}", req.slug.trim()) }; Ok(Json( serde_json::json!({"token":token,"url":format!("{base}?share={token}"),"permission":permission,"expires_at":req.expires_at}), )) } pub async fn update_share_link( State(state): State, headers: HeaderMap, Json(req): Json, ) -> Result, AuthError> { let owner = require_user(&state, &headers).await?; ensure_owner(&state, owner.id, &req.kind, &req.slug).await?; let permission = validate_permission(&req.permission)?; validate_share_expiration(req.expires_at.as_deref())?; let result = sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_UPDATE)) .bind(permission) .bind(&req.expires_at) .bind(req.token.trim()) .bind(&req.kind) .bind(req.slug.trim()) .execute(state.db.pool()) .await .map_err(AuthError::database)?; if result.rows_affected() == 0 { return Err(AuthError::bad_request( "Share link was not found or is already revoked.", )); } Ok(Json( serde_json::json!({"ok":true,"permission":permission,"expires_at":req.expires_at}), )) } pub async fn revoke_share_link( State(state): State, headers: HeaderMap, Json(req): Json, ) -> Result, AuthError> { let owner = require_user(&state, &headers).await?; ensure_owner(&state, owner.id, &req.kind, &req.slug).await?; sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_REVOKE)) .bind(Utc::now().to_rfc3339()) .bind(req.token.trim()) .bind(&req.kind) .bind(req.slug.trim()) .execute(state.db.pool()) .await .map_err(AuthError::database)?; Ok(Json(serde_json::json!({"ok":true}))) } fn validate_permission(value: &str) -> Result<&str, AuthError> { match value { "ro" | "rw" => Ok(value), _ => Err(AuthError::bad_request("Permission must be ro or rw.")), } } fn validate_share_expiration(value: Option<&str>) -> Result<(), AuthError> { let Some(value) = value else { return Ok(()); }; let expires = chrono::DateTime::parse_from_rfc3339(value) .map_err(|_| AuthError::bad_request("Invalid expiration date."))? .with_timezone(&Utc); if expires <= Utc::now() { return Err(AuthError::bad_request("Expiration must be in the future.")); } Ok(()) } pub async fn is_resource_owner( state: &SharedState, kind: &str, slug: &str, token: Option<&str>, ) -> Result { let Some(token) = token.filter(|v| !v.is_empty()) else { return Ok(false); }; let Some(user) = user_from_token(state, token).await? else { return Ok(false); }; Ok(ensure_owner(state, user.id, kind, slug).await.is_ok()) } pub async fn resource_permission( state: &SharedState, kind: &str, slug: &str, token: Option<&str>, ) -> Result, AuthError> { let Some(token) = token.filter(|v| !v.is_empty()) else { return Ok(None); }; if let Some(user) = user_from_token(state, token).await? { let owns = ensure_owner(state, user.id, kind, slug).await.is_ok(); if owns { return Ok(Some("rw".into())); } let permission: Option = sqlx::query_scalar(queries::get( state.db.kind(), queries::RESOURCE_PERMISSION_BY_USER, )) .bind(kind) .bind(slug) .bind(user.id) .fetch_optional(state.db.pool()) .await .map_err(AuthError::database)?; return Ok(permission); } let now = Utc::now().to_rfc3339(); let permission: Option = sqlx::query_scalar(queries::get( state.db.kind(), queries::SHARE_LINK_PERMISSION, )) .bind(hash_token(token)) .bind(kind) .bind(slug) .bind(now) .fetch_optional(state.db.pool()) .await .map_err(AuthError::database)?; Ok(permission) } pub async fn logout( State(state): State, headers: HeaderMap, ) -> Result, AuthError> { if let Some(token) = bearer(&headers) { let result = sqlx::query(queries::get( state.db.kind(), queries::AUTH_DELETE_SESSION_BY_TOKEN, )) .bind(token) .execute(state.db.pool()) .await .map_err(AuthError::database)?; info!(rows_affected = result.rows_affected(), "logout processed"); } else { debug!("logout requested without an active session"); } Ok(Json(serde_json::json!({"ok": true}))) } pub async fn request_reset( State(state): State, Json(req): Json, ) -> Result, AuthError> { let email = validate_email(&req.email)?; info!(email_domain = %email_domain(&email), "password reset requested"); let smtp = state.smtp.as_ref().ok_or_else(|| { AuthError::service_unavailable("Password reset is not configured on this server.") })?; if let Some(user) = find_user_by_email(&state, &email).await? { let token = random_token(); let expires = (Utc::now() + Duration::minutes(30)).to_rfc3339(); let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?; sqlx::query(queries::get( state.db.kind(), queries::AUTH_DELETE_RESET_TOKENS_BY_USER, )) .bind(user.id) .execute(&mut *tx) .await .map_err(AuthError::database)?; sqlx::query(queries::get( state.db.kind(), queries::AUTH_INSERT_RESET_TOKEN, )) .bind(hash_token(&token)) .bind(user.id) .bind(expires) .execute(&mut *tx) .await .map_err(AuthError::database)?; tx.commit().await.map_err(AuthError::database)?; send_reset(smtp, &user, &token).await?; info!(user_id = user.id, "password reset e-mail sent"); } else { debug!(email_domain = %email_domain(&email), "password reset requested for unknown account"); } Ok(Json( serde_json::json!({"ok": true, "message": "If the account exists, a reset link has been sent."}), )) } pub async fn confirm_reset( State(state): State, Json(req): Json, ) -> Result, AuthError> { validate_password(&req.password)?; info!("password reset confirmation requested"); let now_time = Utc::now(); let now = now_time.to_rfc3339(); let token_hash = hash_token(req.token.trim()); let token_row: Option<(i64, String, Option)> = sqlx::query_as(queries::get( state.db.kind(), queries::AUTH_FIND_RESET_TOKEN, )) .bind(&token_hash) .fetch_optional(state.db.pool()) .await .map_err(AuthError::database)?; let (user_id, expires_at, used_at) = token_row .ok_or_else(|| AuthError::bad_request("The reset link is invalid or has expired."))?; let expires_at = chrono::DateTime::parse_from_rfc3339(&expires_at) .map_err(|_| AuthError::bad_request("The reset link is invalid or has expired."))? .with_timezone(&Utc); if used_at.is_some() || expires_at <= now_time { warn!( user_id, used = used_at.is_some(), expired = expires_at <= now_time, "password reset token rejected" ); return Err(AuthError::bad_request( "The reset link is invalid or has expired.", )); } let password_hash = hash_password(&req.password)?; let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?; let consumed = sqlx::query(queries::get( state.db.kind(), queries::AUTH_CONSUME_RESET_TOKEN, )) .bind(&now) .bind(&token_hash) .bind(&now) .execute(&mut *tx) .await .map_err(AuthError::database)?; if consumed.rows_affected() != 1 { return Err(AuthError::bad_request( "The reset link is invalid or has expired.", )); } let updated = sqlx::query(queries::get(state.db.kind(), queries::AUTH_UPDATE_PASSWORD)) .bind(password_hash) .bind(&now) .bind(user_id) .execute(&mut *tx) .await .map_err(AuthError::database)?; if updated.rows_affected() != 1 { return Err(AuthError::internal("The account could not be updated.")); } sqlx::query(queries::get( state.db.kind(), queries::AUTH_DELETE_RESET_TOKENS_BY_USER, )) .bind(user_id) .execute(&mut *tx) .await .map_err(AuthError::database)?; sqlx::query(queries::get( state.db.kind(), queries::AUTH_DELETE_SESSIONS_BY_USER, )) .bind(user_id) .execute(&mut *tx) .await .map_err(AuthError::database)?; tx.commit().await.map_err(AuthError::database)?; info!( user_id, "password reset completed and existing sessions revoked" ); Ok(Json(serde_json::json!({"ok": true}))) } pub async fn user_from_token(state: &SharedState, token: &str) -> Result, AuthError> { let now = Utc::now(); let now_rfc3339 = now.to_rfc3339(); let user = sqlx::query_as::<_, User>(queries::get(state.db.kind(), queries::AUTH_USER_BY_SESSION)) .bind(token) .bind(&now_rfc3339) .fetch_optional(state.db.pool()) .await .map_err(AuthError::database)?; if let Some(user) = user { let expires_at = (now + Duration::days(state.user_session_ttl_days)).to_rfc3339(); let refreshed = sqlx::query(queries::get(state.db.kind(), queries::AUTH_REFRESH_SESSION)) .bind(&expires_at) .bind(token) .bind(&now_rfc3339) .execute(state.db.pool()) .await .map_err(AuthError::database)?; if refreshed.rows_affected() == 1 { debug!(user_id = user.id, expires_at = %expires_at, "authentication session extended"); Ok(Some(user)) } else { Ok(None) } } else { sqlx::query(queries::get( state.db.kind(), queries::AUTH_DELETE_SESSION_BY_TOKEN, )) .bind(token) .execute(state.db.pool()) .await .map_err(AuthError::database)?; Ok(None) } } pub async fn authorize_nickname( state: &SharedState, nickname: Option, token: Option, ) -> Result, String> { let Some(nickname) = nickname else { return Ok(None); }; let nickname = validate_nickname(&nickname).map_err(|e| e.message)?; let registered = find_user_by_nickname(state, &nickname) .await .map_err(|_| "Database error".to_string())?; match registered { None => Ok(Some(nickname)), Some(owner) => { let Some(token) = token else { return Err("This nickname is registered. Log in to use it.".into()); }; let current = user_from_token(state, &token) .await .map_err(|_| "Database error".to_string())?; match current { Some(user) if user.id == owner.id => Ok(Some(owner.nickname)), _ => Err("This nickname belongs to another account or the session expired.".into()), } } } } async fn create_session(state: &SharedState, user: &User) -> Result { let token = random_token(); let expires_at = (Utc::now() + Duration::days(state.user_session_ttl_days)).to_rfc3339(); sqlx::query(queries::get(state.db.kind(), queries::AUTH_INSERT_SESSION)) .bind(&token) .bind(user.id) .bind(&expires_at) .execute(state.db.pool()) .await .map_err(AuthError::database)?; debug!(user_id = user.id, expires_at = %expires_at, "authentication session created"); let (directory_managed, directory_display_name, directory_organization, suggested_nickname) = directory_profile_metadata(state, user).await?; let editor_color: Option = sqlx::query_scalar(queries::get( state.db.kind(), queries::AUTH_EDITOR_COLOR_BY_USER, )) .bind(user.id) .fetch_one(state.db.pool()) .await .map_err(AuthError::database)?; Ok(SessionResponse { token, nickname: user.nickname.clone(), email: user.email.clone(), expires_at, directory_managed, directory_display_name, directory_organization, suggested_nickname, editor_color, }) } async fn find_user_by_nickname( state: &SharedState, nickname: &str, ) -> Result, AuthError> { sqlx::query_as::<_, User>(queries::get( state.db.kind(), queries::AUTH_USER_BY_NICKNAME, )) .bind(normalize(nickname)) .fetch_optional(state.db.pool()) .await .map_err(AuthError::database) } async fn find_user_by_external_id( state: &SharedState, provider: &str, external_id: &str, ) -> Result, AuthError> { sqlx::query_as::<_, User>(queries::get( state.db.kind(), queries::AUTH_USER_BY_EXTERNAL_ID, )) .bind(provider) .bind(external_id) .fetch_optional(state.db.pool()) .await .map_err(AuthError::database) } async fn find_user_by_email(state: &SharedState, email: &str) -> Result, AuthError> { sqlx::query_as::<_, User>(queries::get(state.db.kind(), queries::AUTH_USER_BY_EMAIL)) .bind(normalize(email)) .fetch_optional(state.db.pool()) .await .map_err(AuthError::database) } fn validate_editor_color(value: &str) -> Result { let value = value.trim(); if value.len() == 7 && value.starts_with('#') && value[1..].chars().all(|c| c.is_ascii_hexdigit()) { Ok(value.to_ascii_lowercase()) } else { Err(AuthError::bad_request("Invalid editor color.")) } } fn validate_nickname(v: &str) -> Result { let v = v.trim(); if v.is_empty() || v.chars().count() > MAX_NICKNAME { return Err(AuthError::bad_request( "Nickname must contain 1 to 40 characters.", )); } if v.chars().any(|c| c.is_control()) { return Err(AuthError::bad_request( "Nickname contains invalid characters.", )); } Ok(v.into()) } fn validate_email(value: &str) -> Result { let value = value.trim(); if value.len() > 320 || !value.contains('@') || value.starts_with('@') || value.ends_with('@') { return Err(AuthError::bad_request("Enter a valid e-mail address.")); } Ok(value.into()) } fn validate_password(value: &str) -> Result<(), AuthError> { if value.len() < MIN_PASSWORD || value.len() > MAX_PASSWORD { return Err(AuthError::bad_request( "Password must contain 8 to 128 characters.", )); } Ok(()) } fn normalize(value: &str) -> String { value.trim().to_lowercase() } fn hash_password(value: &str) -> Result { let salt = SaltString::generate(&mut OsRng); Argon2::default() .hash_password(value.as_bytes(), &salt) .map(|hash| hash.to_string()) .map_err(|_| AuthError::internal("Failed to secure the password.")) } fn verify_password(hash: &str, value: &str) -> bool { PasswordHash::new(hash) .ok() .and_then(|hash| { Argon2::default() .verify_password(value.as_bytes(), &hash) .ok() }) .is_some() } fn random_token() -> String { random_hex_token::<32>() } fn random_confirmation_token() -> String { random_hex_token::<32>() } fn random_hex_token() -> String { let mut bytes = [0_u8; N]; let mut rng = OsRng; rng.fill_bytes(&mut bytes); bytes.iter().map(|byte| format!("{byte:02x}")).collect() } fn hash_token(value: &str) -> String { format!("{:x}", Sha256::digest(value.as_bytes())) } fn bearer(headers: &HeaderMap) -> Option<&str> { headers .get("authorization")? .to_str() .ok()? .strip_prefix("Bearer ") } async fn send_registration_email( smtp: &SmtpConfig, user: &User, token: Option<&str>, ) -> Result<(), AuthError> { let site = smtp.public_url.trim_end_matches('/'); let sender = smtp .from .parse::() .map_err(|_| AuthError::internal("Invalid SMTP_FROM."))?; let recipient = user .email .parse::() .map_err(|_| AuthError::internal("Invalid recipient address."))?; let builder = Message::builder().from(sender).to(recipient); let message = match token { Some(token) => { let confirmation_url = format!("{site}/?confirm_token={token}"); let subject = "Confirm your RustPad account"; let text_body = format!( "Hello {},\n\nYour RustPad account has been created.\nNickname: {}\nSite: {}\n\nConfirm the account within 24 hours by opening this link:\n{}\n", user.nickname, user.nickname, site, confirmation_url ); let html_body = format!( r#"

Confirm your RustPad account

Hello {},

Your RustPad account has been created.

Nickname: {}
Site: {}

Confirm the account within 24 hours:

Confirm account

If the button does not work, open this address:

{}

"#, user.nickname, user.nickname, site, confirmation_url, confirmation_url, confirmation_url ); builder .subject(subject) .multipart( MultiPart::alternative() .singlepart( SinglePart::builder() .header(ContentType::TEXT_PLAIN) .body(text_body), ) .singlepart( SinglePart::builder() .header(ContentType::TEXT_HTML) .body(html_body), ), ) .map_err(|_| AuthError::internal("Failed to build registration e-mail."))? } None => { let subject = "Your RustPad account has been created"; let text_body = format!( "Hello {},\n\nYour RustPad account has been created.\nNickname: {}\nSite: {}\n", user.nickname, user.nickname, site ); let html_body = format!( r#"

Your RustPad account is ready

Hello {},

Your RustPad account has been created.

Nickname: {}

Open RustPad

"#, user.nickname, user.nickname, site ); builder .subject(subject) .multipart( MultiPart::alternative() .singlepart( SinglePart::builder() .header(ContentType::TEXT_PLAIN) .body(text_body), ) .singlepart( SinglePart::builder() .header(ContentType::TEXT_HTML) .body(html_body), ), ) .map_err(|_| AuthError::internal("Failed to build registration e-mail."))? } }; send_message(smtp, message, "registration e-mail").await } async fn send_share_invitation( smtp: &SmtpConfig, owner: &User, recipient_user: &User, kind: &str, slug: &str, permission: &str, token: &str, ) -> Result<(), AuthError> { let site = smtp.public_url.trim_end_matches('/'); let accept_url = format!("{site}/share-invitations/{token}/accept"); let resource_label = if kind == "workspace" { "workspace" } else { "note" }; let access_label = if permission == "rw" { "view and edit" } else { "view" }; let sender = smtp .from .parse::() .map_err(|_| AuthError::internal("Invalid SMTP_FROM."))?; let recipient = recipient_user .email .parse::() .map_err(|_| AuthError::internal("Invalid recipient address."))?; let subject = format!( "{} shared a RustPad {} with you", owner.nickname, resource_label ); let text_body = format!( "Hello {},\n\n{} shared the {} '{}' with you ({access_label}).\nAccept the invitation within 7 days:\n{}\n\nIf you were not expecting this invitation, ignore this message.", recipient_user.nickname, owner.nickname, resource_label, slug, accept_url ); let html_body = format!( r#"

A RustPad {resource_label} was shared with you

Hello {},

{} shared {} with you. Permission: {access_label}.

Accept invitation

This link expires in 7 days.

If the button does not work, copy and paste this link into your browser:
{}

"#, recipient_user.nickname, owner.nickname, slug, accept_url, accept_url, accept_url ); let message = Message::builder() .from(sender) .to(recipient) .subject(subject) .multipart( MultiPart::alternative() .singlepart( SinglePart::builder() .header(ContentType::TEXT_PLAIN) .body(text_body), ) .singlepart( SinglePart::builder() .header(ContentType::TEXT_HTML) .body(html_body), ), ) .map_err(|_| AuthError::internal("Failed to build sharing invitation e-mail."))?; send_message(smtp, message, "sharing invitation e-mail").await } async fn send_message(smtp: &SmtpConfig, message: Message, label: &str) -> Result<(), AuthError> { let mut builder = if smtp.port == 465 { AsyncSmtpTransport::::relay(&smtp.host) } else { AsyncSmtpTransport::::starttls_relay(&smtp.host) } .map_err(|error| { tracing::error!(error = %error, host = %smtp.host, port = smtp.port, "invalid SMTP configuration"); AuthError::internal("Invalid SMTP configuration.") })? .port(smtp.port); if !smtp.username.is_empty() { builder = builder.credentials(Credentials::new( smtp.username.clone(), smtp.password.clone(), )); } builder.build().send(message).await.map_err(|error| { tracing::error!(error = %error, host = %smtp.host, port = smtp.port, message_type = label, "SMTP delivery failed"); AuthError::service_unavailable( "The e-mail could not be sent. Check the SMTP configuration.", ) })?; Ok(()) } async fn send_reset(smtp: &SmtpConfig, user: &User, token: &str) -> Result<(), AuthError> { let site = smtp.public_url.trim_end_matches('/'); let reset_url = format!("{site}/?reset_token={token}"); let sender = smtp .from .parse::() .map_err(|_| AuthError::internal("Invalid SMTP_FROM."))?; let recipient = user .email .parse::() .map_err(|_| AuthError::internal("Invalid recipient address."))?; let text_body = format!( "Hello {},\n\nUse this link within 30 minutes to set a new password:\n{}\n\nIf you did not request this, ignore this message.", user.nickname, reset_url ); let html_body = format!( r#"

Reset your RustPad password

Hello {},

Use the button below within 30 minutes to set a new password.

Reset password

If the button does not work, open this address:

{}

If you did not request a password reset, ignore this message.

"#, user.nickname, reset_url, reset_url, reset_url ); let message = Message::builder() .from(sender) .to(recipient) .subject("RustPad password reset") .multipart( MultiPart::alternative() .singlepart( SinglePart::builder() .header(ContentType::TEXT_PLAIN) .body(text_body), ) .singlepart( SinglePart::builder() .header(ContentType::TEXT_HTML) .body(html_body), ), ) .map_err(|_| AuthError::internal("Failed to build reset e-mail."))?; send_message(smtp, message, "password reset e-mail").await } pub struct AuthError { status: StatusCode, pub message: String, } impl AuthError { fn bad_request(m: &str) -> Self { Self { status: StatusCode::BAD_REQUEST, message: m.into(), } } fn unauthorized(m: &str) -> Self { Self { status: StatusCode::UNAUTHORIZED, message: m.into(), } } fn forbidden(m: &str) -> Self { Self { status: StatusCode::FORBIDDEN, message: m.into(), } } fn conflict(m: &str) -> Self { Self { status: StatusCode::CONFLICT, message: m.into(), } } fn internal(m: &str) -> Self { Self { status: StatusCode::INTERNAL_SERVER_ERROR, message: m.into(), } } fn service_unavailable(m: &str) -> Self { Self { status: StatusCode::SERVICE_UNAVAILABLE, message: m.into(), } } fn database(e: sqlx::Error) -> Self { tracing::error!(error=%e,"authentication database error"); Self::internal("Database error.") } } fn email_domain(email: &str) -> &str { email .rsplit_once('@') .map(|(_, domain)| domain) .unwrap_or("invalid") } impl axum::response::IntoResponse for AuthError { fn into_response(self) -> axum::response::Response { ( self.status, Json(serde_json::json!( { "error":self.message } )), ) .into_response() } }