Microservice to bring 2FA to self hosted PDSes

2FA logins gatekept #1

merged opened by baileytownsend.dev targeting main from feature/2faCodeGeneration
Labels

None yet.

Participants 1
AT URI
at://did:plc:rnpkyqnmsw4ipey6eotbdnnf/sh.tangled.repo.pull/3lxnftbzj4v22
+937 -137
Diff #0
+11 -7
Cargo.toml
··· 3 4 5 6 - 7 - 8 - 9 - 10 - 11 - 12 - 13 14 15 ··· 22 handlebars = { version = "6.3.2", features = ["rust-embed"] } 23 rust-embed = "8.7.2" 24 axum-template = { version = "3.0.0", features = ["handlebars"] }
··· 3 4 5 6 + [dependencies] 7 + axum = { version = "0.8.4", features = ["macros", "json"] } 8 + tokio = { version = "1.47.1", features = ["rt-multi-thread", "macros", "signal"] } 9 + sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "sqlite", "migrate", "chrono"] } 10 + dotenvy = "0.15.7" 11 + serde = { version = "1.0", features = ["derive"] } 12 + serde_json = "1.0" 13 14 15 ··· 22 handlebars = { version = "6.3.2", features = ["rust-embed"] } 23 rust-embed = "8.7.2" 24 axum-template = { version = "3.0.0", features = ["handlebars"] } 25 + rand = "0.9.2" 26 + anyhow = "1.0.99" 27 + chrono = "0.4.41" 28 + sha2 = "0.10"
-3
migrations_bells_and_whistles/.keep
··· 1 - # This directory holds SQLx migrations for the bells_and_whistles.sqlite database. 2 - # It is intentionally empty for now; running `sqlx::migrate!` will still ensure the 3 - # migrations table exists and succeed with zero migrations.
···
+339 -59
src/xrpc/helpers.rs
··· 1 use axum::body::{Body, to_bytes}; 2 use axum::extract::Request; 3 - use axum::http::{HeaderMap, Method, StatusCode, Uri}; 4 use axum::http::header::CONTENT_TYPE; 5 use axum::response::{IntoResponse, Response}; 6 use serde::de::DeserializeOwned; 7 - use tracing::error; 8 - 9 - use crate::AppState; 10 11 /// The result of a proxied call that attempts to parse JSON. 12 pub enum ProxiedResult<T> { ··· 16 17 18 19 20 21 ··· 57 58 59 60 61 62 63 ··· 74 75 76 77 78 79 80 81 82 - 83 - 84 - 85 - 86 - 87 - 88 - 89 - 90 - 91 - 92 - 93 - 94 - 95 - 96 - 97 - 98 - 99 - 100 - 101 - 102 - 103 - 104 - 105 - 106 - 107 - 108 - 109 - 110 - 111 - 112 - 113 - 114 - 115 - 116 - 117 - 118 - 119 - 120 - 121 - 122 - 123 - 124 - 125 } 126 } 127 128 129 - /// Build a JSON error response with the required Content-Type header 130 - /// Content-Type: application/json;charset=utf-8 131 - /// Body shape: { "error": string, "message": string } 132 - 133 - 134 - 135 - 136 - 137 - 138 - 139 140 141 142 143 144 145 146 147 148 - .body(Body::from(body_str)) 149 - .map_err(|_| StatusCode::BAD_REQUEST) 150 }
··· 1 + use anyhow::anyhow; 2 + use crate::AppState; 3 + use crate::xrpc::helpers::TokenCheckError::InvalidToken; 4 use axum::body::{Body, to_bytes}; 5 use axum::extract::Request; 6 use axum::http::header::CONTENT_TYPE; 7 + use axum::http::{HeaderMap, StatusCode, Uri}; 8 use axum::response::{IntoResponse, Response}; 9 + use axum_template::TemplateEngine; 10 + use chrono::Utc; 11 + use lettre::message::{MultiPart, SinglePart, header}; 12 + use lettre::{AsyncTransport, Message}; 13 + use rand::distr::{Alphanumeric, SampleString}; 14 use serde::de::DeserializeOwned; 15 + use serde_json::{Map, Value}; 16 + use sqlx::SqlitePool; 17 + use tracing::{error, log}; 18 19 /// The result of a proxied call that attempts to parse JSON. 20 pub enum ProxiedResult<T> { ··· 24 25 26 27 + /// Proxy the incoming request to the PDS base URL plus the provided path and attempt to parse 28 + /// the successful response body as JSON into `T`. 29 + /// 30 + pub async fn proxy_get_json<T>( 31 + state: &AppState, 32 + mut req: Request, 33 34 35 ··· 71 72 73 74 + } 75 + } 76 77 + /// Build a JSON error response with the required Content-Type header 78 + /// Content-Type: application/json;charset=utf-8 79 + /// Body shape: { "error": string, "message": string } 80 81 82 ··· 93 94 95 96 + .body(Body::from(body_str)) 97 + .map_err(|_| StatusCode::BAD_REQUEST) 98 + } 99 100 + /// Creates a random token of 10 characters for email 2FA 101 + pub fn get_random_token() -> String { 102 + let full_code = Alphanumeric.sample_string(&mut rand::rng(), 10); 103 + //The PDS implementation creates in lowercase, then converts to uppercase. 104 + //Just going a head and doing uppercase here. 105 + let slice_one = &full_code[0..5].to_ascii_uppercase(); 106 + let slice_two = &full_code[5..10].to_ascii_uppercase(); 107 + format!("{slice_one}-{slice_two}") 108 + } 109 110 + pub enum TokenCheckError { 111 + InvalidToken, 112 + ExpiredToken, 113 + } 114 115 + pub enum AuthResult { 116 + WrongIdentityOrPassword, 117 + TwoFactorRequired, 118 + /// User does not have 2FA enabled, or using an app password, or passes it 119 + ProxyThrough, 120 + TokenCheckFailed(TokenCheckError), 121 + } 122 123 + pub enum IdentifierType { 124 + Email, 125 + Did, 126 + Handle, 127 + } 128 129 + impl IdentifierType { 130 + fn what_is_it(identifier: String) -> Self { 131 + if identifier.contains("@") { 132 + IdentifierType::Email 133 + } else if identifier.contains("did:") { 134 + IdentifierType::Did 135 + } else { 136 + IdentifierType::Handle 137 + } 138 } 139 } 140 141 + fn scrypt_hex(password: &str, salt: &str) -> anyhow::Result<String> { 142 + let params = scrypt::Params::new(14, 8, 1, 64)?; 143 + let mut derived = [0u8; 64]; 144 + scrypt::scrypt(password.as_bytes(), salt.as_bytes(), &params, &mut derived)?; 145 + Ok(hex::encode(derived)) 146 + } 147 148 + pub fn hash_app_password(did: &str, password: &str) -> anyhow::Result<String> { 149 + use sha2::{Digest, Sha256}; 150 + let mut hasher = Sha256::new(); 151 + hasher.update(did.as_bytes()); 152 + let sha = hasher.finalize(); 153 + let salt = hex::encode(&sha[..16]); 154 + let hash_hex = scrypt_hex(password, &salt)?; 155 + Ok(format!("{salt}:{hash_hex}")) 156 + } 157 158 + async fn verify_password(password: &str, password_scrypt: &str) -> anyhow::Result<bool> { 159 + // Expected format: "salt:hash" where hash is hex of scrypt(password, salt, 64 bytes) 160 + let mut parts = password_scrypt.splitn(2, ':'); 161 + let salt = match parts.next() { 162 + Some(s) if !s.is_empty() => s, 163 + _ => return Ok(false), 164 + }; 165 + let stored_hash_hex = match parts.next() { 166 + Some(h) if !h.is_empty() => h, 167 + _ => return Ok(false), 168 + }; 169 + 170 + // Derive using the shared helper and compare 171 + let derived_hex = match scrypt_hex(password, salt) { 172 + Ok(h) => h, 173 + Err(_) => return Ok(false), 174 + }; 175 176 + Ok(derived_hex.as_str() == stored_hash_hex) 177 + } 178 179 + /// Handles the auth checks along with sending a 2fa email 180 + pub async fn preauth_check( 181 + state: &AppState, 182 + identifier: &str, 183 + password: &str, 184 + two_factor_code: Option<String>, 185 + ) -> anyhow::Result<AuthResult> { 186 + // Determine identifier type 187 + let id_type = IdentifierType::what_is_it(identifier.to_string()); 188 + 189 + // Query account DB for did and passwordScrypt based on identifier type 190 + let account_row: Option<(String, String, String, String)> = match id_type { 191 + IdentifierType::Email => sqlx::query_as::<_, (String, String, String, String)>( 192 + "SELECT account.did, account.passwordScrypt, account.email, actor.handle 193 + FROM actor 194 + LEFT JOIN account ON actor.did = account.did 195 + where account.email = ? LIMIT 1", 196 + ) 197 + .bind(identifier) 198 + .fetch_optional(&state.account_pool) 199 + .await?, 200 + IdentifierType::Handle => sqlx::query_as::<_, (String, String, String, String)>( 201 + "SELECT account.did, account.passwordScrypt, account.email, actor.handle 202 + FROM actor 203 + LEFT JOIN account ON actor.did = account.did 204 + where actor.handle = ? LIMIT 1", 205 + ) 206 + .bind(identifier) 207 + .fetch_optional(&state.account_pool) 208 + .await?, 209 + IdentifierType::Did => sqlx::query_as::<_, (String, String, String, String)>( 210 + "SELECT account.did, account.passwordScrypt, account.email, actor.handle 211 + FROM actor 212 + LEFT JOIN account ON actor.did = account.did 213 + where account.did = ? LIMIT 1", 214 + ) 215 + .bind(identifier) 216 + .fetch_optional(&state.account_pool) 217 + .await?, 218 + }; 219 + 220 + if let Some((did, password_scrypt, email, handle)) = account_row { 221 + // Check two-factor requirement for this DID in the gatekeeper DB 222 + let required_opt = sqlx::query_as::<_, (u8,)>( 223 + "SELECT required FROM two_factor_accounts WHERE did = ? LIMIT 1", 224 + ) 225 + .bind(did.clone()) 226 + .fetch_optional(&state.pds_gatekeeper_pool) 227 + .await?; 228 + 229 + let two_factor_required = match required_opt { 230 + Some(row) => row.0 != 0, 231 + None => false, 232 + }; 233 + 234 + if two_factor_required { 235 + // Verify password before proceeding to 2FA email step 236 + let verified = verify_password(password, &password_scrypt).await?; 237 + if !verified { 238 + //Theres a chance it could be an app password so check that as well 239 + return match verify_app_password(&state.account_pool, &did, password).await { 240 + Ok(valid) => { 241 + if valid { 242 + //Was a valid app password up to the PDS now 243 + Ok(AuthResult::ProxyThrough) 244 + } else { 245 + Ok(AuthResult::WrongIdentityOrPassword) 246 + } 247 + } 248 + Err(err) => { 249 + log::error!("Error checking the app password: {err}"); 250 + Err(err) 251 + } 252 + }; 253 + } 254 + //Two factor is required and a taken was provided 255 + if let Some(two_factor_code) = two_factor_code { 256 + //It seems it sends over a empty on login without it set? As in no input is shown on the ui for the first login try 257 + if !two_factor_code.is_empty() { 258 + return match assert_valid_token( 259 + &state.account_pool, 260 + did.clone(), 261 + two_factor_code, 262 + ) 263 + .await 264 + { 265 + Ok(_) => { 266 + let result_of_cleanup = delete_all_email_tokens(&state.account_pool, did.clone()).await; 267 + if result_of_cleanup.is_err(){ 268 + log::error!("There was an error deleting the email tokens after login: {:?}", result_of_cleanup.err()) 269 + } 270 + Ok(AuthResult::ProxyThrough) 271 + } 272 + Err(err) => Ok(AuthResult::TokenCheckFailed(err)), 273 + }; 274 + } 275 + } 276 + 277 + return match create_two_factor_token(&state.account_pool, did).await { 278 + Ok(code) => { 279 + let mut email_data = Map::new(); 280 + email_data.insert("token".to_string(), Value::from(code.clone())); 281 + email_data.insert("handle".to_string(), Value::from(handle.clone())); 282 + let email_body = state 283 + .template_engine 284 + .render("two_factor_code.hbs", email_data)?; 285 + 286 + let email = Message::builder() 287 + //TODO prob get the proper type in the state 288 + .from(state.mailer_from.parse()?) 289 + .to(email.parse()?) 290 + .subject("Sign in to Bluesky") 291 + .multipart( 292 + MultiPart::alternative() // This is composed of two parts. 293 + .singlepart( 294 + SinglePart::builder() 295 + .header(header::ContentType::TEXT_PLAIN) 296 + .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. 297 + ) 298 + .singlepart( 299 + SinglePart::builder() 300 + .header(header::ContentType::TEXT_HTML) 301 + .body(email_body), 302 + ), 303 + )?; 304 + match state.mailer.send(email).await { 305 + Ok(_) => Ok(AuthResult::TwoFactorRequired), 306 + Err(err) => { 307 + log::error!("Error sending the 2FA email: {err}"); 308 + Err(anyhow!(err)) 309 + } 310 + } 311 + } 312 + Err(err) => { 313 + log::error!("error on creating a 2fa token: {err}"); 314 + Err(anyhow!(err)) 315 + } 316 + }; 317 + } 318 + } 319 320 + // No local 2FA requirement (or account not found) 321 + Ok(AuthResult::ProxyThrough) 322 + } 323 324 + pub async fn create_two_factor_token( 325 + account_db: &SqlitePool, 326 + did: String, 327 + ) -> anyhow::Result<String> { 328 + let purpose = "2fa_code"; 329 + 330 + let token = get_random_token(); 331 + let right_now = Utc::now(); 332 + 333 + let res = sqlx::query( 334 + "INSERT INTO email_token (purpose, did, token, requestedAt) 335 + VALUES (?, ?, ?, ?) 336 + ON CONFLICT(purpose, did) DO UPDATE SET 337 + token=excluded.token, 338 + requestedAt=excluded.requestedAt", 339 + ) 340 + .bind(purpose) 341 + .bind(&did) 342 + .bind(&token) 343 + .bind(right_now) 344 + .execute(account_db) 345 + .await; 346 + 347 + match res { 348 + Ok(_) => Ok(token), 349 + Err(err) => { 350 + log::error!("Error creating a two factor token: {err}"); 351 + Err(anyhow::anyhow!(err)) 352 + } 353 + } 354 + } 355 356 + pub async fn delete_all_email_tokens(account_db: &SqlitePool, did: String) -> anyhow::Result<()> { 357 + sqlx::query("DELETE FROM email_token WHERE did = ?") 358 + .bind(did) 359 + .execute(account_db) 360 + .await?; 361 + Ok(()) 362 + } 363 364 + pub async fn assert_valid_token( 365 + account_db: &SqlitePool, 366 + did: String, 367 + token: String, 368 + ) -> Result<(), TokenCheckError> { 369 + let token_upper = token.to_ascii_uppercase(); 370 + let purpose = "2fa_code"; 371 + 372 + let row: Option<(String,)> = sqlx::query_as( 373 + "SELECT requestedAt FROM email_token WHERE purpose = ? AND did = ? AND token = ? LIMIT 1", 374 + ) 375 + .bind(purpose) 376 + .bind(did) 377 + .bind(token_upper) 378 + .fetch_optional(account_db) 379 + .await 380 + .map_err(|err| { 381 + log::error!("Error getting the 2fa token: {err}"); 382 + InvalidToken 383 + })?; 384 + 385 + match row { 386 + None => Err(InvalidToken), 387 + Some(row) => { 388 + // Token lives for 15 minutes 389 + let expiration_ms = 15 * 60_000; 390 + 391 + let requested_at_utc = match chrono::DateTime::parse_from_rfc3339(&row.0) { 392 + Ok(dt) => dt.with_timezone(&Utc), 393 + Err(_) => { 394 + return Err(TokenCheckError::InvalidToken); 395 + } 396 + }; 397 + 398 + let now = Utc::now(); 399 + let age_ms = (now - requested_at_utc).num_milliseconds(); 400 + let expired = age_ms > expiration_ms; 401 + if expired { 402 + return Err(TokenCheckError::ExpiredToken); 403 + } 404 405 + Ok(()) 406 + } 407 + } 408 + } 409 410 + /// We just need to confirm if it's there or not. Will let the PDS do the actual figuring of permissions 411 + pub async fn verify_app_password( 412 + account_db: &SqlitePool, 413 + did: &str, 414 + password: &str, 415 + ) -> anyhow::Result<bool> { 416 + let password_scrypt = hash_app_password(did, password)?; 417 + 418 + let row: Option<(i64,)> = sqlx::query_as( 419 + "SELECT Count(*) FROM app_password WHERE did = ? AND passwordScrypt = ? LIMIT 1", 420 + ) 421 + .bind(did) 422 + .bind(password_scrypt) 423 + .fetch_optional(account_db) 424 + .await?; 425 + 426 + Ok(match row { 427 + None => false, 428 + Some((count,)) => count > 0, 429 + }) 430 }
+25 -40
src/middleware.rs
··· 1 - 2 - 3 - 4 - 5 6 7 use jwt_compact::{AlgorithmExt, Claims, Token, UntrustedToken, ValidationError}; 8 use serde::{Deserialize, Serialize}; 9 use std::env; 10 11 #[derive(Clone, Debug)] 12 pub struct Did(pub Option<String>); ··· 22 match token { 23 Ok(token) => { 24 match token { 25 - None => { 26 - return json_error_response( 27 - StatusCode::BAD_REQUEST, 28 - "TokenRequired", 29 - "", 30 - ).unwrap(); 31 - } 32 Some(token) => { 33 let token = UntrustedToken::new(&token); 34 - //Doing weird unwraps cause I can't do Result for middleware? 35 if token.is_err() { 36 - return json_error_response( 37 - StatusCode::BAD_REQUEST, 38 - "TokenRequired", 39 - "", 40 - ).unwrap(); 41 } 42 - let parsed_token = token.unwrap(); 43 let claims: Result<Claims<TokenClaims>, ValidationError> = 44 parsed_token.deserialize_claims_unchecked(); 45 if claims.is_err() { 46 - return json_error_response( 47 - StatusCode::BAD_REQUEST, 48 - "TokenRequired", 49 - "", 50 - ).unwrap(); 51 } 52 53 - let key = Hs256Key::new(env::var("PDS_JWT_SECRET").unwrap()); 54 let token: Result<Token<TokenClaims>, ValidationError> = 55 Hs256.validator(&key).validate(&parsed_token); 56 if token.is_err() { 57 - return json_error_response( 58 - StatusCode::BAD_REQUEST, 59 - "InvalidToken", 60 - "", 61 - ).unwrap(); 62 } 63 - let token = token.unwrap(); 64 //Not going to worry about expiration since it still goes to the PDS 65 - 66 - 67 - 68 - 69 } 70 } 71 } 72 - Err(_) => { 73 - return json_error_response( 74 - StatusCode::BAD_REQUEST, 75 - "InvalidToken", 76 - "", 77 - ).unwrap(); 78 } 79 } 80 }
··· 1 + use crate::helpers::json_error_response; 2 + use axum::extract::Request; 3 + use axum::http::{HeaderMap, StatusCode}; 4 + use axum::middleware::Next; 5 6 7 use jwt_compact::{AlgorithmExt, Claims, Token, UntrustedToken, ValidationError}; 8 use serde::{Deserialize, Serialize}; 9 use std::env; 10 + use tracing::log; 11 12 #[derive(Clone, Debug)] 13 pub struct Did(pub Option<String>); ··· 23 match token { 24 Ok(token) => { 25 match token { 26 + None => json_error_response(StatusCode::BAD_REQUEST, "TokenRequired", "") 27 + .expect("Error creating an error response"), 28 Some(token) => { 29 let token = UntrustedToken::new(&token); 30 if token.is_err() { 31 + return json_error_response(StatusCode::BAD_REQUEST, "TokenRequired", "") 32 + .expect("Error creating an error response"); 33 } 34 + let parsed_token = token.expect("Already checked for error"); 35 let claims: Result<Claims<TokenClaims>, ValidationError> = 36 parsed_token.deserialize_claims_unchecked(); 37 if claims.is_err() { 38 + return json_error_response(StatusCode::BAD_REQUEST, "TokenRequired", "") 39 + .expect("Error creating an error response"); 40 } 41 42 + let key = Hs256Key::new( 43 + env::var("PDS_JWT_SECRET").expect("PDS_JWT_SECRET not set in the pds.env"), 44 + ); 45 let token: Result<Token<TokenClaims>, ValidationError> = 46 Hs256.validator(&key).validate(&parsed_token); 47 if token.is_err() { 48 + return json_error_response(StatusCode::BAD_REQUEST, "InvalidToken", "") 49 + .expect("Error creating an error response"); 50 } 51 + let token = token.expect("Already checked for error,"); 52 //Not going to worry about expiration since it still goes to the PDS 53 + req.extensions_mut() 54 + .insert(Did(Some(token.claims().custom.sub.clone()))); 55 + next.run(req).await 56 } 57 } 58 } 59 + Err(err) => { 60 + log::error!("Error extracting token: {err}"); 61 + json_error_response(StatusCode::BAD_REQUEST, "InvalidToken", "") 62 + .expect("Error creating an error response") 63 } 64 } 65 }
+141
src/oauth_provider.rs
···
··· 1 + use crate::AppState; 2 + use crate::helpers::{AuthResult, oauth_json_error_response, preauth_check}; 3 + use axum::body::Body; 4 + use axum::extract::State; 5 + use axum::http::header::CONTENT_TYPE; 6 + use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; 7 + use axum::response::{IntoResponse, Response}; 8 + use axum::{Json, extract}; 9 + use serde::{Deserialize, Serialize}; 10 + use tracing::log; 11 + 12 + #[derive(Serialize, Deserialize, Clone)] 13 + pub struct SignInRequest { 14 + pub username: String, 15 + pub password: String, 16 + pub remember: bool, 17 + pub locale: String, 18 + #[serde(skip_serializing_if = "Option::is_none", rename = "emailOtp")] 19 + pub email_otp: Option<String>, 20 + } 21 + 22 + pub async fn sign_in( 23 + State(state): State<AppState>, 24 + headers: HeaderMap, 25 + Json(mut payload): extract::Json<SignInRequest>, 26 + ) -> Result<Response<Body>, StatusCode> { 27 + let identifier = payload.username.clone(); 28 + let password = payload.password.clone(); 29 + let auth_factor_token = payload.email_otp.clone(); 30 + 31 + match preauth_check(&state, &identifier, &password, auth_factor_token, true).await { 32 + Ok(result) => match result { 33 + AuthResult::WrongIdentityOrPassword => oauth_json_error_response( 34 + StatusCode::BAD_REQUEST, 35 + "invalid_request", 36 + "Invalid identifier or password", 37 + ), 38 + AuthResult::TwoFactorRequired(masked_email) => { 39 + // Email sending step can be handled here if needed in the future. 40 + 41 + // {"error":"second_authentication_factor_required","error_description":"emailOtp authentication factor required (hint: 2***0@p***m)","type":"emailOtp","hint":"2***0@p***m"} 42 + let body_str = match serde_json::to_string(&serde_json::json!({ 43 + "error": "second_authentication_factor_required", 44 + "error_description": format!("emailOtp authentication factor required (hint: {})", masked_email), 45 + "type": "emailOtp", 46 + "hint": masked_email, 47 + })) { 48 + Ok(s) => s, 49 + Err(_) => return Err(StatusCode::BAD_REQUEST), 50 + }; 51 + 52 + Response::builder() 53 + .status(StatusCode::BAD_REQUEST) 54 + .header(CONTENT_TYPE, "application/json") 55 + .body(Body::from(body_str)) 56 + .map_err(|_| StatusCode::BAD_REQUEST) 57 + } 58 + AuthResult::ProxyThrough => { 59 + //No 2FA or already passed 60 + let uri = format!( 61 + "{}{}", 62 + state.pds_base_url, "/@atproto/oauth-provider/~api/sign-in" 63 + ); 64 + 65 + let mut req = axum::http::Request::post(uri); 66 + if let Some(req_headers) = req.headers_mut() { 67 + // Copy headers but remove problematic ones. There was an issue with the PDS not parsing the body fully if i forwarded all headers 68 + copy_filtered_headers(&headers, req_headers); 69 + //Setting the content type to application/json manually 70 + req_headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); 71 + } 72 + 73 + //Clears the email_otp because the pds will reject a request with it. 74 + payload.email_otp = None; 75 + let payload_bytes = 76 + serde_json::to_vec(&payload).map_err(|_| StatusCode::BAD_REQUEST)?; 77 + 78 + let req = req 79 + .body(Body::from(payload_bytes)) 80 + .map_err(|_| StatusCode::BAD_REQUEST)?; 81 + 82 + let proxied = state 83 + .reverse_proxy_client 84 + .request(req) 85 + .await 86 + .map_err(|_| StatusCode::BAD_REQUEST)? 87 + .into_response(); 88 + 89 + Ok(proxied) 90 + } 91 + //Ignoring the type of token check failure. Looks like oauth on the entry treads them the same. 92 + AuthResult::TokenCheckFailed(_) => oauth_json_error_response( 93 + StatusCode::BAD_REQUEST, 94 + "invalid_request", 95 + "Unable to sign-in due to an unexpected server error", 96 + ), 97 + }, 98 + Err(err) => { 99 + log::error!( 100 + "Error during pre-auth check. This happens on the create_session endpoint when trying to decide if the user has access:\n {err}" 101 + ); 102 + oauth_json_error_response( 103 + StatusCode::BAD_REQUEST, 104 + "pds_gatekeeper_error", 105 + "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.", 106 + ) 107 + } 108 + } 109 + } 110 + 111 + fn is_disallowed_header(name: &HeaderName) -> bool { 112 + // possible problematic headers with proxying 113 + matches!( 114 + name.as_str(), 115 + "connection" 116 + | "keep-alive" 117 + | "proxy-authenticate" 118 + | "proxy-authorization" 119 + | "te" 120 + | "trailer" 121 + | "transfer-encoding" 122 + | "upgrade" 123 + | "host" 124 + | "content-length" 125 + | "content-encoding" 126 + | "expect" 127 + | "accept-encoding" 128 + ) 129 + } 130 + 131 + fn copy_filtered_headers(src: &HeaderMap, dst: &mut HeaderMap) { 132 + for (name, value) in src.iter() { 133 + if is_disallowed_header(name) { 134 + continue; 135 + } 136 + // Only copy valid headers 137 + if let Ok(hv) = HeaderValue::from_bytes(value.as_bytes()) { 138 + dst.insert(name.clone(), hv); 139 + } 140 + } 141 + }
-1
src/xrpc/mod.rs
··· 1 pub mod com_atproto_server; 2 - pub mod helpers;
··· 1 pub mod com_atproto_server;
+377 -1
src/helpers.rs
··· 3 use anyhow::anyhow; 4 use axum::body::{Body, to_bytes}; 5 use axum::extract::Request; 6 - use axum::http::header::{CONTENT_LENGTH, CONTENT_TYPE}; 7 use axum::http::{HeaderMap, StatusCode, Uri}; 8 use axum::response::{IntoResponse, Response}; 9 use axum_template::TemplateEngine;
··· 3 use anyhow::anyhow; 4 use axum::body::{Body, to_bytes}; 5 use axum::extract::Request; 6 + use axum::http::header::CONTENT_TYPE; 7 use axum::http::{HeaderMap, StatusCode, Uri}; 8 use axum::response::{IntoResponse, Response}; 9 use axum_template::TemplateEngine; 10 + use chrono::Utc; 11 + use lettre::message::{MultiPart, SinglePart, header}; 12 + use lettre::{AsyncTransport, Message}; 13 + use rand::Rng; 14 + use serde::de::DeserializeOwned; 15 + use serde_json::{Map, Value}; 16 + use sha2::{Digest, Sha256}; 17 + use sqlx::SqlitePool; 18 + use tracing::{error, log}; 19 + 20 + ///Used to generate the email 2fa code 21 + const UPPERCASE_BASE32_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; 22 + 23 + /// The result of a proxied call that attempts to parse JSON. 24 + pub enum ProxiedResult<T> { 25 + /// Successfully parsed JSON body along with original response headers. 26 + 27 + 28 + 29 + 30 + 31 + 32 + 33 + 34 + 35 + 36 + 37 + 38 + 39 + 40 + 41 + 42 + 43 + 44 + 45 + 46 + 47 + 48 + 49 + 50 + 51 + 52 + 53 + 54 + 55 + 56 + 57 + 58 + 59 + 60 + 61 + 62 + 63 + 64 + 65 + 66 + 67 + 68 + 69 + 70 + 71 + 72 + 73 + 74 + 75 + 76 + 77 + 78 + 79 + 80 + 81 + 82 + 83 + 84 + 85 + 86 + 87 + 88 + 89 + 90 + 91 + 92 + 93 + 94 + 95 + 96 + 97 + 98 + 99 + 100 + 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 } 107 + pub fn oauth_json_error_response( 108 + status: StatusCode, 109 + error: impl Into<String>, 110 + 111 + 112 + 113 + 114 + 115 + 116 + 117 + 118 + 119 + 120 + 121 + 122 + 123 + 124 + 125 + } 126 + 127 + /// Creates a random token of 10 characters for email 2FA 128 + pub 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 + 141 + 142 + 143 + 144 + 145 + 146 + 147 + 148 + 149 + 150 + 151 + 152 + 153 + 154 + 155 + 156 + 157 + 158 + 159 + 160 + 161 + 162 + 163 + 164 + 165 + 166 + 167 + 168 + 169 + 170 + 171 + 172 + 173 + } 174 + } 175 + 176 + /// Creates a hex string from the password and salt to find app passwords 177 + fn 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 + 181 + Ok(hex::encode(derived)) 182 + } 183 + 184 + /// Hashes the app password. did is used as the salt. 185 + pub 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 + 190 + 191 + 192 + 193 + 194 + 195 + 196 + 197 + 198 + 199 + 200 + 201 + 202 + 203 + 204 + 205 + 206 + 207 + 208 + 209 + 210 + 211 + 212 + 213 + 214 + 215 + 216 + 217 + 218 + 219 + 220 + 221 + 222 + 223 + 224 + 225 + 226 + 227 + 228 + 229 + 230 + 231 + 232 + 233 + 234 + 235 + 236 + 237 + 238 + 239 + 240 + 241 + 242 + 243 + 244 + 245 + 246 + 247 + 248 + 249 + 250 + 251 + 252 + 253 + 254 + 255 + 256 + 257 + 258 + 259 + 260 + 261 + 262 + 263 + 264 + 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 + 273 + 274 + 275 + 276 + 277 + 278 + 279 + 280 + 281 + 282 + 283 + 284 + 285 + 286 + 287 + 288 + 289 + 290 + 291 + 292 + 293 + 294 + 295 + 296 + 297 + 298 + 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 + 309 + 310 + 311 + 312 + 313 + 314 + 315 + 316 + 317 + 318 + 319 + 320 + 321 + 322 + 323 + 324 + 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 + 334 + 335 + 336 + 337 + 338 + 339 + 340 + 341 + 342 + 343 + 344 + 345 + 346 + 347 + 348 + 349 + 350 + 351 + 352 + 353 + 354 + 355 + 356 + 357 + 358 + 359 + 360 + 361 + 362 + 363 + 364 + 365 + 366 + 367 + 368 + 369 + 370 + 371 + 372 + 373 + 374 + 375 + 376 + pub 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(
+39 -20
src/main.rs
··· 22 use tower_governor::governor::GovernorConfigBuilder; 23 use tower_http::compression::CompressionLayer; 24 use tower_http::cors::{Any, CorsLayer}; 25 - use tracing::error; 26 use tracing_subscriber::{EnvFilter, fmt, prelude::*}; 27 28 pub mod helpers; ··· 88 #[tokio::main] 89 async fn main() -> Result<(), Box<dyn std::error::Error>> { 90 setup_tracing(); 91 - //TODO may need to change where this reads from? Like an env variable for it's location? 92 dotenvy::from_path(Path::new("./pds.env"))?; 93 let pds_root = env::var("PDS_DATA_DIRECTORY")?; 94 let account_db_url = format!("{pds_root}/account.sqlite"); 95 96 let account_options = SqliteConnectOptions::new() 97 - .journal_mode(SqliteJournalMode::Wal) 98 - .filename(account_db_url); 99 100 let account_pool = SqlitePoolOptions::new() 101 .max_connections(5) ··· 106 let options = SqliteConnectOptions::new() 107 .journal_mode(SqliteJournalMode::Wal) 108 .filename(bells_db_url) 109 - .create_if_missing(true); 110 let pds_gatekeeper_pool = SqlitePoolOptions::new() 111 .max_connections(5) 112 .connect_with(options) ··· 129 130 131 132 133 - 134 - 135 - //TODO add an override to manually load in the hbs templates 136 - let _ = hbs.register_embed_templates::<EmailTemplates>(); 137 138 let state = AppState { 139 account_pool, 140 pds_gatekeeper_pool, 141 reverse_proxy_client: client, 142 - //TODO should be env prob 143 - pds_base_url: "http://localhost:3000".to_string(), 144 mailer, 145 mailer_from: sent_from, 146 template_engine: Engine::from(hbs), ··· 148 149 // Rate limiting 150 //Allows 5 within 60 seconds, and after 60 should drop one off? So hit 5, then goes to 4 after 60 seconds. 151 - let governor_conf = GovernorConfigBuilder::default() 152 .per_second(60) 153 .burst_size(5) 154 .finish() 155 .expect("failed to create governor config. this should not happen and is a bug"); 156 157 - let governor_limiter = governor_conf.limiter().clone(); 158 let interval = Duration::from_secs(60); 159 // a separate background task to clean up 160 std::thread::spawn(move || { 161 loop { 162 std::thread::sleep(interval); 163 - tracing::info!("rate limiting storage size: {}", governor_limiter.len()); 164 - governor_limiter.retain_recent(); 165 } 166 }); 167 ··· 182 ) 183 .route( 184 "/@atproto/oauth-provider/~api/sign-in", 185 - post(sign_in), // .layer(GovernorLayer::new(governor_conf.clone()))), 186 ) 187 .route( 188 "/xrpc/com.atproto.server.createSession", 189 - post(create_session.layer(GovernorLayer::new(governor_conf))), 190 ) 191 .layer(CompressionLayer::new()) 192 .layer(cors) 193 .with_state(state); 194 195 - let host = env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); 196 - let port: u16 = env::var("PORT") 197 .ok() 198 .and_then(|s| s.parse().ok()) 199 .unwrap_or(8080); ··· 210 .with_graceful_shutdown(shutdown_signal()); 211 212 if let Err(err) = server.await { 213 - error!(error = %err, "server error"); 214 } 215 216 Ok(())
··· 22 use tower_governor::governor::GovernorConfigBuilder; 23 use tower_http::compression::CompressionLayer; 24 use tower_http::cors::{Any, CorsLayer}; 25 + use tracing::log; 26 use tracing_subscriber::{EnvFilter, fmt, prelude::*}; 27 28 pub mod helpers; ··· 88 #[tokio::main] 89 async fn main() -> Result<(), Box<dyn std::error::Error>> { 90 setup_tracing(); 91 + //TODO may need to change where this reads from? Like an env variable for it's location? Or arg? 92 dotenvy::from_path(Path::new("./pds.env"))?; 93 let pds_root = env::var("PDS_DATA_DIRECTORY")?; 94 let account_db_url = format!("{pds_root}/account.sqlite"); 95 96 let account_options = SqliteConnectOptions::new() 97 + .filename(account_db_url) 98 + .busy_timeout(Duration::from_secs(5)); 99 100 let account_pool = SqlitePoolOptions::new() 101 .max_connections(5) ··· 106 let options = SqliteConnectOptions::new() 107 .journal_mode(SqliteJournalMode::Wal) 108 .filename(bells_db_url) 109 + .create_if_missing(true) 110 + .busy_timeout(Duration::from_secs(5)); 111 let pds_gatekeeper_pool = SqlitePoolOptions::new() 112 .max_connections(5) 113 .connect_with(options) ··· 130 131 132 133 + AsyncSmtpTransport::<Tokio1Executor>::from_url(smtp_url.as_str())?.build(); 134 + //Email templates setup 135 + let mut hbs = Handlebars::new(); 136 + 137 + let users_email_directory = env::var("GATEKEEPER_EMAIL_TEMPLATES_DIRECTORY"); 138 + if let Ok(users_email_directory) = users_email_directory { 139 + hbs.register_template_file( 140 + "two_factor_code.hbs", 141 + format!("{users_email_directory}/two_factor_code.hbs"), 142 + )?; 143 + } else { 144 + let _ = hbs.register_embed_templates::<EmailTemplates>(); 145 + } 146 147 + let pds_base_url = 148 + env::var("PDS_BASE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string()); 149 150 let state = AppState { 151 account_pool, 152 pds_gatekeeper_pool, 153 reverse_proxy_client: client, 154 + pds_base_url, 155 mailer, 156 mailer_from: sent_from, 157 template_engine: Engine::from(hbs), ··· 159 160 // Rate limiting 161 //Allows 5 within 60 seconds, and after 60 should drop one off? So hit 5, then goes to 4 after 60 seconds. 162 + let create_session_governor_conf = GovernorConfigBuilder::default() 163 + .per_second(60) 164 + .burst_size(5) 165 + .finish() 166 + .expect("failed to create governor config. this should not happen and is a bug"); 167 + 168 + // Create a second config with the same settings for the other endpoint 169 + let sign_in_governor_conf = GovernorConfigBuilder::default() 170 .per_second(60) 171 .burst_size(5) 172 .finish() 173 .expect("failed to create governor config. this should not happen and is a bug"); 174 175 + let create_session_governor_limiter = create_session_governor_conf.limiter().clone(); 176 + let sign_in_governor_limiter = sign_in_governor_conf.limiter().clone(); 177 let interval = Duration::from_secs(60); 178 // a separate background task to clean up 179 std::thread::spawn(move || { 180 loop { 181 std::thread::sleep(interval); 182 + create_session_governor_limiter.retain_recent(); 183 + sign_in_governor_limiter.retain_recent(); 184 } 185 }); 186 ··· 201 ) 202 .route( 203 "/@atproto/oauth-provider/~api/sign-in", 204 + post(sign_in).layer(GovernorLayer::new(sign_in_governor_conf)), 205 ) 206 .route( 207 "/xrpc/com.atproto.server.createSession", 208 + post(create_session.layer(GovernorLayer::new(create_session_governor_conf))), 209 ) 210 .layer(CompressionLayer::new()) 211 .layer(cors) 212 .with_state(state); 213 214 + let host = env::var("GATEKEEPER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); 215 + let port: u16 = env::var("GATEKEEPER_PORT") 216 .ok() 217 .and_then(|s| s.parse().ok()) 218 .unwrap_or(8080); ··· 229 .with_graceful_shutdown(shutdown_signal()); 230 231 if let Err(err) = server.await { 232 + log::error!("server error:{err}"); 233 } 234 235 Ok(())
+5 -6
README.md
··· 12 13 ## 2FA 14 15 - - [x] Ability to turn on/off 2FA 16 - - [x] getSession overwrite to set the `emailAuthFactor` flag if the user has 2FA turned on 17 - - [x] send an email using the `PDS_EMAIL_SMTP_URL` with a handlebar email template like Bluesky's 2FA sign in email. 18 - - [ ] generate a 2FA code 19 - - [ ] createSession gatekeeping (It does stop logins, just eh, doesn't actually send a real code or check it yet) 20 - - [ ] oauth endpoint gatekeeping 21 22 ## Captcha on Create Account 23 ··· 25 26 # Setup 27 28 Nothing here yet! If you are brave enough to try before full release, let me know and I'll help you set it up. 29 But I want to run it locally on my own PDS first to test run it a bit. 30 ··· 37 path /xrpc/com.atproto.server.getSession 38 path /xrpc/com.atproto.server.updateEmail 39 path /xrpc/com.atproto.server.createSession 40 } 41 42 handle @gatekeeper {
··· 12 13 ## 2FA 14 15 + - Overrides The login endpoint to add 2FA for both Bluesky client logged in and OAuth logins 16 + - Overrides the settings endpoints as well. As long as you have a confirmed email you can turn on 2FA 17 18 ## Captcha on Create Account 19 ··· 21 22 # Setup 23 24 + We are getting close! Testing now 25 + 26 Nothing here yet! If you are brave enough to try before full release, let me know and I'll help you set it up. 27 But I want to run it locally on my own PDS first to test run it a bit. 28 ··· 35 path /xrpc/com.atproto.server.getSession 36 path /xrpc/com.atproto.server.updateEmail 37 path /xrpc/com.atproto.server.createSession 38 + path /@atproto/oauth-provider/~api/sign-in 39 } 40 41 handle @gatekeeper {

History

3 rounds 0 comments
sign up or login to add to the discussion
1 commit
expand
2FA gatekeeping
expand 0 comments
pull request successfully merged
12 commits
expand
Added rng code and place holder for db call. wont build
token create and all that
little clean up
app password support
Some clippy warning clean ups
Started better error handling. DOES NOT BUILD
clippy warnings and unwrap cleanups
oauth wip
wip finally returning an okay error for the ui
Crashes again
wip
HOLY COW THAT WORKED
expand 0 comments
16 commits
expand
Added rng code and place holder for db call. wont build
token create and all that
little clean up
app password support
Some clippy warning clean ups
Started better error handling. DOES NOT BUILD
clippy warnings and unwrap cleanups
oauth wip
wip finally returning an okay error for the ui
Crashes again
wip
HOLY COW THAT WORKED
Some more clean ups
custom email directory
Base 32 email tokens now
Final clean ups
expand 0 comments