Files
rustpad/src/auth/mod.rs
T
2026-07-27 12:36:00 +02:00

2224 lines
74 KiB
Rust

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<String>,
}
#[derive(Deserialize)]
pub struct IdentityRequest {
nickname: String,
#[serde(default)]
session_token: Option<String>,
}
#[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<String>,
#[serde(default)]
new_email: Option<String>,
#[serde(default)]
new_password: Option<String>,
#[serde(default)]
password: String,
#[serde(default)]
editor_color: Option<String>,
}
#[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<String>,
}
#[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<String>,
}
#[derive(Deserialize)]
pub struct UpdateShareLinkRequest {
kind: String,
slug: String,
token: String,
permission: String,
expires_at: Option<String>,
}
#[derive(Deserialize)]
pub struct RevokeShareLinkRequest {
kind: String,
slug: String,
token: String,
}
#[derive(Serialize)]
pub struct ResourceList {
workspaces: Vec<ResourceItem>,
pads: Vec<ResourceItem>,
}
#[derive(Serialize)]
pub struct SessionResponse {
token: String,
nickname: String,
email: String,
expires_at: String,
directory_managed: bool,
directory_display_name: Option<String>,
directory_organization: Option<String>,
suggested_nickname: Option<String>,
editor_color: Option<String>,
}
#[derive(Serialize)]
pub struct IdentityResponse {
nickname: String,
registered: bool,
}
#[derive(Serialize)]
pub struct RegisterResponse {
token: Option<String>,
nickname: String,
email: String,
expires_at: Option<String>,
confirmation_required: bool,
message: String,
}
pub async fn identity(
State(state): State<SharedState>,
Json(req): Json<IdentityRequest>,
) -> Result<Json<IdentityResponse>, 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<SharedState>,
Json(req): Json<RegisterRequest>,
) -> Result<(StatusCode, Json<RegisterResponse>), 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<SharedState>,
Json(req): Json<LoginRequest>,
) -> Result<Json<SessionResponse>, 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<SharedState>,
Json(req): Json<ResendConfirmationRequest>,
) -> Result<Json<serde_json::Value>, 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<String> = 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<SharedState>,
Json(req): Json<ConfirmAccountRequest>,
) -> Result<Json<serde_json::Value>, 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<String>)> = 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<String>, Option<String>, Option<String>), AuthError> {
let row: Option<(String, Option<String>)> = 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<String> {
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<SharedState>,
headers: HeaderMap,
) -> Result<Json<SessionResponse>, 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<String> = 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<SharedState>,
headers: HeaderMap,
Json(req): Json<ProfileUpdateRequest>,
) -> Result<Json<serde_json::Value>, 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<SharedState>,
headers: HeaderMap,
Json(req): Json<DeleteAccountRequest>,
) -> Result<Json<serde_json::Value>, 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<SharedState>,
Json(req): Json<AccountActionConfirmRequest>,
) -> Result<Json<serde_json::Value>, AuthError> {
let now = Utc::now();
let hash = hash_token(req.token.trim());
let row: Option<(i64, String, Option<String>, String, Option<String>)> = 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::<Mailbox>()
.map_err(|_| AuthError::internal("Invalid SMTP_FROM."))?;
let target = if action == "email" {
payload.unwrap_or(&user.email)
} else {
&user.email
};
let recipient = target
.parse::<Mailbox>()
.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#"<!doctype html><html lang="en"><body style="margin:0;padding:24px;background:#f4f4f5;font-family:Arial,sans-serif;color:#18181b"><div style="max-width:560px;margin:0 auto;padding:24px;background:#fff;border-radius:10px"><h1 style="margin-top:0;font-size:22px">{}</h1><p>Hello {},</p><p>{}</p><p><a href="{}" style="display:inline-block;padding:11px 18px;background:#2563eb;color:#fff;text-decoration:none;border-radius:6px">Confirm action</a></p><p style="font-size:13px;overflow-wrap:anywhere"><a href="{}">{}</a></p><p>If you did not request this, ignore this message.</p></div></body></html>"#,
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<SharedState>,
headers: HeaderMap,
) -> Result<Json<ResourceList>, 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<SharedState>,
headers: HeaderMap,
Json(req): Json<ResourceActionRequest>,
) -> Result<Json<serde_json::Value>, 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<SharedState>,
headers: HeaderMap,
Json(req): Json<ResourceActionRequest>,
) -> Result<Json<serde_json::Value>, 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<Option<User>, AuthError> {
match bearer(headers) {
Some(token) => user_from_token(state, token).await,
None => Ok(None),
}
}
async fn require_user(state: &SharedState, headers: &HeaderMap) -> Result<User, AuthError> {
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<SharedState>,
headers: HeaderMap,
Json(req): Json<PrivacyRequest>,
) -> Result<Json<serde_json::Value>, 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<SharedState>,
headers: HeaderMap,
Json(req): Json<ShareUsersRequest>,
) -> Result<Json<serde_json::Value>, 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<String> = 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<SharedState>,
AxumPath(token): AxumPath<String>,
) -> Result<Redirect, AuthError> {
let token_hash = hash_token(token.trim());
let row: Option<(String, String, i64, String, String, Option<String>)> = 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<SharedState>,
headers: HeaderMap,
Json(req): Json<RemoveShareRequest>,
) -> Result<Json<serde_json::Value>, 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<SharedState>,
headers: HeaderMap,
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
) -> Result<Json<serde_json::Value>, 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>, String, Option<String>, 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::<Vec<_>>(), "pending":pending.into_iter().map(|(email,nickname,permission,expires_at)|serde_json::json!({"email":email,"nickname":nickname,"permission":permission,"expires_at":expires_at})).collect::<Vec<_>>(), "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::<Vec<_>>() }),
))
}
pub async fn create_share_link(
State(state): State<SharedState>,
headers: HeaderMap,
Json(req): Json<CreateShareLinkRequest>,
) -> Result<Json<serde_json::Value>, 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<SharedState>,
headers: HeaderMap,
Json(req): Json<UpdateShareLinkRequest>,
) -> Result<Json<serde_json::Value>, 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<SharedState>,
headers: HeaderMap,
Json(req): Json<RevokeShareLinkRequest>,
) -> Result<Json<serde_json::Value>, 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<bool, AuthError> {
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<Option<String>, 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<String> = 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<String> = 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<SharedState>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, 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<SharedState>,
Json(req): Json<ResetRequest>,
) -> Result<Json<serde_json::Value>, 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<SharedState>,
Json(req): Json<ResetConfirmRequest>,
) -> Result<Json<serde_json::Value>, 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<String>)> = 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<Option<User>, 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<String>,
token: Option<String>,
) -> Result<Option<String>, 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<SessionResponse, AuthError> {
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<String> = 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<Option<User>, 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<Option<User>, 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<Option<User>, 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<String, AuthError> {
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<String, AuthError> {
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<String, AuthError> {
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<String, AuthError> {
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<const N: usize>() -> 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::<Mailbox>()
.map_err(|_| AuthError::internal("Invalid SMTP_FROM."))?;
let recipient = user
.email
.parse::<Mailbox>()
.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#"<!doctype html>
<html lang="en">
<body style="margin: 0; padding: 24px; background: #f4f4f5; font-family: Arial, sans-serif; color: #18181b;">
<div style="max-width: 560px; margin: 0 auto; padding: 24px; background: #ffffff; border-radius: 10px;">
<h1 style="margin-top: 0; font-size: 22px;">Confirm your RustPad account</h1>
<p>Hello {},</p>
<p>Your RustPad account has been created.</p>
<p><strong>Nickname:</strong> {}<br><strong>Site:</strong> {}</p>
<p>Confirm the account within 24 hours:</p>
<p><a href="{}" style="display: inline-block; padding: 11px 18px; background: #2563eb; color: #ffffff; text-decoration: none; border-radius: 6px;">Confirm account</a></p>
<p style="font-size: 13px; color: #52525b;">If the button does not work, open this address:</p>
<p style="font-size: 13px; overflow-wrap: anywhere;"><a href="{}">{}</a></p>
</div>
</body>
</html>"#,
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#"<!doctype html>
<html lang="en">
<body style="margin: 0; padding: 24px; background: #f4f4f5; font-family: Arial, sans-serif; color: #18181b;">
<div style="max-width: 560px; margin: 0 auto; padding: 24px; background: #ffffff; border-radius: 10px;">
<h1 style="margin-top: 0; font-size: 22px;">Your RustPad account is ready</h1>
<p>Hello {},</p>
<p>Your RustPad account has been created.</p>
<p><strong>Nickname:</strong> {}</p>
<p><a href="{}" style="display: inline-block; padding: 11px 18px; background: #2563eb; color: #ffffff; text-decoration: none; border-radius: 6px;">Open RustPad</a></p>
</div>
</body>
</html>"#,
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::<Mailbox>()
.map_err(|_| AuthError::internal("Invalid SMTP_FROM."))?;
let recipient = recipient_user
.email
.parse::<Mailbox>()
.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#"<!doctype html><html lang="en"><body style="margin:0;padding:24px;background:#f4f4f5;font-family:Arial,sans-serif;color:#18181b"><div style="max-width:560px;margin:0 auto;padding:24px;background:#fff;border-radius:10px"><h1 style="margin-top:0;font-size:22px">A RustPad {resource_label} was shared with you</h1><p>Hello {},</p><p><strong>{}</strong> shared <strong>{}</strong> with you. Permission: <strong>{access_label}</strong>.</p><p><a href="{}" style="display:inline-block;padding:11px 18px;background:#2563eb;color:#fff;text-decoration:none;border-radius:6px">Accept invitation</a></p><p style="font-size:13px;color:#52525b">This link expires in 7 days.</p><p style="font-size:13px;color:#52525b;word-break:break-all">If the button does not work, copy and paste this link into your browser:<br><a href="{}" style="color:#2563eb">{}</a></p></div></body></html>"#,
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::<Tokio1Executor>::relay(&smtp.host)
} else {
AsyncSmtpTransport::<Tokio1Executor>::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::<Mailbox>()
.map_err(|_| AuthError::internal("Invalid SMTP_FROM."))?;
let recipient = user
.email
.parse::<Mailbox>()
.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#"<!doctype html>
<html lang="en">
<body style="margin: 0; padding: 24px; background: #f4f4f5; font-family: Arial, sans-serif; color: #18181b;">
<div style="max-width: 560px; margin: 0 auto; padding: 24px; background: #ffffff; border-radius: 10px;">
<h1 style="margin-top: 0; font-size: 22px;">Reset your RustPad password</h1>
<p>Hello {},</p>
<p>Use the button below within 30 minutes to set a new password.</p>
<p><a href="{}" style="display: inline-block; padding: 11px 18px; background: #2563eb; color: #ffffff; text-decoration: none; border-radius: 6px;">Reset password</a></p>
<p style="font-size: 13px; color: #52525b;">If the button does not work, open this address:</p>
<p style="font-size: 13px; overflow-wrap: anywhere;"><a href="{}">{}</a></p>
<p>If you did not request a password reset, ignore this message.</p>
</div>
</body>
</html>"#,
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()
}
}