Initial account deletion request endpoint

This commit is contained in:
Lewis
2025-12-09 21:09:08 +02:00
parent 7559378fa8
commit abe1307673
10 changed files with 159 additions and 4 deletions
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO account_deletion_requests (token, did, expires_at) VALUES ($1, $2, $3)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Timestamptz"
]
},
"nullable": []
},
"hash": "a45ee2c7a075b27a403838b3295604e67c4213023b49e1155c4ab22e657954ff"
}
+1 -1
View File
@@ -34,7 +34,7 @@ Lewis' corrected big boy todofile
- [x] Implement `com.atproto.server.getAccountInviteCodes`.
- [x] Implement `com.atproto.server.getServiceAuth` (Cross-service auth).
- [x] Implement `com.atproto.server.listAppPasswords`.
- [ ] Implement `com.atproto.server.requestAccountDelete`.
- [x] Implement `com.atproto.server.requestAccountDelete`.
- [ ] Implement `com.atproto.server.requestEmailConfirmation` / `requestEmailUpdate`.
- [ ] Implement `com.atproto.server.requestPasswordReset` / `resetPassword`.
- [ ] Implement `com.atproto.server.reserveSigningKey`.
@@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS account_deletion_requests (
token TEXT PRIMARY KEY,
did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
+1 -1
View File
@@ -7,5 +7,5 @@ pub use meta::{describe_server, health};
pub use session::{
activate_account, check_account_status, create_app_password, create_session,
deactivate_account, delete_session, get_service_auth, get_session, list_app_passwords,
refresh_session, revoke_app_password,
refresh_session, request_account_delete, revoke_app_password,
};
+88
View File
@@ -6,6 +6,8 @@ use axum::{
response::{IntoResponse, Response},
};
use bcrypt::verify;
use chrono::{Duration, Utc};
use uuid::Uuid;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::{error, info, warn};
@@ -340,6 +342,92 @@ pub async fn delete_session(
.into_response()
}
pub async fn request_account_delete(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
) -> 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
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) = match session {
Ok(Some(row)) => (row.did, row.key_bytes),
Ok(None) => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "AuthenticationFailed"})),
)
.into_response();
}
Err(e) => {
error!("DB error in request_account_delete: {:?}", 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 confirmation_token = Uuid::new_v4().to_string();
let expires_at = Utc::now() + Duration::minutes(15);
let insert = sqlx::query!(
"INSERT INTO account_deletion_requests (token, did, expires_at) VALUES ($1, $2, $3)",
confirmation_token,
did,
expires_at
)
.execute(&state.db)
.await;
if let Err(e) = insert {
error!("DB error creating deletion token: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
// TODO: Send email or other notification
info!("Account deletion requested for user {}, token: {}", did, confirmation_token);
(StatusCode::OK, Json(json!({}))).into_response()
}
pub async fn refresh_session(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
+4
View File
@@ -150,6 +150,10 @@ pub fn app(state: AppState) -> Router {
"/xrpc/com.atproto.server.deactivateAccount",
post(api::server::deactivate_account),
)
.route(
"/xrpc/com.atproto.server.requestAccountDelete",
post(api::server::request_account_delete),
)
.route(
"/xrpc/com.atproto.identity.updateHandle",
post(api::identity::update_handle),
+8
View File
@@ -201,6 +201,14 @@ async fn spawn_app(database_url: String) -> String {
format!("http://{}", addr)
}
#[allow(dead_code)]
pub async fn get_db_connection_string() -> String {
base_url().await;
let container = DB_CONTAINER.get().expect("DB container not initialized");
let port = container.get_host_port_ipv4(5432).await.expect("Failed to get port");
format!("postgres://postgres:postgres@127.0.0.1:{}/postgres", port)
}
#[allow(dead_code)]
pub async fn upload_test_blob(client: &Client, data: &'static str, mime: &'static str) -> Value {
let res = client
+3
View File
@@ -96,6 +96,7 @@ pub async fn create_post(
(uri, cid)
}
#[allow(dead_code)]
pub async fn create_follow(
client: &reqwest::Client,
follower_did: &str,
@@ -142,6 +143,7 @@ pub async fn create_follow(
(uri, cid)
}
#[allow(dead_code)]
pub async fn create_like(
client: &reqwest::Client,
liker_did: &str,
@@ -186,6 +188,7 @@ pub async fn create_like(
)
}
#[allow(dead_code)]
pub async fn create_repost(
client: &reqwest::Client,
reposter_did: &str,
+32 -1
View File
@@ -442,4 +442,35 @@ async fn test_service_auth_lifecycle() {
assert_eq!(claims["iss"], did);
assert_eq!(claims["aud"], "did:web:api.bsky.app");
assert_eq!(claims["lxm"], "com.atproto.repo.uploadBlob");
}
}
#[tokio::test]
async fn test_request_account_delete() {
let client = client();
let (did, jwt) = setup_new_user("request-delete-test").await;
let res = client
.post(format!(
"{}/xrpc/com.atproto.server.requestAccountDelete",
base_url().await
))
.bearer_auth(&jwt)
.send()
.await
.expect("Failed to request account deletion");
assert_eq!(res.status(), StatusCode::OK);
let db_url = get_db_connection_string().await;
let pool = sqlx::PgPool::connect(&db_url).await.expect("Failed to connect to test DB");
let row = sqlx::query!("SELECT token, expires_at FROM account_deletion_requests WHERE did = $1", did)
.fetch_optional(&pool)
.await
.expect("Failed to query DB");
assert!(row.is_some(), "Deletion token should exist in DB");
let row = row.unwrap();
assert!(!row.token.is_empty(), "Token should not be empty");
assert!(row.expires_at > Utc::now(), "Token should not be expired");
}
-1
View File
@@ -6,7 +6,6 @@ use helpers::*;
use reqwest::StatusCode;
use reqwest::header;
use serde_json::{Value, json};
use chrono::Utc;
#[tokio::test]
async fn test_get_latest_commit_success() {