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