Email conf. vs ref

This commit is contained in:
lewis
2025-12-29 18:08:12 +02:00
parent c302d2aea1
commit 5aceed2ab3
17 changed files with 818 additions and 608 deletions
+204 -179
View File
@@ -1,4 +1,5 @@
use crate::api::ApiError;
use crate::auth::BearerAuth;
use crate::state::{AppState, RateLimitKind};
use axum::{
Json,
@@ -10,16 +11,10 @@ use serde::Deserialize;
use serde_json::json;
use tracing::{error, info, warn};
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RequestEmailUpdateInput {
pub email: String,
}
pub async fn request_email_update(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Json(input): Json<RequestEmailUpdateInput>,
auth: BearerAuth,
) -> Response {
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
if !state
@@ -37,41 +32,33 @@ pub async fn request_email_update(
.into_response();
}
let token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok()),
) {
Some(t) => t,
None => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
};
let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await;
let auth_user = match auth_result {
Ok(user) => user,
Err(e) => return ApiError::from(e).into_response(),
};
if let Err(e) = crate::auth::scope_check::check_account_scope(
auth_user.is_oauth,
auth_user.scope.as_deref(),
auth.0.is_oauth,
auth.0.scope.as_deref(),
crate::oauth::scopes::AccountAttr::Email,
crate::oauth::scopes::AccountAction::Manage,
) {
return e;
}
let did = auth_user.did.clone();
let user = match sqlx::query!("SELECT id, handle, email FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
.await
let did = auth.0.did.clone();
let user = match sqlx::query!(
"SELECT id, handle, email, email_verified FROM users WHERE did = $1",
did
)
.fetch_optional(&state.db)
.await
{
Ok(Some(row)) => row,
_ => {
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "account not found"})),
)
.into_response();
}
Err(e) => {
error!("DB error: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
@@ -80,59 +67,44 @@ pub async fn request_email_update(
}
};
let user_id = user.id;
let handle = user.handle;
let current_email = user.email;
let email = input.email.trim().to_lowercase();
let current_email: String = match user.email {
Some(e) => e,
None => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "account does not have an email address"})),
)
.into_response();
}
};
if !crate::api::validation::is_valid_email(&email) {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})),
let token_required = user.email_verified;
if token_required {
let code = crate::auth::verification_token::generate_channel_update_token(
&did,
"email_update",
&current_email.to_lowercase(),
);
let formatted_code =
crate::auth::verification_token::format_token_for_display(&code);
let hostname =
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
if let Err(e) = crate::comms::enqueue_email_update_token(
&state.db,
user.id,
&formatted_code,
&hostname,
)
.into_response();
.await
{
warn!("Failed to enqueue email update notification: {:?}", e);
}
}
if current_email.as_ref().map(|e| e.to_lowercase()) == Some(email.clone()) {
return (StatusCode::OK, Json(json!({ "tokenRequired": false }))).into_response();
}
let exists = sqlx::query!(
"SELECT 1 as one FROM users WHERE LOWER(email) = $1 AND id != $2",
email,
user_id
)
.fetch_optional(&state.db)
.await;
if let Ok(Some(_)) = exists {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "EmailTaken", "message": "Email already taken"})),
)
.into_response();
}
if let Err(e) = crate::api::notification_prefs::request_channel_verification(
&state.db,
user_id,
&did,
"email",
&email,
Some(&handle),
)
.await
{
error!("Failed to request email verification: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
info!("Email update requested for user {}", user_id);
(StatusCode::OK, Json(json!({ "tokenRequired": true }))).into_response()
info!("Email update requested for user {}", user.id);
(StatusCode::OK, Json(json!({ "tokenRequired": token_required }))).into_response()
}
#[derive(Deserialize)]
@@ -145,11 +117,12 @@ pub struct ConfirmEmailInput {
pub async fn confirm_email(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
auth: BearerAuth,
Json(input): Json<ConfirmEmailInput>,
) -> Response {
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
if !state
.check_rate_limit(RateLimitKind::AppPassword, &client_ip)
.check_rate_limit(RateLimitKind::EmailUpdate, &client_ip)
.await
{
warn!(ip = %client_ip, "Confirm email rate limit exceeded");
@@ -163,41 +136,33 @@ pub async fn confirm_email(
.into_response();
}
let token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok()),
) {
Some(t) => t,
None => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
};
let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await;
let auth_user = match auth_result {
Ok(user) => user,
Err(e) => return ApiError::from(e).into_response(),
};
if let Err(e) = crate::auth::scope_check::check_account_scope(
auth_user.is_oauth,
auth_user.scope.as_deref(),
auth.0.is_oauth,
auth.0.scope.as_deref(),
crate::oauth::scopes::AccountAttr::Email,
crate::oauth::scopes::AccountAction::Manage,
) {
return e;
}
let did = auth_user.did;
let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
.fetch_one(&state.db)
.await
let did = auth.0.did;
let user = match sqlx::query!(
"SELECT id, email, email_verified FROM users WHERE did = $1",
did
)
.fetch_optional(&state.db)
.await
{
Ok(id) => id,
Err(_) => {
Ok(Some(row)) => row,
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "AccountNotFound", "message": "user not found"})),
)
.into_response();
}
Err(e) => {
error!("DB error: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
@@ -206,14 +171,37 @@ pub async fn confirm_email(
}
};
let email = input.email.trim().to_lowercase();
let current_email = match &user.email {
Some(e) => e.to_lowercase(),
None => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidEmail", "message": "account does not have an email address"})),
)
.into_response();
}
};
let provided_email = input.email.trim().to_lowercase();
if provided_email != current_email {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidEmail", "message": "invalid email"})),
)
.into_response();
}
if user.email_verified {
return (StatusCode::OK, Json(json!({}))).into_response();
}
let confirmation_code =
crate::auth::verification_token::normalize_token_input(input.token.trim());
let verified = crate::auth::verification_token::verify_channel_update_token(
let verified = crate::auth::verification_token::verify_signup_token(
&confirmation_code,
"email",
&email,
&provided_email,
);
match verified {
@@ -245,25 +233,14 @@ pub async fn confirm_email(
}
let update = sqlx::query!(
"UPDATE users SET email = $1, email_verified = TRUE, updated_at = NOW() WHERE id = $2",
email,
user_id
"UPDATE users SET email_verified = TRUE, updated_at = NOW() WHERE id = $1",
user.id
)
.execute(&state.db)
.await;
if let Err(e) = update {
error!("DB error finalizing email update: {:?}", e);
if e.as_database_error()
.map(|db_err| db_err.is_unique_violation())
.unwrap_or(false)
{
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "EmailTaken", "message": "Email already taken"})),
)
.into_response();
}
error!("DB error confirming email: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
@@ -271,7 +248,7 @@ pub async fn confirm_email(
.into_response();
}
info!("Email updated for user {}", user_id);
info!("Email confirmed for user {}", user.id);
(StatusCode::OK, Json(json!({}))).into_response()
}
@@ -289,7 +266,7 @@ pub async fn update_email(
headers: axum::http::HeaderMap,
Json(input): Json<UpdateEmailInput>,
) -> Response {
let token = match crate::auth::extract_bearer_token_from_header(
let bearer_token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok()),
) {
Some(t) => t,
@@ -302,7 +279,7 @@ pub async fn update_email(
}
};
let auth_result = crate::auth::validate_bearer_token(&state.db, &token).await;
let auth_result = crate::auth::validate_bearer_token(&state.db, &bearer_token).await;
let auth_user = match auth_result {
Ok(user) => user,
Err(e) => return ApiError::from(e).into_response(),
@@ -318,12 +295,23 @@ pub async fn update_email(
}
let did = auth_user.did;
let user = match sqlx::query!("SELECT id, email FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
.await
let user = match sqlx::query!(
"SELECT id, email, email_verified FROM users WHERE did = $1",
did
)
.fetch_optional(&state.db)
.await
{
Ok(Some(row)) => row,
_ => {
Ok(None) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "account not found"})),
)
.into_response();
}
Err(e) => {
error!("DB error: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
@@ -333,13 +321,17 @@ pub async fn update_email(
};
let user_id = user.id;
let current_email = user.email;
let current_email = user.email.clone();
let email_verified = user.email_verified;
let new_email = input.email.trim().to_lowercase();
if !crate::api::validation::is_valid_email(&new_email) {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})),
Json(json!({
"error": "InvalidRequest",
"message": "This email address is not supported, please use a different email."
})),
)
.into_response();
}
@@ -350,48 +342,58 @@ pub async fn update_email(
return (StatusCode::OK, Json(json!({}))).into_response();
}
let confirmation_token = match &input.token {
Some(t) => crate::auth::verification_token::normalize_token_input(t.trim()),
None => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "TokenRequired", "message": "Token required. Call requestEmailUpdate first."})),
)
.into_response();
}
};
let verified = crate::auth::verification_token::verify_channel_update_token(
&confirmation_token,
"email",
&new_email,
);
match verified {
Ok(token_data) => {
if token_data.did != did {
if email_verified {
let confirmation_token = match &input.token {
Some(t) => crate::auth::verification_token::normalize_token_input(t.trim()),
None => {
return (
StatusCode::BAD_REQUEST,
Json(
json!({"error": "InvalidToken", "message": "Token does not match account"}),
),
Json(json!({
"error": "TokenRequired",
"message": "confirmation token required"
})),
)
.into_response();
}
};
let current_email_lower = current_email
.as_ref()
.map(|e| e.to_lowercase())
.unwrap_or_default();
let verified = crate::auth::verification_token::verify_channel_update_token(
&confirmation_token,
"email_update",
&current_email_lower,
);
match verified {
Ok(token_data) => {
if token_data.did != did {
return (
StatusCode::BAD_REQUEST,
Json(
json!({"error": "InvalidToken", "message": "Token does not match account"}),
),
)
.into_response();
}
}
Err(crate::auth::verification_token::VerifyError::Expired) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "ExpiredToken", "message": "Token has expired"})),
)
.into_response();
}
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidToken", "message": "Invalid token"})),
)
.into_response();
}
}
Err(crate::auth::verification_token::VerifyError::Expired) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "ExpiredToken", "message": "Token has expired"})),
)
.into_response();
}
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidToken", "message": "Invalid token"})),
)
.into_response();
}
}
@@ -406,13 +408,16 @@ pub async fn update_email(
if let Ok(Some(_)) = exists {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "Email already in use"})),
Json(json!({
"error": "InvalidRequest",
"message": "This email address is already in use, please use a different email."
})),
)
.into_response();
}
let update = sqlx::query!(
"UPDATE users SET email = $1, email_verified = TRUE, updated_at = NOW() WHERE id = $2",
let update: Result<sqlx::postgres::PgQueryResult, sqlx::Error> = sqlx::query!(
"UPDATE users SET email = $1, email_verified = FALSE, updated_at = NOW() WHERE id = $2",
new_email,
user_id
)
@@ -420,14 +425,17 @@ pub async fn update_email(
.await;
if let Err(e) = update {
error!("DB error finalizing email update: {:?}", e);
error!("DB error updating email: {:?}", e);
if e.as_database_error()
.map(|db_err| db_err.is_unique_violation())
.map(|db_err: &dyn sqlx::error::DatabaseError| db_err.is_unique_violation())
.unwrap_or(false)
{
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "Email already in use"})),
Json(json!({
"error": "InvalidRequest",
"message": "This email address is already in use, please use a different email."
})),
)
.into_response();
}
@@ -438,6 +446,23 @@ pub async fn update_email(
.into_response();
}
let verification_token =
crate::auth::verification_token::generate_signup_token(&did, "email", &new_email);
let formatted_token =
crate::auth::verification_token::format_token_for_display(&verification_token);
if let Err(e) = crate::comms::enqueue_signup_verification(
&state.db,
user_id,
"email",
&new_email,
&formatted_token,
None,
)
.await
{
warn!("Failed to send verification email to new address: {:?}", e);
}
match sqlx::query!(
"INSERT INTO account_preferences (user_id, name, value_json) VALUES ($1, 'email_auth_factor', $2) ON CONFLICT (user_id, name) DO UPDATE SET value_json = $2",
user_id,
+3 -3
View File
@@ -10,9 +10,9 @@ pub use sender::{
pub use service::{
CommsService, channel_display_name, enqueue_2fa_code, enqueue_account_deletion, enqueue_comms,
enqueue_email_update, enqueue_migration_verification, enqueue_passkey_recovery,
enqueue_password_reset, enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome,
queue_legacy_login_notification,
enqueue_email_update, enqueue_email_update_token, enqueue_migration_verification,
enqueue_passkey_recovery, enqueue_password_reset, enqueue_plc_operation,
enqueue_signup_verification, enqueue_welcome, queue_legacy_login_notification,
};
pub use types::{CommsChannel, CommsStatus, CommsType, NewComms, QueuedComms};
+38
View File
@@ -380,6 +380,44 @@ pub async fn enqueue_email_update(
.await
}
pub async fn enqueue_email_update_token(
db: &PgPool,
user_id: Uuid,
code: &str,
hostname: &str,
) -> Result<Uuid, sqlx::Error> {
let prefs = get_user_comms_prefs(db, user_id).await?;
let strings = get_strings(&prefs.locale);
let current_email = prefs.email.clone().unwrap_or_default();
let verify_page = format!("https://{}/#/verify?type=email-update", hostname);
let verify_link = format!(
"https://{}/#/verify?type=email-update&token={}",
hostname,
urlencoding::encode(code)
);
let body = format_message(
strings.email_update_body,
&[
("handle", &prefs.handle),
("code", code),
("verify_page", &verify_page),
("verify_link", &verify_link),
],
);
let subject = format_message(strings.email_update_subject, &[("hostname", hostname)]);
enqueue_comms(
db,
NewComms::email(
user_id,
super::types::CommsType::EmailUpdate,
current_email,
subject,
body,
),
)
.await
}
pub async fn enqueue_account_deletion(
db: &PgPool,
user_id: Uuid,