Microservice to bring 2FA to self hosted PDSes

token create and all that

+7
Cargo.lock
··· 287 287 dependencies = [ 288 288 "android-tzdata", 289 289 "iana-time-zone", 290 + "js-sys", 290 291 "num-traits", 292 + "wasm-bindgen", 291 293 "windows-link", 292 294 ] 293 295 ··· 1655 1657 "anyhow", 1656 1658 "axum", 1657 1659 "axum-template", 1660 + "chrono", 1658 1661 "dotenvy", 1659 1662 "handlebars", 1660 1663 "hex", ··· 2395 2398 dependencies = [ 2396 2399 "base64", 2397 2400 "bytes", 2401 + "chrono", 2398 2402 "crc", 2399 2403 "crossbeam-queue", 2400 2404 "either", ··· 2472 2476 "bitflags", 2473 2477 "byteorder", 2474 2478 "bytes", 2479 + "chrono", 2475 2480 "crc", 2476 2481 "digest", 2477 2482 "dotenvy", ··· 2513 2518 "base64", 2514 2519 "bitflags", 2515 2520 "byteorder", 2521 + "chrono", 2516 2522 "crc", 2517 2523 "dotenvy", 2518 2524 "etcetera", ··· 2547 2553 checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" 2548 2554 dependencies = [ 2549 2555 "atoi", 2556 + "chrono", 2550 2557 "flume", 2551 2558 "futures-channel", 2552 2559 "futures-core",
+2 -1
Cargo.toml
··· 6 6 [dependencies] 7 7 axum = { version = "0.8.4", features = ["macros", "json"] } 8 8 tokio = { version = "1.47.1", features = ["rt-multi-thread", "macros", "signal"] } 9 - sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "sqlite", "migrate"] } 9 + sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "sqlite", "migrate", "chrono"] } 10 10 dotenvy = "0.15.7" 11 11 serde = { version = "1.0", features = ["derive"] } 12 12 serde_json = "1.0" ··· 24 24 axum-template = { version = "3.0.0", features = ["handlebars"] } 25 25 rand = "0.9.2" 26 26 anyhow = "1.0.99" 27 + chrono = "0.4.41"
+17 -166
src/xrpc/com_atproto_server.rs
··· 1 1 use crate::AppState; 2 2 use crate::middleware::Did; 3 - use crate::xrpc::helpers::{ProxiedResult, json_error_response, proxy_get_json}; 3 + use crate::xrpc::helpers::{ 4 + AuthResult, ProxiedResult, TokenCheckError, json_error_response, preauth_check, proxy_get_json, 5 + }; 4 6 use axum::body::Body; 5 7 use axum::extract::State; 6 8 use axum::http::{HeaderMap, StatusCode}; ··· 58 60 pub struct CreateSessionRequest { 59 61 identifier: String, 60 62 password: String, 61 - auth_factor_token: String, 62 - allow_takendown: bool, 63 - } 64 - 65 - pub enum AuthResult { 66 - WrongIdentityOrPassword, 67 - TwoFactorRequired, 68 - TwoFactorFailed, 69 - /// User does not have 2FA enabled, or passes it 70 - ProxyThrough, 71 - } 72 - 73 - pub enum IdentifierType { 74 - Email, 75 - DID, 76 - Handle, 77 - } 78 - 79 - impl IdentifierType { 80 - fn what_is_it(identifier: String) -> Self { 81 - if identifier.contains("@") { 82 - IdentifierType::Email 83 - } else if identifier.contains("did:") { 84 - IdentifierType::DID 85 - } else { 86 - IdentifierType::Handle 87 - } 88 - } 89 - } 90 - 91 - async fn verify_password(password: &str, password_scrypt: &str) -> Result<bool, StatusCode> { 92 - // Expected format: "salt:hash" where hash is hex of scrypt(password, salt, 64 bytes) 93 - let mut parts = password_scrypt.splitn(2, ':'); 94 - let salt = match parts.next() { 95 - Some(s) if !s.is_empty() => s, 96 - _ => return Ok(false), 97 - }; 98 - let stored_hash_hex = match parts.next() { 99 - Some(h) if !h.is_empty() => h, 100 - _ => return Ok(false), 101 - }; 102 - 103 - //Sets up scrypt to mimic node's scrypt 104 - let params = match scrypt::Params::new(14, 8, 1, 64) { 105 - Ok(p) => p, 106 - Err(_) => return Ok(false), 107 - }; 108 - let mut derived = [0u8; 64]; 109 - if scrypt::scrypt(password.as_bytes(), salt.as_bytes(), &params, &mut derived).is_err() { 110 - return Ok(false); 111 - } 112 - 113 - let stored_bytes = match hex::decode(stored_hash_hex) { 114 - Ok(b) => b, 115 - Err(e) => { 116 - log::error!("Error decoding stored hash: {}", e); 117 - return Ok(false); 118 - } 119 - }; 120 - 121 - Ok(derived.as_slice() == stored_bytes.as_slice()) 122 - } 123 - 124 - async fn preauth_check( 125 - state: &AppState, 126 - identifier: &str, 127 - password: &str, 128 - ) -> Result<AuthResult, StatusCode> { 129 - // Determine identifier type 130 - let id_type = IdentifierType::what_is_it(identifier.to_string()); 131 - 132 - // Query account DB for did and passwordScrypt based on identifier type 133 - let account_row: Option<(String, String, String)> = match id_type { 134 - IdentifierType::Email => sqlx::query_as::<_, (String, String, String)>( 135 - "SELECT did, passwordScrypt, account.email FROM account WHERE email = ? LIMIT 1", 136 - ) 137 - .bind(identifier) 138 - .fetch_optional(&state.account_pool) 139 - .await 140 - .map_err(|_| StatusCode::BAD_REQUEST)?, 141 - IdentifierType::Handle => sqlx::query_as::<_, (String, String, String)>( 142 - "SELECT account.did, account.passwordScrypt, account.email 143 - FROM actor 144 - LEFT JOIN account ON actor.did = account.did 145 - where actor.handle =? LIMIT 1", 146 - ) 147 - .bind(identifier) 148 - .fetch_optional(&state.account_pool) 149 - .await 150 - .map_err(|_| StatusCode::BAD_REQUEST)?, 151 - IdentifierType::DID => sqlx::query_as::<_, (String, String, String)>( 152 - "SELECT did, passwordScrypt, account.email FROM account WHERE did = ? LIMIT 1", 153 - ) 154 - .bind(identifier) 155 - .fetch_optional(&state.account_pool) 156 - .await 157 - .map_err(|_| StatusCode::BAD_REQUEST)?, 158 - }; 159 - 160 - if let Some((did, password_scrypt, email)) = account_row { 161 - // Check two-factor requirement for this DID in the gatekeeper DB 162 - let required_opt = sqlx::query_as::<_, (u8,)>( 163 - "SELECT required FROM two_factor_accounts WHERE did = ? LIMIT 1", 164 - ) 165 - .bind(&did) 166 - .fetch_optional(&state.pds_gatekeeper_pool) 167 - .await 168 - .map_err(|_| StatusCode::BAD_REQUEST)?; 169 - 170 - let two_factor_required = match required_opt { 171 - Some(row) => row.0 != 0, 172 - None => false, 173 - }; 174 - 175 - if two_factor_required { 176 - // Verify password before proceeding to 2FA email step 177 - let verified = verify_password(password, &password_scrypt).await?; 178 - if !verified { 179 - return Ok(AuthResult::WrongIdentityOrPassword); 180 - } 181 - let mut email_data = Map::new(); 182 - //TODO these need real values 183 - let token = "test".to_string(); 184 - let handle = "baileytownsend.dev".to_string(); 185 - email_data.insert("token".to_string(), Value::from(token.clone())); 186 - email_data.insert("handle".to_string(), Value::from(handle.clone())); 187 - //TODO bad unwrap 188 - let email_body = state 189 - .template_engine 190 - .render("two_factor_code.hbs", email_data) 191 - .unwrap(); 192 - 193 - let email = Message::builder() 194 - //TODO prob get the proper type in the state 195 - .from(state.mailer_from.parse().unwrap()) 196 - .to(email.parse().unwrap()) 197 - .subject("Sign in to Bluesky") 198 - .multipart( 199 - MultiPart::alternative() // This is composed of two parts. 200 - .singlepart( 201 - SinglePart::builder() 202 - .header(header::ContentType::TEXT_PLAIN) 203 - .body(format!("We received a sign-in request for the account @{}. Use the 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.", handle, token)), // Every message should have a plain text fallback. 204 - ) 205 - .singlepart( 206 - SinglePart::builder() 207 - .header(header::ContentType::TEXT_HTML) 208 - .body(email_body), 209 - ), 210 - ) 211 - //TODO bad 212 - .unwrap(); 213 - return match state.mailer.send(email).await { 214 - Ok(_) => Ok(AuthResult::TwoFactorRequired), 215 - Err(err) => { 216 - log::error!("Error sending the 2FA email: {}", err); 217 - Err(StatusCode::BAD_REQUEST) 218 - } 219 - }; 220 - } 221 - } 222 - 223 - // No local 2FA requirement (or account not found) 224 - Ok(AuthResult::ProxyThrough) 63 + auth_factor_token: Option<String>, 64 + allow_takendown: Option<bool>, 225 65 } 226 66 227 67 pub async fn create_session( ··· 231 71 ) -> Result<Response<Body>, StatusCode> { 232 72 let identifier = payload.identifier.clone(); 233 73 let password = payload.password.clone(); 74 + let auth_factor_token = payload.auth_factor_token.clone(); 234 75 235 76 // Run the shared pre-auth logic to validate and check 2FA requirement 236 - match preauth_check(&state, &identifier, &password).await? { 77 + match preauth_check(&state, &identifier, &password, auth_factor_token).await? { 237 78 AuthResult::WrongIdentityOrPassword => json_error_response( 238 79 StatusCode::UNAUTHORIZED, 239 80 "AuthenticationRequired", ··· 278 119 279 120 Ok(proxied) 280 121 } 122 + AuthResult::TokenCheckFailed(err) => match err { 123 + TokenCheckError::InvalidToken => json_error_response( 124 + StatusCode::BAD_REQUEST, 125 + "InvalidToken", 126 + "Hey this token is invalid and this is a custom message to show it's bot a normal PDS", 127 + ), 128 + TokenCheckError::ExpiredToken => { 129 + json_error_response(StatusCode::BAD_REQUEST, "ExpiredToken", "Token is expired") 130 + } 131 + }, 281 132 } 282 133 } 283 134
+293 -3
src/xrpc/helpers.rs
··· 1 1 use crate::AppState; 2 + use crate::xrpc::helpers::TokenCheckError::InvalidToken; 2 3 use axum::body::{Body, to_bytes}; 3 4 use axum::extract::Request; 4 5 use axum::http::header::CONTENT_TYPE; 5 6 use axum::http::{HeaderMap, Method, StatusCode, Uri}; 6 7 use axum::response::{IntoResponse, Response}; 8 + use axum_template::TemplateEngine; 9 + use chrono::Utc; 10 + use lettre::message::{MultiPart, SinglePart, header}; 11 + use lettre::{AsyncTransport, Message}; 7 12 use rand::distr::{Alphanumeric, SampleString}; 8 13 use serde::de::DeserializeOwned; 14 + use serde_json::{Map, Value}; 9 15 use sqlx::SqlitePool; 10 - use sqlx::sqlite::SqliteError; 11 - use tracing::error; 16 + use tracing::{error, log}; 12 17 13 18 /// The result of a proxied call that attempts to parse JSON. 14 19 pub enum ProxiedResult<T> { ··· 159 164 format!("{}-{}", slice_one, slice_two) 160 165 } 161 166 162 - pub fn create_two_factor_token(account_db: &SqlitePool, did: String) -> anyhow::Result<String> {} 167 + pub enum TokenCheckError { 168 + InvalidToken, 169 + ExpiredToken, 170 + } 171 + pub enum AuthResult { 172 + WrongIdentityOrPassword, 173 + TwoFactorRequired, 174 + TwoFactorFailed, 175 + /// User does not have 2FA enabled, or passes it 176 + ProxyThrough, 177 + TokenCheckFailed(TokenCheckError), 178 + } 179 + 180 + pub enum IdentifierType { 181 + Email, 182 + DID, 183 + Handle, 184 + } 185 + 186 + impl IdentifierType { 187 + fn what_is_it(identifier: String) -> Self { 188 + if identifier.contains("@") { 189 + IdentifierType::Email 190 + } else if identifier.contains("did:") { 191 + IdentifierType::DID 192 + } else { 193 + IdentifierType::Handle 194 + } 195 + } 196 + } 197 + 198 + async fn verify_password(password: &str, password_scrypt: &str) -> Result<bool, StatusCode> { 199 + // Expected format: "salt:hash" where hash is hex of scrypt(password, salt, 64 bytes) 200 + let mut parts = password_scrypt.splitn(2, ':'); 201 + let salt = match parts.next() { 202 + Some(s) if !s.is_empty() => s, 203 + _ => return Ok(false), 204 + }; 205 + let stored_hash_hex = match parts.next() { 206 + Some(h) if !h.is_empty() => h, 207 + _ => return Ok(false), 208 + }; 209 + 210 + //Sets up scrypt to mimic node's scrypt 211 + let params = match scrypt::Params::new(14, 8, 1, 64) { 212 + Ok(p) => p, 213 + Err(_) => return Ok(false), 214 + }; 215 + let mut derived = [0u8; 64]; 216 + if scrypt::scrypt(password.as_bytes(), salt.as_bytes(), &params, &mut derived).is_err() { 217 + return Ok(false); 218 + } 219 + 220 + let stored_bytes = match hex::decode(stored_hash_hex) { 221 + Ok(b) => b, 222 + Err(e) => { 223 + log::error!("Error decoding stored hash: {}", e); 224 + return Ok(false); 225 + } 226 + }; 227 + 228 + Ok(derived.as_slice() == stored_bytes.as_slice()) 229 + } 230 + 231 + pub async fn preauth_check( 232 + state: &AppState, 233 + identifier: &str, 234 + password: &str, 235 + two_factor_code: Option<String>, 236 + ) -> Result<AuthResult, StatusCode> { 237 + // Determine identifier type 238 + let id_type = IdentifierType::what_is_it(identifier.to_string()); 239 + 240 + // Query account DB for did and passwordScrypt based on identifier type 241 + let account_row: Option<(String, String, String, String)> = match id_type { 242 + IdentifierType::Email => sqlx::query_as::<_, (String, String, String, String)>( 243 + "SELECT account.did, account.passwordScrypt, account.email, actor.handle 244 + FROM actor 245 + LEFT JOIN account ON actor.did = account.did 246 + where account.email = ? LIMIT 1", 247 + ) 248 + .bind(identifier) 249 + .fetch_optional(&state.account_pool) 250 + .await 251 + .map_err(|_| StatusCode::BAD_REQUEST)?, 252 + IdentifierType::Handle => sqlx::query_as::<_, (String, String, String, String)>( 253 + "SELECT account.did, account.passwordScrypt, account.email, actor.handle 254 + FROM actor 255 + LEFT JOIN account ON actor.did = account.did 256 + where actor.handle = ? LIMIT 1", 257 + ) 258 + .bind(identifier) 259 + .fetch_optional(&state.account_pool) 260 + .await 261 + .map_err(|_| StatusCode::BAD_REQUEST)?, 262 + IdentifierType::DID => sqlx::query_as::<_, (String, String, String, String)>( 263 + "SELECT account.did, account.passwordScrypt, account.email, actor.handle 264 + FROM actor 265 + LEFT JOIN account ON actor.did = account.did 266 + where account.did = ? LIMIT 1", 267 + ) 268 + .bind(identifier) 269 + .fetch_optional(&state.account_pool) 270 + .await 271 + .map_err(|_| StatusCode::BAD_REQUEST)?, 272 + }; 273 + 274 + if let Some((did, password_scrypt, email, handle)) = account_row { 275 + // Check two-factor requirement for this DID in the gatekeeper DB 276 + let required_opt = sqlx::query_as::<_, (u8,)>( 277 + "SELECT required FROM two_factor_accounts WHERE did = ? LIMIT 1", 278 + ) 279 + .bind(&did) 280 + .fetch_optional(&state.pds_gatekeeper_pool) 281 + .await 282 + .map_err(|_| StatusCode::BAD_REQUEST)?; 283 + 284 + let two_factor_required = match required_opt { 285 + Some(row) => row.0 != 0, 286 + None => false, 287 + }; 288 + 289 + if two_factor_required { 290 + // Verify password before proceeding to 2FA email step 291 + let verified = verify_password(password, &password_scrypt).await?; 292 + if !verified { 293 + return Ok(AuthResult::WrongIdentityOrPassword); 294 + } 295 + //Two factor is required and a taken was provided 296 + if let Some(two_factor_code) = two_factor_code { 297 + //Seems it sends over a empty on login without it set? As in no input is shown on the ui for first login try 298 + if two_factor_code != "" { 299 + return match assert_valid_token( 300 + &state.account_pool, 301 + did.clone(), 302 + two_factor_code, 303 + ) 304 + .await 305 + { 306 + Ok(_) => { 307 + let _ = delete_all_email_tokens(&state.account_pool, did.clone()).await; 308 + Ok(AuthResult::ProxyThrough) 309 + } 310 + Err(err) => Ok(AuthResult::TokenCheckFailed(err)), 311 + }; 312 + } 313 + } 314 + 315 + return match create_two_factor_token(&state.account_pool, did).await { 316 + Ok(code) => { 317 + let mut email_data = Map::new(); 318 + email_data.insert("token".to_string(), Value::from(code.clone())); 319 + email_data.insert("handle".to_string(), Value::from(handle.clone())); 320 + //TODO bad unwrap 321 + let email_body = state 322 + .template_engine 323 + .render("two_factor_code.hbs", email_data) 324 + .unwrap(); 325 + 326 + let email = Message::builder() 327 + //TODO prob get the proper type in the state 328 + .from(state.mailer_from.parse().unwrap()) 329 + .to(email.parse().unwrap()) 330 + .subject("Sign in to Bluesky") 331 + .multipart( 332 + MultiPart::alternative() // This is composed of two parts. 333 + .singlepart( 334 + SinglePart::builder() 335 + .header(header::ContentType::TEXT_PLAIN) 336 + .body(format!("We received a sign-in request for the account @{}. Use the 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.", handle, code)), // Every message should have a plain text fallback. 337 + ) 338 + .singlepart( 339 + SinglePart::builder() 340 + .header(header::ContentType::TEXT_HTML) 341 + .body(email_body), 342 + ), 343 + ) 344 + //TODO bad 345 + .unwrap(); 346 + match state.mailer.send(email).await { 347 + Ok(_) => Ok(AuthResult::TwoFactorRequired), 348 + Err(err) => { 349 + log::error!("Error sending the 2FA email: {}", err); 350 + Err(StatusCode::BAD_REQUEST) 351 + } 352 + } 353 + } 354 + Err(err) => { 355 + log::error!("error on creating a 2fa token: {}", err); 356 + Err(StatusCode::BAD_REQUEST) 357 + } 358 + }; 359 + } 360 + } 361 + 362 + // No local 2FA requirement (or account not found) 363 + Ok(AuthResult::ProxyThrough) 364 + } 365 + 366 + pub async fn create_two_factor_token( 367 + account_db: &SqlitePool, 368 + did: String, 369 + ) -> anyhow::Result<String> { 370 + let purpose = "2fa_code"; 371 + 372 + loop { 373 + let token = get_random_token(); 374 + let right_now = Utc::now(); 375 + let query = "INSERT INTO email_token (purpose, did, token, requestedAt) 376 + VALUES (?, ?, ?, ?) 377 + ON CONFLICT(purpose, did) DO UPDATE SET 378 + token=excluded.token, 379 + requestedAt=excluded.requestedAt"; 380 + 381 + let res = sqlx::query(query) 382 + .bind(purpose) 383 + .bind(&did) 384 + .bind(&token) 385 + .bind(right_now) 386 + .execute(account_db) 387 + .await; 388 + 389 + return match res { 390 + Ok(_) => Ok(token), 391 + Err(e) => { 392 + log::error!("Error creating a two factor token: {}", e); 393 + Err(anyhow::anyhow!(e)) 394 + } 395 + }; 396 + } 397 + } 398 + 399 + pub async fn delete_all_email_tokens(account_db: &SqlitePool, did: String) -> anyhow::Result<()> { 400 + sqlx::query("DELETE FROM email_token WHERE did = ?") 401 + .bind(did) 402 + .execute(account_db) 403 + .await?; 404 + Ok(()) 405 + } 406 + 407 + pub async fn assert_valid_token( 408 + account_db: &SqlitePool, 409 + did: String, 410 + token: String, 411 + ) -> Result<(), TokenCheckError> { 412 + let token_upper = token.to_ascii_uppercase(); 413 + let purpose = "2fa_code"; 414 + 415 + let row: Option<(String,)> = sqlx::query_as( 416 + "SELECT requestedAt FROM email_token WHERE purpose = ? AND did = ? AND token = ? LIMIT 1", 417 + ) 418 + .bind(purpose) 419 + .bind(did) 420 + .bind(token_upper) 421 + .fetch_optional(account_db) 422 + .await 423 + .map_err(|err| { 424 + log::error!("Error getting the 2fa token: {}", err); 425 + TokenCheckError::InvalidToken 426 + })?; 427 + 428 + match row { 429 + None => Err(TokenCheckError::InvalidToken), 430 + Some(row) => { 431 + // Token lives for 15 minutes 432 + let expiration_ms = 15 * 60_000; 433 + 434 + // Parse requestedAt; assume RFC3339-like string (as created_by PDS or by our code) 435 + let requested_at_utc = match chrono::DateTime::parse_from_rfc3339(&row.0) { 436 + Ok(dt) => dt.with_timezone(&Utc), 437 + Err(_) => { 438 + return Err(TokenCheckError::InvalidToken); 439 + } 440 + }; 441 + 442 + let now = Utc::now(); 443 + let age_ms = (now - requested_at_utc).num_milliseconds(); 444 + let expired = age_ms > expiration_ms; 445 + if expired { 446 + return Err(TokenCheckError::ExpiredToken); 447 + } 448 + 449 + Ok(()) 450 + } 451 + } 452 + }