Microservice to bring 2FA to self hosted PDSes
1use crate::AppState; 2use crate::helpers::TokenCheckError::InvalidToken; 3use anyhow::anyhow; 4use axum::body::{Body, to_bytes}; 5use axum::extract::Request; 6use axum::http::header::CONTENT_TYPE; 7use axum::http::{HeaderMap, StatusCode, Uri}; 8use axum::response::{IntoResponse, Response}; 9use axum_template::TemplateEngine; 10use chrono::Utc; 11use lettre::message::{MultiPart, SinglePart, header}; 12use lettre::{AsyncTransport, Message}; 13use rand::Rng; 14use serde::de::DeserializeOwned; 15use serde_json::{Map, Value}; 16use sha2::{Digest, Sha256}; 17use sqlx::SqlitePool; 18use tracing::{error, log}; 19 20///Used to generate the email 2fa code 21const UPPERCASE_BASE32_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; 22 23/// The result of a proxied call that attempts to parse JSON. 24pub enum ProxiedResult<T> { 25 /// Successfully parsed JSON body along with original response headers. 26 Parsed { value: T, _headers: HeaderMap }, 27 /// Could not or should not parse: return the original (or rebuilt) response as-is. 28 Passthrough(Response<Body>), 29} 30 31/// Proxy the incoming request to the PDS base URL plus the provided path and attempt to parse 32/// the successful response body as JSON into `T`. 33/// 34pub async fn proxy_get_json<T>( 35 state: &AppState, 36 mut req: Request, 37 path: &str, 38) -> Result<ProxiedResult<T>, StatusCode> 39where 40 T: DeserializeOwned, 41{ 42 let uri = format!("{}{}", state.pds_base_url, path); 43 *req.uri_mut() = Uri::try_from(uri).map_err(|_| StatusCode::BAD_REQUEST)?; 44 45 let result = state 46 .reverse_proxy_client 47 .request(req) 48 .await 49 .map_err(|_| StatusCode::BAD_REQUEST)? 50 .into_response(); 51 52 if result.status() != StatusCode::OK { 53 return Ok(ProxiedResult::Passthrough(result)); 54 } 55 56 let response_headers = result.headers().clone(); 57 let body = result.into_body(); 58 let body_bytes = to_bytes(body, usize::MAX) 59 .await 60 .map_err(|_| StatusCode::BAD_REQUEST)?; 61 62 match serde_json::from_slice::<T>(&body_bytes) { 63 Ok(value) => Ok(ProxiedResult::Parsed { 64 value, 65 _headers: response_headers, 66 }), 67 Err(err) => { 68 error!(%err, "failed to parse proxied JSON response; returning original body"); 69 let mut builder = Response::builder().status(StatusCode::OK); 70 if let Some(headers) = builder.headers_mut() { 71 *headers = response_headers; 72 } 73 let resp = builder 74 .body(Body::from(body_bytes)) 75 .map_err(|_| StatusCode::BAD_REQUEST)?; 76 Ok(ProxiedResult::Passthrough(resp)) 77 } 78 } 79} 80 81/// Build a JSON error response with the required Content-Type header 82/// Content-Type: application/json;charset=utf-8 83/// Body shape: { "error": string, "message": string } 84pub fn json_error_response( 85 status: StatusCode, 86 error: impl Into<String>, 87 message: impl Into<String>, 88) -> Result<Response<Body>, StatusCode> { 89 let body_str = match serde_json::to_string(&serde_json::json!({ 90 "error": error.into(), 91 "message": message.into(), 92 })) { 93 Ok(s) => s, 94 Err(_) => return Err(StatusCode::BAD_REQUEST), 95 }; 96 97 Response::builder() 98 .status(status) 99 .header(CONTENT_TYPE, "application/json;charset=utf-8") 100 .body(Body::from(body_str)) 101 .map_err(|_| StatusCode::BAD_REQUEST) 102} 103 104/// Build a JSON error response with the required Content-Type header 105/// Content-Type: application/json (oauth endpoint does not like utf ending) 106/// Body shape: { "error": string, "error_description": string } 107pub fn oauth_json_error_response( 108 status: StatusCode, 109 error: impl Into<String>, 110 message: impl Into<String>, 111) -> Result<Response<Body>, StatusCode> { 112 let body_str = match serde_json::to_string(&serde_json::json!({ 113 "error": error.into(), 114 "error_description": message.into(), 115 })) { 116 Ok(s) => s, 117 Err(_) => return Err(StatusCode::BAD_REQUEST), 118 }; 119 120 Response::builder() 121 .status(status) 122 .header(CONTENT_TYPE, "application/json") 123 .body(Body::from(body_str)) 124 .map_err(|_| StatusCode::BAD_REQUEST) 125} 126 127/// Creates a random token of 10 characters for email 2FA 128pub fn get_random_token() -> String { 129 let mut rng = rand::rng(); 130 131 let mut full_code = String::with_capacity(10); 132 for _ in 0..10 { 133 let idx = rng.random_range(0..UPPERCASE_BASE32_CHARS.len()); 134 full_code.push(UPPERCASE_BASE32_CHARS[idx] as char); 135 } 136 137 //The PDS implementation creates in lowercase, then converts to uppercase. 138 //Just going a head and doing uppercase here. 139 let slice_one = &full_code[0..5].to_ascii_uppercase(); 140 let slice_two = &full_code[5..10].to_ascii_uppercase(); 141 format!("{slice_one}-{slice_two}") 142} 143 144pub enum TokenCheckError { 145 InvalidToken, 146 ExpiredToken, 147} 148 149pub enum AuthResult { 150 WrongIdentityOrPassword, 151 /// The string here is the email address to create a hint for oauth 152 TwoFactorRequired(String), 153 /// User does not have 2FA enabled, or using an app password, or passes it 154 ProxyThrough, 155 TokenCheckFailed(TokenCheckError), 156} 157 158pub enum IdentifierType { 159 Email, 160 Did, 161 Handle, 162} 163 164impl IdentifierType { 165 fn what_is_it(identifier: String) -> Self { 166 if identifier.contains("@") { 167 IdentifierType::Email 168 } else if identifier.contains("did:") { 169 IdentifierType::Did 170 } else { 171 IdentifierType::Handle 172 } 173 } 174} 175 176/// Creates a hex string from the password and salt to find app passwords 177fn scrypt_hex(password: &str, salt: &str) -> anyhow::Result<String> { 178 let params = scrypt::Params::new(14, 8, 1, 64)?; 179 let mut derived = [0u8; 64]; 180 scrypt::scrypt(password.as_bytes(), salt.as_bytes(), &params, &mut derived)?; 181 Ok(hex::encode(derived)) 182} 183 184/// Hashes the app password. did is used as the salt. 185pub fn hash_app_password(did: &str, password: &str) -> anyhow::Result<String> { 186 let mut hasher = Sha256::new(); 187 hasher.update(did.as_bytes()); 188 let sha = hasher.finalize(); 189 let salt = hex::encode(&sha[..16]); 190 let hash_hex = scrypt_hex(password, &salt)?; 191 Ok(format!("{salt}:{hash_hex}")) 192} 193 194async fn verify_password(password: &str, password_scrypt: &str) -> anyhow::Result<bool> { 195 // Expected format: "salt:hash" where hash is hex of scrypt(password, salt, 64 bytes) 196 let mut parts = password_scrypt.splitn(2, ':'); 197 let salt = match parts.next() { 198 Some(s) if !s.is_empty() => s, 199 _ => return Ok(false), 200 }; 201 let stored_hash_hex = match parts.next() { 202 Some(h) if !h.is_empty() => h, 203 _ => return Ok(false), 204 }; 205 206 // Derive using the shared helper and compare 207 let derived_hex = match scrypt_hex(password, salt) { 208 Ok(h) => h, 209 Err(_) => return Ok(false), 210 }; 211 212 Ok(derived_hex.as_str() == stored_hash_hex) 213} 214 215/// Handles the auth checks along with sending a 2fa email 216pub async fn preauth_check( 217 state: &AppState, 218 identifier: &str, 219 password: &str, 220 two_factor_code: Option<String>, 221 oauth: bool, 222) -> anyhow::Result<AuthResult> { 223 // Determine identifier type 224 let id_type = IdentifierType::what_is_it(identifier.to_string()); 225 226 // Query account DB for did and passwordScrypt based on identifier type 227 let account_row: Option<(String, String, String, String)> = match id_type { 228 IdentifierType::Email => { 229 sqlx::query_as::<_, (String, String, String, String)>( 230 "SELECT account.did, account.passwordScrypt, account.email, actor.handle 231 FROM actor 232 LEFT JOIN account ON actor.did = account.did 233 where account.email = ? LIMIT 1", 234 ) 235 .bind(identifier) 236 .fetch_optional(&state.account_pool) 237 .await? 238 } 239 IdentifierType::Handle => { 240 sqlx::query_as::<_, (String, String, String, String)>( 241 "SELECT account.did, account.passwordScrypt, account.email, actor.handle 242 FROM actor 243 LEFT JOIN account ON actor.did = account.did 244 where actor.handle = ? LIMIT 1", 245 ) 246 .bind(identifier) 247 .fetch_optional(&state.account_pool) 248 .await? 249 } 250 IdentifierType::Did => { 251 sqlx::query_as::<_, (String, String, String, String)>( 252 "SELECT account.did, account.passwordScrypt, account.email, actor.handle 253 FROM actor 254 LEFT JOIN account ON actor.did = account.did 255 where account.did = ? LIMIT 1", 256 ) 257 .bind(identifier) 258 .fetch_optional(&state.account_pool) 259 .await? 260 } 261 }; 262 263 if let Some((did, password_scrypt, email, handle)) = account_row { 264 // Verify password before proceeding to 2FA email step 265 let verified = verify_password(password, &password_scrypt).await?; 266 if !verified { 267 if oauth { 268 //OAuth does not allow app password logins so just go ahead and send it along it's way 269 return Ok(AuthResult::WrongIdentityOrPassword); 270 } 271 //Theres a chance it could be an app password so check that as well 272 return match verify_app_password(&state.account_pool, &did, password).await { 273 Ok(valid) => { 274 if valid { 275 //Was a valid app password up to the PDS now 276 Ok(AuthResult::ProxyThrough) 277 } else { 278 Ok(AuthResult::WrongIdentityOrPassword) 279 } 280 } 281 Err(err) => { 282 log::error!("Error checking the app password: {err}"); 283 Err(err) 284 } 285 }; 286 } 287 288 // Check two-factor requirement for this DID in the gatekeeper DB 289 let required_opt = sqlx::query_as::<_, (u8,)>( 290 "SELECT required FROM two_factor_accounts WHERE did = ? LIMIT 1", 291 ) 292 .bind(did.clone()) 293 .fetch_optional(&state.pds_gatekeeper_pool) 294 .await?; 295 296 let two_factor_required = match required_opt { 297 Some(row) => row.0 != 0, 298 None => false, 299 }; 300 301 if two_factor_required { 302 //Two factor is required and a taken was provided 303 if let Some(two_factor_code) = two_factor_code { 304 //if the two_factor_code is set need to see if we have a valid token 305 if !two_factor_code.is_empty() { 306 return match assert_valid_token( 307 &state.account_pool, 308 did.clone(), 309 two_factor_code, 310 ) 311 .await 312 { 313 Ok(_) => { 314 let result_of_cleanup = 315 delete_all_email_tokens(&state.account_pool, did.clone()).await; 316 if result_of_cleanup.is_err() { 317 log::error!( 318 "There was an error deleting the email tokens after login: {:?}", 319 result_of_cleanup.err() 320 ) 321 } 322 Ok(AuthResult::ProxyThrough) 323 } 324 Err(err) => Ok(AuthResult::TokenCheckFailed(err)), 325 }; 326 } 327 } 328 329 return match create_two_factor_token(&state.account_pool, did).await { 330 Ok(code) => { 331 let mut email_data = Map::new(); 332 email_data.insert("token".to_string(), Value::from(code.clone())); 333 email_data.insert("handle".to_string(), Value::from(handle.clone())); 334 let email_body = state 335 .template_engine 336 .render("two_factor_code.hbs", email_data)?; 337 338 let email_message = Message::builder() 339 //TODO prob get the proper type in the state 340 .from(state.mailer_from.parse()?) 341 .to(email.parse()?) 342 .subject("Sign in to Bluesky") 343 .multipart( 344 MultiPart::alternative() // This is composed of two parts. 345 .singlepart( 346 SinglePart::builder() 347 .header(header::ContentType::TEXT_PLAIN) 348 .body(format!("We received a sign-in request for the account @{handle}. Use the code: {code} to sign in. If this wasn't you, we recommend taking steps to protect your account by changing your password at https://bsky.app/settings.")), // Every message should have a plain text fallback. 349 ) 350 .singlepart( 351 SinglePart::builder() 352 .header(header::ContentType::TEXT_HTML) 353 .body(email_body), 354 ), 355 )?; 356 match state.mailer.send(email_message).await { 357 Ok(_) => Ok(AuthResult::TwoFactorRequired(mask_email(email))), 358 Err(err) => { 359 log::error!("Error sending the 2FA email: {err}"); 360 Err(anyhow!(err)) 361 } 362 } 363 } 364 Err(err) => { 365 log::error!("error on creating a 2fa token: {err}"); 366 Err(anyhow!(err)) 367 } 368 }; 369 } 370 } 371 372 // No local 2FA requirement (or account not found) 373 Ok(AuthResult::ProxyThrough) 374} 375 376pub async fn create_two_factor_token( 377 account_db: &SqlitePool, 378 did: String, 379) -> anyhow::Result<String> { 380 let purpose = "2fa_code"; 381 382 let token = get_random_token(); 383 let right_now = Utc::now(); 384 385 let res = sqlx::query( 386 "INSERT INTO email_token (purpose, did, token, requestedAt) 387 VALUES (?, ?, ?, ?) 388 ON CONFLICT(purpose, did) DO UPDATE SET 389 token=excluded.token, 390 requestedAt=excluded.requestedAt 391 WHERE did=excluded.did", 392 ) 393 .bind(purpose) 394 .bind(&did) 395 .bind(&token) 396 .bind(right_now) 397 .execute(account_db) 398 .await; 399 400 match res { 401 Ok(_) => Ok(token), 402 Err(err) => { 403 log::error!("Error creating a two factor token: {err}"); 404 Err(anyhow::anyhow!(err)) 405 } 406 } 407} 408 409pub async fn delete_all_email_tokens(account_db: &SqlitePool, did: String) -> anyhow::Result<()> { 410 sqlx::query("DELETE FROM email_token WHERE did = ?") 411 .bind(did) 412 .execute(account_db) 413 .await?; 414 Ok(()) 415} 416 417pub async fn assert_valid_token( 418 account_db: &SqlitePool, 419 did: String, 420 token: String, 421) -> Result<(), TokenCheckError> { 422 let token_upper = token.to_ascii_uppercase(); 423 let purpose = "2fa_code"; 424 425 let row: Option<(String,)> = sqlx::query_as( 426 "SELECT requestedAt FROM email_token WHERE purpose = ? AND did = ? AND token = ? LIMIT 1", 427 ) 428 .bind(purpose) 429 .bind(did) 430 .bind(token_upper) 431 .fetch_optional(account_db) 432 .await 433 .map_err(|err| { 434 log::error!("Error getting the 2fa token: {err}"); 435 InvalidToken 436 })?; 437 438 match row { 439 None => Err(InvalidToken), 440 Some(row) => { 441 // Token lives for 15 minutes 442 let expiration_ms = 15 * 60_000; 443 444 let requested_at_utc = match chrono::DateTime::parse_from_rfc3339(&row.0) { 445 Ok(dt) => dt.with_timezone(&Utc), 446 Err(_) => { 447 return Err(TokenCheckError::InvalidToken); 448 } 449 }; 450 451 let now = Utc::now(); 452 let age_ms = (now - requested_at_utc).num_milliseconds(); 453 let expired = age_ms > expiration_ms; 454 if expired { 455 return Err(TokenCheckError::ExpiredToken); 456 } 457 458 Ok(()) 459 } 460 } 461} 462 463/// We just need to confirm if it's there or not. Will let the PDS do the actual figuring of permissions 464pub async fn verify_app_password( 465 account_db: &SqlitePool, 466 did: &str, 467 password: &str, 468) -> anyhow::Result<bool> { 469 let password_scrypt = hash_app_password(did, password)?; 470 471 let row: Option<(i64,)> = sqlx::query_as( 472 "SELECT Count(*) FROM app_password WHERE did = ? AND passwordScrypt = ? LIMIT 1", 473 ) 474 .bind(did) 475 .bind(password_scrypt) 476 .fetch_optional(account_db) 477 .await?; 478 479 Ok(match row { 480 None => false, 481 Some((count,)) => count > 0, 482 }) 483} 484 485/// Mask an email address into a hint like "2***0@p***m". 486pub fn mask_email(email: String) -> String { 487 // Basic split on first '@' 488 let mut parts = email.splitn(2, '@'); 489 let local = match parts.next() { 490 Some(l) => l, 491 None => return email.to_string(), 492 }; 493 let domain_rest = match parts.next() { 494 Some(d) if !d.is_empty() => d, 495 _ => return email.to_string(), 496 }; 497 498 // Helper to mask a single label (keep first and last, middle becomes ***). 499 fn mask_label(s: &str) -> String { 500 let chars: Vec<char> = s.chars().collect(); 501 match chars.len() { 502 0 => String::new(), 503 1 => format!("{}***", chars[0]), 504 2 => format!("{}***{}", chars[0], chars[1]), 505 _ => format!("{}***{}", chars[0], chars[chars.len() - 1]), 506 } 507 } 508 509 // Mask local 510 let masked_local = mask_label(local); 511 512 // Mask first domain label only, keep the rest of the domain intact 513 let mut dom_parts = domain_rest.splitn(2, '.'); 514 let first_label = dom_parts.next().unwrap_or(""); 515 let rest = dom_parts.next(); 516 let masked_first = mask_label(first_label); 517 let masked_domain = if let Some(rest) = rest { 518 format!("{}.{rest}", masked_first) 519 } else { 520 masked_first 521 }; 522 523 format!("{masked_local}@{masked_domain}") 524}