new functions

This commit is contained in:
Mateusz Gruszczyński
2026-07-27 12:36:00 +02:00
parent 63999f0878
commit 79298b5d17
15 changed files with 1064 additions and 218 deletions
+304 -91
View File
@@ -77,15 +77,25 @@ pub struct ResetConfirmRequest {
}
#[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)]
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 }
pub struct DeleteAccountRequest {
password: String,
}
#[derive(Deserialize)]
pub struct AccountActionConfirmRequest { token: String }
pub struct AccountActionConfirmRequest {
token: String,
}
#[derive(Deserialize)]
pub struct ResourceActionRequest {
kind: String,
@@ -163,6 +173,7 @@ pub struct SessionResponse {
directory_display_name: Option<String>,
directory_organization: Option<String>,
suggested_nickname: Option<String>,
editor_color: Option<String>,
}
#[derive(Serialize)]
pub struct IdentityResponse {
@@ -367,18 +378,23 @@ pub async fn login(
}
}
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."));
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 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."))?;
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."));
}
@@ -386,25 +402,47 @@ pub async fn resend_confirmation(
state.db.kind(),
queries::AUTH_LATEST_CONFIRMATION_CREATED_AT,
))
.bind(user.id).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?;
.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)));
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)?;
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)?;
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."})))
Ok(Json(
serde_json::json!({"ok":true,"message":"A new confirmation e-mail has been sent."}),
))
}
pub async fn confirm_account(
@@ -504,7 +542,10 @@ async fn directory_profile_metadata(
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();
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();
@@ -540,6 +581,14 @@ pub async fn me(
.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,
@@ -549,6 +598,7 @@ pub async fn me(
directory_display_name,
directory_organization,
suggested_nickname,
editor_color,
}))
}
@@ -586,38 +636,34 @@ pub async fn update_profile(
if normalize(&nickname) != normalize(&user.nickname)
&& find_user_by_nickname(&state, &nickname).await?.is_some()
{
return Err(AuthError::conflict(
"This nickname is already registered.",
));
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)
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
@@ -632,18 +678,34 @@ pub async fn update_profile(
"This e-mail address is already registered.",
));
}
let smtp = state.smtp.as_ref().ok_or_else(|| {
AuthError::service_unavailable("SMTP is not configured.")
})?;
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."
@@ -654,61 +716,190 @@ pub async fn update_profile(
}
pub async fn request_account_deletion(
State(state): State<SharedState>, headers: HeaderMap, Json(req): Json<DeleteAccountRequest>,
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.")); }
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."))?;
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."})))
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>,
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 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.")); }
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)?;
.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)?;
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.")); };
} 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 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
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(
@@ -1476,12 +1667,13 @@ pub async fn confirm_reset(
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)?;
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();
@@ -1553,6 +1745,14 @@ async fn create_session(state: &SharedState, user: &User) -> Result<SessionRespo
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(),
@@ -1562,6 +1762,7 @@ async fn create_session(state: &SharedState, user: &User) -> Result<SessionRespo
directory_display_name,
directory_organization,
suggested_nickname,
editor_color,
})
}
async fn find_user_by_nickname(
@@ -1600,6 +1801,18 @@ async fn find_user_by_email(state: &SharedState, email: &str) -> Result<Option<U
.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 {