forked from
baileytownsend.dev/pds-gatekeeper
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(), ¶ms, &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}