diff --git a/.sqlx/query-a45ee2c7a075b27a403838b3295604e67c4213023b49e1155c4ab22e657954ff.json b/.sqlx/query-a45ee2c7a075b27a403838b3295604e67c4213023b49e1155c4ab22e657954ff.json new file mode 100644 index 0000000..4b57bbd --- /dev/null +++ b/.sqlx/query-a45ee2c7a075b27a403838b3295604e67c4213023b49e1155c4ab22e657954ff.json @@ -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" +} diff --git a/TODO.md b/TODO.md index d37acad..06f2375 100644 --- a/TODO.md +++ b/TODO.md @@ -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`. diff --git a/migrations/202512211900_account_deletion_tokens.sql b/migrations/202512211900_account_deletion_tokens.sql new file mode 100644 index 0000000..1b42221 --- /dev/null +++ b/migrations/202512211900_account_deletion_tokens.sql @@ -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() +); diff --git a/src/api/server/mod.rs b/src/api/server/mod.rs index 04c5727..8ef1129 100644 --- a/src/api/server/mod.rs +++ b/src/api/server/mod.rs @@ -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, }; diff --git a/src/api/server/session.rs b/src/api/server/session.rs index cd70f73..6dc68d1 100644 --- a/src/api/server/session.rs +++ b/src/api/server/session.rs @@ -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, + 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, headers: axum::http::HeaderMap, diff --git a/src/lib.rs b/src/lib.rs index ba3393f..c3b12b8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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), diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 912ad4a..7e95ecb 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -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 diff --git a/tests/helpers/mod.rs b/tests/helpers/mod.rs index 20c454e..2b5cda1 100644 --- a/tests/helpers/mod.rs +++ b/tests/helpers/mod.rs @@ -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, diff --git a/tests/lifecycle_session.rs b/tests/lifecycle_session.rs index 683030a..a6580b3 100644 --- a/tests/lifecycle_session.rs +++ b/tests/lifecycle_session.rs @@ -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"); -} \ No newline at end of file +} + +#[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"); +} diff --git a/tests/sync_repo.rs b/tests/sync_repo.rs index 787a12a..20730f6 100644 --- a/tests/sync_repo.rs +++ b/tests/sync_repo.rs @@ -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() {