use argon2::{password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, Argon2}; use axum::{extract::{Path as AxumPath, State}, http::{HeaderMap, StatusCode}, response::Redirect, Json}; use chrono::{Duration, Utc}; use lettre::{ message::{header::ContentType, Mailbox, MultiPart, SinglePart}, transport::smtp::authentication::Credentials, AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, }; 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 ResetRequest { email: String } #[derive(Deserialize)] pub struct ResetConfirmRequest { token: String, password: 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 } #[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> { 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.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(); sqlx::query(queries::get(state.db.kind(), queries::AUTH_INSERT_CONFIRMATION_TOKEN)) .bind(hash_token(&token)).bind(user.id).bind(expires).execute(state.db.pool()).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> { let email = validate_email(&req.email)?; debug!(email_domain = %email_domain(&email), "login requested"); let user = find_user_by_email(&state, &email).await?.ok_or_else(|| AuthError::unauthorized("Invalid e-mail address or password."))?; if !verify_password(&user.password_hash, &req.password) { warn!(user_id = user.id, "login rejected: invalid password"); return Err(AuthError::unauthorized("Invalid e-mail address or password.")); } if state.account_confirmation_required && user.confirmed_at.is_none() { return Err(AuthError::forbidden("Confirm the account using the link sent by e-mail before logging in.")); } let session = create_session(&state, &user).await?; info!(user_id = user.id, nickname = %user.nickname, "login successful"); Ok(Json(session)) } 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)?; 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)?; sqlx::query(queries::get(state.db.kind(), queries::AUTH_MARK_CONFIRMATION_TOKEN_USED)).bind(&now).bind(&token_hash).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."}))) } 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)?; Ok(Json(SessionResponse { token: token.into(), nickname: user.nickname, email: user.email, expires_at })) } 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(), "DELETE FROM resource_access_tokens WHERE resource_kind = ? AND resource_slug = ?")) .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" => queries::USER_DELETE_WORKSPACE, "pad" => queries::USER_DELETE_PAD, _ => return Err(AuthError::bad_request("Unknown resource type.")) }; sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_access_tokens WHERE resource_kind = ? AND resource_slug = ?")) .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 table = match req.kind.as_str() { "workspace" => "workspaces", "pad" => "pads", _ => return Err(AuthError::bad_request("Unknown resource type.")) }; let query = format!("UPDATE {table} SET is_private = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"); sqlx::query(&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.id == owner.id { continue; } sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?")) .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(), "DELETE FROM resource_share_invitations WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?")) .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(), "INSERT INTO resource_share_invitations (token_hash, resource_kind, resource_slug, user_id, permission, created_by, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)")) .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(), "DELETE FROM resource_share_invitations WHERE token_hash = ?")) .bind(&token_hash).execute(state.db.pool()).await; return Err(error); } } else { sqlx::query(queries::get(state.db.kind(), "INSERT INTO resource_permissions (resource_kind, resource_slug, user_id, permission) VALUES (?, ?, ?, ?)")) .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(), "SELECT resource_kind, resource_slug, user_id, permission, expires_at, accepted_at FROM resource_share_invitations WHERE token_hash = ?")) .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(), "DELETE FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?")) .bind(&kind).bind(&slug).bind(user_id).execute(&mut *tx).await.map_err(AuthError::database)?; sqlx::query(queries::get(state.db.kind(), "INSERT INTO resource_permissions (resource_kind, resource_slug, user_id, permission) VALUES (?, ?, ?, ?)")) .bind(&kind).bind(&slug).bind(user_id).bind(&permission).execute(&mut *tx).await.map_err(AuthError::database)?; sqlx::query(queries::get(state.db.kind(), "UPDATE resource_share_invitations SET accepted_at = ? WHERE token_hash = ?")) .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(), "DELETE FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?")) .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(), "DELETE FROM resource_share_invitations WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?")) .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(), "SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = ? AND rp.resource_slug = ? ORDER BY u.email")) .bind(kind).bind(slug).fetch_all(state.db.pool()).await.map_err(AuthError::database)?; let links: Vec<(String,String,Option,String)> = sqlx::query_as(queries::get(state.db.kind(), "SELECT token_hash, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC")) .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(), "SELECT u.email, u.nickname, i.permission, i.expires_at FROM resource_share_invitations i JOIN users u ON u.id = i.user_id WHERE i.resource_kind = ? AND i.resource_slug = ? AND i.accepted_at IS NULL ORDER BY u.email")) .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,permission,expires_at,created_at)|serde_json::json!({"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(), "INSERT INTO resource_share_links (token_hash, resource_kind, resource_slug, permission, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?)")) .bind(token_hash).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(), "UPDATE resource_share_links SET permission = ?, expires_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL")) .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(), "UPDATE resource_share_links SET revoked_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ?")) .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 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(), "SELECT permission FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?")) .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(), "SELECT permission FROM resource_share_links WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)")) .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(); sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_RESET_TOKENS_BY_USER)).bind(user.id).execute(state.db.pool()).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(state.db.pool()).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 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_MARK_RESET_TOKEN_USED)) .bind(&now).bind(&token_hash).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().to_rfc3339(); sqlx::query_as::<_, User>(queries::get(state.db.kind(), queries::AUTH_USER_BY_SESSION)) .bind(token).bind(now).fetch_optional(state.db.pool()).await.map_err(AuthError::database) } 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"); Ok(SessionResponse { token, nickname: user.nickname.clone(), email: user.email.clone(), expires_at }) } 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_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_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.

"#, recipient_user.nickname, owner.nickname, slug, 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() } }