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 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(), ¶ms, &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}