More email-related endpoints

This commit is contained in:
Lewis
2025-12-09 22:52:22 +02:00
parent 39096a217e
commit 264e61eb14
20 changed files with 820 additions and 160 deletions

View File

@@ -5,8 +5,8 @@ pub mod session;
pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes};
pub use meta::{describe_server, health};
pub use session::{
activate_account, check_account_status, create_app_password, create_session,
activate_account, check_account_status, confirm_email, create_app_password, create_session,
deactivate_account, delete_session, get_service_auth, get_session, list_app_passwords,
refresh_session, request_account_delete, request_password_reset, reset_password,
revoke_app_password,
refresh_session, request_account_delete, request_email_update, request_password_reset,
reset_password, revoke_app_password,
};

View File

@@ -1419,3 +1419,271 @@ pub async fn reset_password(
(StatusCode::OK, Json(json!({}))).into_response()
}
#[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>,
) -> Response {
let auth_header = headers.get("Authorization");
if auth_header.is_none() {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
let token = auth_header
.unwrap()
.to_str()
.unwrap_or("")
.replace("Bearer ", "");
let session = sqlx::query!(
r#"
SELECT s.did, k.key_bytes, u.id as user_id, u.handle
FROM sessions s
JOIN users u ON s.did = u.did
JOIN user_keys k ON u.id = k.user_id
WHERE s.access_jwt = $1
"#,
token
)
.fetch_optional(&state.db)
.await;
let (_did, key_bytes, user_id, handle) = match session {
Ok(Some(row)) => (row.did, row.key_bytes, row.user_id, row.handle),
Ok(None) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
)
.into_response();
}
Err(e) => {
error!("DB error in request_email_update: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
if let Err(_) = crate::auth::verify_token(&token, &key_bytes) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})),
)
.into_response();
}
let email = input.email.trim().to_lowercase();
if email.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "email is required"})),
)
.into_response();
}
let exists = sqlx::query!("SELECT 1 as one FROM users WHERE LOWER(email) = $1", email)
.fetch_optional(&state.db)
.await;
if let Ok(Some(_)) = exists {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "EmailTaken", "message": "Email already taken"})),
)
.into_response();
}
let code = generate_reset_code();
let expires_at = Utc::now() + Duration::minutes(10);
let update = sqlx::query!(
"UPDATE users SET email_pending_verification = $1, email_confirmation_code = $2, email_confirmation_code_expires_at = $3 WHERE id = $4",
email,
code,
expires_at,
user_id
)
.execute(&state.db)
.await;
if let Err(e) = update {
error!("DB error setting email update code: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
if let Err(e) = crate::notifications::enqueue_email_update(
&state.db,
user_id,
&email,
&handle,
&code,
&hostname,
)
.await
{
warn!("Failed to enqueue email update notification: {:?}", e);
}
info!("Email update requested for user {}", user_id);
(StatusCode::OK, Json(json!({ "tokenRequired": true }))).into_response()
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConfirmEmailInput {
pub email: String,
pub token: String,
}
pub async fn confirm_email(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Json(input): Json<ConfirmEmailInput>,
) -> Response {
let auth_header = headers.get("Authorization");
if auth_header.is_none() {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationRequired"})),
)
.into_response();
}
let token = auth_header
.unwrap()
.to_str()
.unwrap_or("")
.replace("Bearer ", "");
let session = sqlx::query!(
r#"
SELECT s.did, k.key_bytes, u.id as user_id, u.email_confirmation_code, u.email_confirmation_code_expires_at, u.email_pending_verification
FROM sessions s
JOIN users u ON s.did = u.did
JOIN user_keys k ON u.id = k.user_id
WHERE s.access_jwt = $1
"#,
token
)
.fetch_optional(&state.db)
.await;
let (_did, key_bytes, user_id, stored_code, expires_at, email_pending_verification) = match session {
Ok(Some(row)) => (
row.did,
row.key_bytes,
row.user_id,
row.email_confirmation_code,
row.email_confirmation_code_expires_at,
row.email_pending_verification,
),
Ok(None) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
)
.into_response();
}
Err(e) => {
error!("DB error in confirm_email: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
if let Err(_) = crate::auth::verify_token(&token, &key_bytes) {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})),
)
.into_response();
}
let email = input.email.trim().to_lowercase();
let confirmation_code = input.token.trim();
if email_pending_verification.is_none() || stored_code.is_none() || expires_at.is_none() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "No pending email update found"})),
)
.into_response();
}
let email_pending_verification = email_pending_verification.unwrap();
if email_pending_verification != email {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "Email does not match pending update"})),
)
.into_response();
}
if stored_code.unwrap() != confirmation_code {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidToken", "message": "Invalid token"})),
)
.into_response();
}
if Utc::now() > expires_at.unwrap() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "ExpiredToken", "message": "Token has expired"})),
)
.into_response();
}
let update = sqlx::query!(
"UPDATE users SET email = $1, email_pending_verification = NULL, email_confirmation_code = NULL, email_confirmation_code_expires_at = NULL WHERE id = $2",
email_pending_verification,
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();
}
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
info!("Email updated for user {}", user_id);
(StatusCode::OK, Json(json!({}))).into_response()
}