Microservice to bring 2FA to self hosted PDSes
0

Configure Feed

Select the types of activity you want to include in your feed.

at main 20 kB View raw
1use crate::AppState; 2use crate::helpers::{ 3 AuthResult, ProxiedResult, TokenCheckError, VerifyServiceAuthError, json_error_response, 4 preauth_check, proxy_get_json, proxy_post, verify_gate_token, verify_service_auth, 5}; 6use crate::middleware::Did; 7use axum::body::{Body, to_bytes}; 8use axum::extract::State; 9use axum::http::{HeaderMap, StatusCode, header}; 10use axum::response::{IntoResponse, Response}; 11use axum::{Extension, Json, debug_handler, extract, extract::Request}; 12use chrono::{Duration, Utc}; 13use jacquard_common::types::did::Did as JacquardDid; 14use serde::{Deserialize, Serialize}; 15use serde_json; 16use tracing::log; 17 18#[derive(Serialize, Deserialize, Debug, Clone)] 19#[serde(rename_all = "camelCase")] 20enum AccountStatus { 21 Takendown, 22 Suspended, 23 Deactivated, 24} 25 26#[derive(Serialize, Deserialize, Debug, Clone)] 27#[serde(rename_all = "camelCase")] 28struct GetSessionResponse { 29 handle: String, 30 did: String, 31 #[serde(skip_serializing_if = "Option::is_none")] 32 email: Option<String>, 33 #[serde(skip_serializing_if = "Option::is_none")] 34 email_confirmed: Option<bool>, 35 #[serde(skip_serializing_if = "Option::is_none")] 36 email_auth_factor: Option<bool>, 37 #[serde(skip_serializing_if = "Option::is_none")] 38 did_doc: Option<String>, 39 #[serde(skip_serializing_if = "Option::is_none")] 40 active: Option<bool>, 41 #[serde(skip_serializing_if = "Option::is_none")] 42 status: Option<AccountStatus>, 43} 44 45#[derive(Serialize, Deserialize, Debug, Clone)] 46#[serde(rename_all = "camelCase")] 47pub struct UpdateEmailResponse { 48 email: String, 49 #[serde(skip_serializing_if = "Option::is_none")] 50 email_auth_factor: Option<bool>, 51 #[serde(skip_serializing_if = "Option::is_none")] 52 token: Option<String>, 53} 54 55#[allow(dead_code)] 56#[derive(Deserialize, Serialize)] 57#[serde(rename_all = "camelCase")] 58pub struct CreateSessionRequest { 59 identifier: String, 60 password: String, 61 #[serde(skip_serializing_if = "Option::is_none")] 62 auth_factor_token: Option<String>, 63 #[serde(skip_serializing_if = "Option::is_none")] 64 allow_takendown: Option<bool>, 65} 66 67#[derive(Deserialize, Serialize, Debug)] 68#[serde(rename_all = "camelCase")] 69pub struct CreateAccountRequest { 70 handle: String, 71 #[serde(skip_serializing_if = "Option::is_none")] 72 email: Option<String>, 73 #[serde(skip_serializing_if = "Option::is_none")] 74 password: Option<String>, 75 #[serde(skip_serializing_if = "Option::is_none")] 76 did: Option<String>, 77 #[serde(skip_serializing_if = "Option::is_none")] 78 invite_code: Option<String>, 79 #[serde(skip_serializing_if = "Option::is_none")] 80 verification_code: Option<String>, 81 #[serde(skip_serializing_if = "Option::is_none")] 82 plc_op: Option<serde_json::Value>, 83} 84 85#[derive(Deserialize, Serialize, Debug, Clone)] 86#[serde(rename_all = "camelCase")] 87pub struct DescribeServerContact { 88 #[serde(skip_serializing_if = "Option::is_none")] 89 email: Option<String>, 90} 91 92#[derive(Deserialize, Serialize, Debug, Clone)] 93#[serde(rename_all = "camelCase")] 94pub struct DescribeServerLinks { 95 #[serde(skip_serializing_if = "Option::is_none")] 96 privacy_policy: Option<String>, 97 #[serde(skip_serializing_if = "Option::is_none")] 98 terms_of_service: Option<String>, 99} 100 101#[derive(Deserialize, Serialize, Debug, Clone)] 102#[serde(rename_all = "camelCase")] 103pub struct DescribeServerResponse { 104 #[serde(skip_serializing_if = "Option::is_none")] 105 invite_code_required: Option<bool>, 106 #[serde(skip_serializing_if = "Option::is_none")] 107 phone_verification_required: Option<bool>, 108 #[serde(skip_serializing_if = "Option::is_none")] 109 available_user_domains: Option<Vec<String>>, 110 #[serde(skip_serializing_if = "Option::is_none")] 111 links: Option<DescribeServerLinks>, 112 #[serde(skip_serializing_if = "Option::is_none")] 113 contact: Option<DescribeServerContact>, 114 #[serde(skip_serializing_if = "Option::is_none")] 115 did: Option<String>, 116} 117 118pub async fn create_session( 119 State(state): State<AppState>, 120 headers: HeaderMap, 121 Json(payload): extract::Json<CreateSessionRequest>, 122) -> Result<Response<Body>, StatusCode> { 123 let identifier = payload.identifier.clone(); 124 let password = payload.password.clone(); 125 let auth_factor_token = payload.auth_factor_token.clone(); 126 127 // Run the shared pre-auth logic to validate and check 2FA requirement 128 match preauth_check(&state, &identifier, &password, auth_factor_token, false).await { 129 Ok(result) => match result { 130 AuthResult::WrongIdentityOrPassword => json_error_response( 131 StatusCode::UNAUTHORIZED, 132 "AuthenticationRequired", 133 "Invalid identifier or password", 134 ), 135 AuthResult::TwoFactorRequired(_) => { 136 // Email sending step can be handled here if needed in the future. 137 json_error_response( 138 StatusCode::UNAUTHORIZED, 139 "AuthFactorTokenRequired", 140 "A sign in code has been sent to your email address", 141 ) 142 } 143 AuthResult::ProxyThrough => { 144 //No 2FA or already passed 145 let payload_bytes = 146 serde_json::to_vec(&payload).map_err(|_| StatusCode::BAD_REQUEST)?; 147 proxy_post(&state, &headers, "/xrpc/com.atproto.server.createSession", payload_bytes).await 148 } 149 AuthResult::TokenCheckFailed(err) => match err { 150 TokenCheckError::InvalidToken => { 151 json_error_response(StatusCode::BAD_REQUEST, "InvalidToken", "Token is invalid") 152 } 153 TokenCheckError::ExpiredToken => { 154 json_error_response(StatusCode::BAD_REQUEST, "ExpiredToken", "Token is expired") 155 } 156 }, 157 }, 158 Err(err) => { 159 log::error!( 160 "Error during pre-auth check. This happens on the create_session endpoint when trying to decide if the user has access:\n {err}" 161 ); 162 json_error_response( 163 StatusCode::INTERNAL_SERVER_ERROR, 164 "InternalServerError", 165 "This error was not generated by the PDS, but PDS Gatekeeper. Please contact your PDS administrator for help and for them to review the server logs.", 166 ) 167 } 168 } 169} 170 171#[debug_handler] 172pub async fn update_email( 173 State(state): State<AppState>, 174 Extension(did): Extension<Did>, 175 headers: HeaderMap, 176 Json(payload): extract::Json<UpdateEmailResponse>, 177) -> Result<Response<Body>, StatusCode> { 178 //If email auth is not set at all it is a update email address 179 let email_auth_not_set = payload.email_auth_factor.is_none(); 180 //If email auth is set it is to either turn on or off 2fa 181 let email_auth_update = payload.email_auth_factor.unwrap_or(false); 182 183 //This means the middleware successfully extracted a did from the request, if not it just needs to be forward to the PDS 184 //This is also empty if it is an oauth request, which is not supported by gatekeeper turning on 2fa since the dpop stuff needs to be implemented 185 let did_is_not_empty = did.0.is_some(); 186 187 if did_is_not_empty { 188 // Email update asked for 189 if email_auth_update { 190 let email = payload.email.clone(); 191 let email_confirmed = match sqlx::query_as::<_, (String,)>( 192 "SELECT did FROM account WHERE emailConfirmedAt IS NOT NULL AND email = ?", 193 ) 194 .bind(&email) 195 .fetch_optional(&state.account_pool) 196 .await 197 { 198 Ok(row) => row, 199 Err(err) => { 200 log::error!("Error checking if email is confirmed: {err}"); 201 return Err(StatusCode::BAD_REQUEST); 202 } 203 }; 204 205 //Since the email is already confirmed we can enable 2fa 206 return match email_confirmed { 207 None => Err(StatusCode::BAD_REQUEST), 208 Some(did_row) => { 209 let _ = sqlx::query( 210 "INSERT INTO two_factor_accounts (did, required) VALUES (?, 1) ON CONFLICT(did) DO UPDATE SET required = 1", 211 ) 212 .bind(&did_row.0) 213 .execute(&state.pds_gatekeeper_pool) 214 .await 215 .map_err(|err| { 216 log::error!("Error enabling 2FA: {err}"); 217 StatusCode::BAD_REQUEST 218 })?; 219 220 Ok(StatusCode::OK.into_response()) 221 } 222 }; 223 } 224 225 // User wants auth turned off 226 if !email_auth_update && !email_auth_not_set { 227 //User wants auth turned off and has a token 228 if let Some(token) = &payload.token { 229 let token_found = match sqlx::query_as::<_, (String,)>( 230 "SELECT token FROM email_token WHERE token = ? AND did = ? AND purpose = 'update_email'", 231 ) 232 .bind(token) 233 .bind(&did.0) 234 .fetch_optional(&state.account_pool) 235 .await{ 236 Ok(token) => token, 237 Err(err) => { 238 log::error!("Error checking if token is valid: {err}"); 239 return Err(StatusCode::BAD_REQUEST); 240 } 241 }; 242 243 return if token_found.is_some() { 244 //TODO I think there may be a bug here and need to do some retry logic 245 // First try was erroring, seconds was allowing 246 match sqlx::query( 247 "INSERT INTO two_factor_accounts (did, required) VALUES (?, 0) ON CONFLICT(did) DO UPDATE SET required = 0", 248 ) 249 .bind(&did.0) 250 .execute(&state.pds_gatekeeper_pool) 251 .await { 252 Ok(_) => {} 253 Err(err) => { 254 log::error!("Error updating email auth: {err}"); 255 return Err(StatusCode::BAD_REQUEST); 256 } 257 } 258 259 Ok(StatusCode::OK.into_response()) 260 } else { 261 Err(StatusCode::BAD_REQUEST) 262 }; 263 } 264 } 265 } 266 // Updating the actual email address by sending it on to the PDS 267 let payload_bytes = serde_json::to_vec(&payload).map_err(|_| StatusCode::BAD_REQUEST)?; 268 proxy_post(&state, &headers, "/xrpc/com.atproto.server.updateEmail", payload_bytes).await 269} 270 271pub async fn get_session( 272 State(state): State<AppState>, 273 req: Request, 274) -> Result<Response<Body>, StatusCode> { 275 match proxy_get_json::<GetSessionResponse>(&state, req, "/xrpc/com.atproto.server.getSession") 276 .await? 277 { 278 ProxiedResult::Parsed { 279 value: mut session, .. 280 } => { 281 let did = session.did.clone(); 282 let required_opt = sqlx::query_as::<_, (u8,)>( 283 "SELECT required FROM two_factor_accounts WHERE did = ? LIMIT 1", 284 ) 285 .bind(&did) 286 .fetch_optional(&state.pds_gatekeeper_pool) 287 .await 288 .map_err(|_| StatusCode::BAD_REQUEST)?; 289 290 let email_auth_factor = match required_opt { 291 Some(row) => row.0 != 0, 292 None => false, 293 }; 294 295 session.email_auth_factor = Some(email_auth_factor); 296 Ok(Json(session).into_response()) 297 } 298 ProxiedResult::Passthrough(resp) => Ok(resp), 299 } 300} 301 302pub async fn describe_server( 303 State(state): State<AppState>, 304 req: Request, 305) -> Result<Response<Body>, StatusCode> { 306 match proxy_get_json::<DescribeServerResponse>( 307 &state, 308 req, 309 "/xrpc/com.atproto.server.describeServer", 310 ) 311 .await? 312 { 313 ProxiedResult::Parsed { 314 value: mut server_info, 315 .. 316 } => { 317 //This signifies the server is configured for captcha verification 318 server_info.phone_verification_required = Some(state.app_config.use_captcha); 319 Ok(Json(server_info).into_response()) 320 } 321 ProxiedResult::Passthrough(resp) => Ok(resp), 322 } 323} 324 325/// Verify a gate code matches the handle and is not expired 326async fn verify_gate_code( 327 state: &AppState, 328 code: &str, 329 handle: &str, 330) -> Result<bool, anyhow::Error> { 331 // First, decrypt and verify the JWE token 332 let payload = match verify_gate_token(code, &state.app_config.gate_jwe_key) { 333 Ok(p) => p, 334 Err(e) => { 335 log::warn!("Failed to decrypt gate token: {}", e); 336 return Ok(false); 337 } 338 }; 339 340 // Verify the handle matches 341 if payload.handle != handle { 342 log::warn!( 343 "Gate code handle mismatch: expected {}, got {}", 344 handle, 345 payload.handle 346 ); 347 return Ok(false); 348 } 349 350 let created_at = chrono::DateTime::parse_from_rfc3339(&payload.created_at) 351 .map_err(|e| anyhow::anyhow!("Failed to parse created_at from token: {}", e))? 352 .with_timezone(&Utc); 353 354 let now = Utc::now(); 355 let age = now - created_at; 356 357 // Check if the token is expired (5 minutes) 358 if age > Duration::minutes(5) { 359 log::warn!("Gate code expired for handle {}", handle); 360 return Ok(false); 361 } 362 363 // Verify the token exists in the database (to prevent reuse) 364 let row: Option<(String,)> = 365 sqlx::query_as("SELECT code FROM gate_codes WHERE code = ? and handle = ? LIMIT 1") 366 .bind(code) 367 .bind(handle) 368 .fetch_optional(&state.pds_gatekeeper_pool) 369 .await?; 370 371 if row.is_none() { 372 log::warn!("Gate code not found in database or already used"); 373 return Ok(false); 374 } 375 376 // Token is valid, delete it so it can't be reused 377 //TODO probably also delete expired codes? Will need to do that at some point probably altho the where is on code and handle 378 379 sqlx::query("DELETE FROM gate_codes WHERE code = ?") 380 .bind(code) 381 .execute(&state.pds_gatekeeper_pool) 382 .await?; 383 384 Ok(true) 385} 386 387pub async fn create_account( 388 State(state): State<AppState>, 389 req: Request, 390) -> Result<Response<Body>, StatusCode> { 391 let headers = req.headers().clone(); 392 let body_bytes = to_bytes(req.into_body(), usize::MAX) 393 .await 394 .map_err(|_| StatusCode::BAD_REQUEST)?; 395 396 // Parse the body to check for verification code 397 let account_request: CreateAccountRequest = 398 serde_json::from_slice(&body_bytes).map_err(|e| { 399 log::error!("Failed to parse create account request: {}", e); 400 StatusCode::BAD_REQUEST 401 })?; 402 403 // Check for service auth (migrations) if configured 404 if state.app_config.allow_only_migrations { 405 // Expect Authorization: Bearer <jwt> 406 let auth_header = headers 407 .get(header::AUTHORIZATION) 408 .and_then(|v| v.to_str().ok()) 409 .map(str::to_string); 410 411 let Some(value) = auth_header else { 412 log::error!("No Authorization header found in the request"); 413 return json_error_response( 414 StatusCode::UNAUTHORIZED, 415 "InvalidAuth", 416 "This PDS is configured to only allow accounts created by migrations via this endpoint.", 417 ); 418 }; 419 420 // Ensure Bearer prefix 421 let token = value.strip_prefix("Bearer ").unwrap_or("").trim(); 422 if token.is_empty() { 423 log::error!("No Service Auth token found in the Authorization header"); 424 return json_error_response( 425 StatusCode::UNAUTHORIZED, 426 "InvalidAuth", 427 "This PDS is configured to only allow accounts created by migrations via this endpoint.", 428 ); 429 } 430 431 // Ensure a non-empty DID was provided when migrations are enabled 432 let requested_did_str = match account_request.did.as_deref() { 433 Some(s) if !s.trim().is_empty() => s, 434 _ => { 435 return json_error_response( 436 StatusCode::BAD_REQUEST, 437 "InvalidRequest", 438 "The 'did' field is required when migrations are enforced.", 439 ); 440 } 441 }; 442 443 // Parse the DID into the expected type for verification 444 let requested_did: JacquardDid<'static> = match requested_did_str.parse() { 445 Ok(d) => d, 446 Err(e) => { 447 log::error!( 448 "Invalid DID format provided in createAccount: {} | error: {}", 449 requested_did_str, 450 e 451 ); 452 return json_error_response( 453 StatusCode::BAD_REQUEST, 454 "InvalidRequest", 455 "The 'did' field is not a valid DID.", 456 ); 457 } 458 }; 459 460 let nsid = "com.atproto.server.createAccount".parse().unwrap(); 461 match verify_service_auth( 462 token, 463 &nsid, 464 state.resolver.clone(), 465 &state.app_config.pds_service_did, 466 &requested_did, 467 ) 468 .await 469 { 470 //Just do nothing if it passes so it continues. 471 Ok(_) => {} 472 Err(err) => match err { 473 VerifyServiceAuthError::AuthFailed => { 474 return json_error_response( 475 StatusCode::UNAUTHORIZED, 476 "InvalidAuth", 477 "This PDS is configured to only allow accounts created by migrations via this endpoint.", 478 ); 479 } 480 VerifyServiceAuthError::Error(err) => { 481 log::error!("Error verifying service auth token: {err}"); 482 return json_error_response( 483 StatusCode::BAD_REQUEST, 484 "InvalidRequest", 485 "There has been an error, please contact your PDS administrator for help and for them to review the server logs.", 486 ); 487 } 488 }, 489 } 490 } 491 492 // Check for captcha verification if configured 493 if state.app_config.use_captcha { 494 if let Some(ref verification_code) = account_request.verification_code { 495 match verify_gate_code(&state, verification_code, &account_request.handle).await { 496 //TODO has a few errors to support 497 498 //expired token 499 // { 500 // "error": "ExpiredToken", 501 // "message": "Token has expired" 502 // } 503 504 //TODO ALSO add rate limits on the /gate endpoints so they can't be abused 505 Ok(true) => { 506 log::info!("Gate code verified for handle: {}", account_request.handle); 507 } 508 Ok(false) => { 509 log::warn!( 510 "Invalid or expired gate code for handle: {}", 511 account_request.handle 512 ); 513 return json_error_response( 514 StatusCode::BAD_REQUEST, 515 "InvalidToken", 516 "Token could not be verified", 517 ); 518 } 519 Err(e) => { 520 log::error!("Error verifying gate code: {}", e); 521 return json_error_response( 522 StatusCode::INTERNAL_SERVER_ERROR, 523 "InvalidToken", 524 "Token could not be verified", 525 ); 526 } 527 } 528 } else { 529 // No verification code provided but captcha is required 530 log::warn!( 531 "No verification code provided for account creation: {}", 532 account_request.handle 533 ); 534 return json_error_response( 535 StatusCode::BAD_REQUEST, 536 "InvalidRequest", 537 "Verification is now required on this server.", 538 ); 539 } 540 } 541 542 // Rebuild the request with the same body and headers 543 proxy_post(&state, &headers, "/xrpc/com.atproto.server.createAccount", body_bytes.to_vec()).await 544}