2FA logins gatekept #1

merged
opened by baileytownsend.dev targeting main from feature/2faCodeGeneration
Changed files
+52 -49
migrations_bells_and_whistles
src
-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.
···
+1
Cargo.lock
··· 1669 "scrypt", 1670 "serde", 1671 "serde_json", 1672 "sqlx", 1673 "tokio", 1674 "tower-http",
··· 1669 "scrypt", 1670 "serde", 1671 "serde_json", 1672 + "sha2", 1673 "sqlx", 1674 "tokio", 1675 "tower-http",
+1
Cargo.toml
··· 25 rand = "0.9.2" 26 anyhow = "1.0.99" 27 chrono = "0.4.41"
··· 25 rand = "0.9.2" 26 anyhow = "1.0.99" 27 chrono = "0.4.41" 28 + sha2 = "0.10"
+27 -32
src/xrpc/helpers.rs
··· 1 use crate::AppState; 2 use crate::xrpc::helpers::TokenCheckError::InvalidToken; 3 use axum::body::{Body, to_bytes}; ··· 103 //Just going a head and doing uppercase here. 104 let slice_one = &full_code[0..5].to_ascii_uppercase(); 105 let slice_two = &full_code[5..10].to_ascii_uppercase(); 106 - format!("{}-{}", slice_one, slice_two) 107 } 108 109 pub enum TokenCheckError { ··· 151 let sha = hasher.finalize(); 152 let salt = hex::encode(&sha[..16]); 153 let hash_hex = scrypt_hex(password, &salt)?; 154 - Ok(format!("{}:{}", salt, hash_hex)) 155 } 156 157 - async fn verify_password(password: &str, password_scrypt: &str) -> Result<bool, StatusCode> { 158 // Expected format: "salt:hash" where hash is hex of scrypt(password, salt, 64 bytes) 159 let mut parts = password_scrypt.splitn(2, ':'); 160 let salt = match parts.next() { ··· 195 ) 196 .bind(identifier) 197 .fetch_optional(&state.account_pool) 198 - .await 199 - .map_err(|_| StatusCode::BAD_REQUEST)?, 200 IdentifierType::Handle => sqlx::query_as::<_, (String, String, String, String)>( 201 "SELECT account.did, account.passwordScrypt, account.email, actor.handle 202 FROM actor ··· 205 ) 206 .bind(identifier) 207 .fetch_optional(&state.account_pool) 208 - .await 209 - .map_err(|_| StatusCode::BAD_REQUEST)?, 210 IdentifierType::Did => sqlx::query_as::<_, (String, String, String, String)>( 211 "SELECT account.did, account.passwordScrypt, account.email, actor.handle 212 FROM actor ··· 215 ) 216 .bind(identifier) 217 .fetch_optional(&state.account_pool) 218 - .await 219 - .map_err(|_| StatusCode::BAD_REQUEST)?, 220 }; 221 222 if let Some((did, password_scrypt, email, handle)) = account_row { ··· 226 ) 227 .bind(did.clone()) 228 .fetch_optional(&state.pds_gatekeeper_pool) 229 - .await 230 - .map_err(|_| StatusCode::BAD_REQUEST)?; 231 232 let two_factor_required = match required_opt { 233 Some(row) => row.0 != 0, ··· 249 } 250 } 251 Err(err) => { 252 - log::error!("Error checking the app password: {}", err); 253 - Err(StatusCode::BAD_REQUEST) 254 } 255 }; 256 } ··· 266 .await 267 { 268 Ok(_) => { 269 - let _ = delete_all_email_tokens(&state.account_pool, did.clone()).await; 270 Ok(AuthResult::ProxyThrough) 271 } 272 Err(err) => Ok(AuthResult::TokenCheckFailed(err)), ··· 275 } 276 277 return match create_two_factor_token(&state.account_pool, did).await { 278 - //TODO replace unwraps with the mythical ? 279 Ok(code) => { 280 let mut email_data = Map::new(); 281 email_data.insert("token".to_string(), Value::from(code.clone())); 282 email_data.insert("handle".to_string(), Value::from(handle.clone())); 283 - //TODO bad unwrap 284 let email_body = state 285 .template_engine 286 - .render("two_factor_code.hbs", email_data) 287 - .unwrap(); 288 289 let email = Message::builder() 290 //TODO prob get the proper type in the state 291 - .from(state.mailer_from.parse().unwrap()) 292 - .to(email.parse().unwrap()) 293 .subject("Sign in to Bluesky") 294 .multipart( 295 MultiPart::alternative() // This is composed of two parts. 296 .singlepart( 297 SinglePart::builder() 298 .header(header::ContentType::TEXT_PLAIN) 299 - .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. 300 ) 301 .singlepart( 302 SinglePart::builder() 303 .header(header::ContentType::TEXT_HTML) 304 .body(email_body), 305 ), 306 - ) 307 - //TODO bad 308 - .unwrap(); 309 match state.mailer.send(email).await { 310 Ok(_) => Ok(AuthResult::TwoFactorRequired), 311 Err(err) => { 312 - log::error!("Error sending the 2FA email: {}", err); 313 - Err(StatusCode::BAD_REQUEST) 314 } 315 } 316 } 317 Err(err) => { 318 - log::error!("error on creating a 2fa token: {}", err); 319 - Err(StatusCode::BAD_REQUEST) 320 } 321 }; 322 } ··· 351 352 match res { 353 Ok(_) => Ok(token), 354 - Err(e) => { 355 - log::error!("Error creating a two factor token: {}", e); 356 - Err(anyhow::anyhow!(e)) 357 } 358 } 359 } ··· 383 .fetch_optional(account_db) 384 .await 385 .map_err(|err| { 386 - log::error!("Error getting the 2fa token: {}", err); 387 InvalidToken 388 })?; 389
··· 1 + use anyhow::anyhow; 2 use crate::AppState; 3 use crate::xrpc::helpers::TokenCheckError::InvalidToken; 4 use axum::body::{Body, to_bytes}; ··· 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 { ··· 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() { ··· 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 ··· 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 ··· 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 { ··· 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, ··· 246 } 247 } 248 Err(err) => { 249 + log::error!("Error checking the app password: {err}"); 250 + Err(err) 251 } 252 }; 253 } ··· 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)), ··· 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 } ··· 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 } ··· 378 .fetch_optional(account_db) 379 .await 380 .map_err(|err| { 381 + log::error!("Error getting the 2fa token: {err}"); 382 InvalidToken 383 })?; 384
+8 -4
src/middleware.rs
··· 1 - use crate::xrpc::helpers::json_error_response; 2 use axum::extract::Request; 3 use axum::http::{HeaderMap, StatusCode}; 4 use axum::middleware::Next; ··· 23 match token { 24 Ok(token) => { 25 match token { 26 - None => json_error_response(StatusCode::BAD_REQUEST, "TokenRequired", "").expect("Error creating an error response"), 27 Some(token) => { 28 let token = UntrustedToken::new(&token); 29 if token.is_err() { ··· 38 .expect("Error creating an error response"); 39 } 40 41 - let key = Hs256Key::new(env::var("PDS_JWT_SECRET").expect("PDS_JWT_SECRET not set in the pds.env")); 42 let token: Result<Token<TokenClaims>, ValidationError> = 43 Hs256.validator(&key).validate(&parsed_token); 44 if token.is_err() { ··· 55 } 56 Err(err) => { 57 log::error!("Error extracting token: {err}"); 58 - json_error_response(StatusCode::BAD_REQUEST, "InvalidToken", "").expect("Error creating an error response") 59 } 60 } 61 }
··· 1 + use crate::helpers::json_error_response; 2 use axum::extract::Request; 3 use axum::http::{HeaderMap, StatusCode}; 4 use axum::middleware::Next; ··· 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() { ··· 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() { ··· 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 }
-1
src/xrpc/mod.rs
··· 1 pub mod com_atproto_server; 2 - pub mod helpers;
··· 1 pub mod com_atproto_server;
+10 -2
src/main.rs
··· 133 AsyncSmtpTransport::<Tokio1Executor>::from_url(smtp_url.as_str())?.build(); 134 //Email templates setup 135 let mut hbs = Handlebars::new(); 136 - //TODO add an override to manually load in the hbs templates 137 - let _ = hbs.register_embed_templates::<EmailTemplates>(); 138 139 let pds_base_url = 140 env::var("PDS_BASE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
··· 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());
+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 {
-1
src/helpers.rs
··· 11 use lettre::message::{MultiPart, SinglePart, header}; 12 use lettre::{AsyncTransport, Message}; 13 use rand::Rng; 14 - use rand::distr::{Alphabetic, Alphanumeric, SampleString}; 15 use serde::de::DeserializeOwned; 16 use serde_json::{Map, Value}; 17 use sha2::{Digest, Sha256};
··· 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};