Microservice to bring 2FA to self hosted PDSes
at main 7.5 kB view raw
1use crate::AppState; 2use crate::helpers::{generate_gate_token, json_error_response}; 3use axum::Form; 4use axum::extract::{Query, State}; 5use axum::http::StatusCode; 6use axum::response::{IntoResponse, Redirect, Response}; 7use axum_template::RenderHtml; 8use chrono::{DateTime, Utc}; 9use serde::{Deserialize, Serialize}; 10use std::env; 11use tracing::log; 12 13#[derive(Deserialize)] 14pub struct GateQuery { 15 handle: String, 16 state: String, 17 #[serde(default)] 18 error: Option<String>, 19 #[serde(default)] 20 redirect_url: Option<String>, 21} 22 23#[derive(Deserialize, Serialize)] 24pub struct CaptchaPage { 25 handle: String, 26 state: String, 27 captcha_site_key: String, 28 error_message: Option<String>, 29 pds: String, 30 redirect_url: Option<String>, 31} 32 33#[derive(Deserialize)] 34pub struct CaptchaForm { 35 #[serde(rename = "h-captcha-response")] 36 h_captcha_response: String, 37 #[serde(default)] 38 redirect_url: Option<String>, 39} 40 41/// GET /gate - Display the captcha page 42pub async fn get_gate( 43 Query(params): Query<GateQuery>, 44 State(state): State<AppState>, 45) -> impl IntoResponse { 46 let hcaptcha_site_key = match env::var("PDS_HCAPTCHA_SITE_KEY") { 47 Ok(key) => key, 48 Err(_) => { 49 return json_error_response( 50 StatusCode::INTERNAL_SERVER_ERROR, 51 "ServerError", 52 "hCaptcha is not configured", 53 ) 54 .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()); 55 } 56 }; 57 58 let error_message = match params.error { 59 None => None, 60 Some(error) => Some(html_escape::encode_safe(&error).to_string()), 61 }; 62 63 RenderHtml( 64 "captcha.hbs", 65 state.template_engine, 66 CaptchaPage { 67 handle: params.handle, 68 state: params.state, 69 captcha_site_key: hcaptcha_site_key, 70 error_message, 71 pds: state.app_config.pds_service_did.replace("did:web:", ""), 72 redirect_url: params.redirect_url, 73 }, 74 ) 75 .into_response() 76} 77 78/// POST /gate - Verify captcha and redirect 79pub async fn post_gate( 80 State(state): State<AppState>, 81 Query(params): Query<GateQuery>, 82 Form(form): Form<CaptchaForm>, 83) -> Response { 84 // Verify hCaptcha response 85 let hcaptcha_secret = match env::var("PDS_HCAPTCHA_SECRET_KEY") { 86 Ok(secret) => secret, 87 Err(_) => { 88 return json_error_response( 89 StatusCode::INTERNAL_SERVER_ERROR, 90 "ServerError", 91 "hCaptcha is not configured", 92 ) 93 .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()); 94 } 95 }; 96 97 let client = match reqwest::Client::builder() 98 .timeout(std::time::Duration::from_secs(10)) 99 .build() 100 { 101 Ok(c) => c, 102 Err(e) => { 103 log::error!("Failed to create HTTP client: {}", e); 104 return json_error_response( 105 StatusCode::INTERNAL_SERVER_ERROR, 106 "ServerError", 107 "Failed to verify captcha", 108 ) 109 .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()); 110 } 111 }; 112 113 #[derive(Deserialize, Serialize)] 114 struct HCaptchaResponse { 115 success: bool, 116 challenge_ts: DateTime<Utc>, 117 hostname: String, 118 #[serde(rename = "error-codes", default)] 119 error_codes: Vec<String>, 120 } 121 122 let verification_result = client 123 .post("https://api.hcaptcha.com/siteverify") 124 .form(&[ 125 ("secret", hcaptcha_secret.as_str()), 126 ("response", form.h_captcha_response.as_str()), 127 ]) 128 .send() 129 .await; 130 131 let verification_response = match verification_result { 132 Ok(resp) => resp, 133 Err(e) => { 134 log::error!("Failed to verify hCaptcha: {}", e); 135 136 return Redirect::to(&format!( 137 "/gate?handle={}&state={}&error={}", 138 url_encode(&params.handle), 139 url_encode(&params.state), 140 url_encode("Verification failed. Please try again.") 141 )) 142 .into_response(); 143 } 144 }; 145 146 let captcha_result: HCaptchaResponse = match verification_response.json().await { 147 Ok(result) => result, 148 Err(e) => { 149 log::error!("Failed to parse hCaptcha response: {}", e); 150 151 return Redirect::to(&format!( 152 "/gate?handle={}&state={}&error={}", 153 url_encode(&params.handle), 154 url_encode(&params.state), 155 url_encode("Verification failed. Please try again.") 156 )) 157 .into_response(); 158 } 159 }; 160 161 if !captcha_result.success { 162 log::warn!( 163 "hCaptcha verification failed for handle {}: {:?}", 164 params.handle, 165 captcha_result.error_codes 166 ); 167 return Redirect::to(&format!( 168 "/gate?handle={}&state={}&error={}", 169 url_encode(&params.handle), 170 url_encode(&params.state), 171 url_encode("Verification failed. Please try again.") 172 )) 173 .into_response(); 174 } 175 176 // Generate secure JWE verification token 177 let code = match generate_gate_token(&params.handle, &state.app_config.gate_jwe_key) { 178 Ok(token) => token, 179 Err(e) => { 180 log::error!("Failed to generate gate token: {}", e); 181 return json_error_response( 182 StatusCode::INTERNAL_SERVER_ERROR, 183 "ServerError", 184 "Failed to create verification code", 185 ) 186 .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()); 187 } 188 }; 189 190 let now = Utc::now(); 191 192 // Store the encrypted token in the database 193 let result = sqlx::query( 194 "INSERT INTO gate_codes (code, handle, created_at) 195 VALUES (?, ?, ?)", 196 ) 197 .bind(&code) 198 .bind(&params.handle) 199 .bind(now) 200 .execute(&state.pds_gatekeeper_pool) 201 .await; 202 203 if let Err(e) = result { 204 log::error!("Failed to store gate code: {}", e); 205 return json_error_response( 206 StatusCode::INTERNAL_SERVER_ERROR, 207 "ServerError", 208 "Failed to create verification code", 209 ) 210 .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()); 211 } 212 213 // Redirects by origin if it's found. If not redirect to the configured URL. 214 let mut base_redirect = state.app_config.default_successful_redirect_url.clone(); 215 if let Some(ref redirect_url) = form.redirect_url { 216 let trimmed = redirect_url.trim(); 217 if !trimmed.is_empty() 218 && (trimmed.starts_with("https://") || trimmed.starts_with("http://")) 219 { 220 base_redirect = trimmed.trim_end_matches('/').to_string(); 221 } 222 } 223 224 let base_redirect = match state 225 .app_config 226 .captcha_success_redirects 227 .contains(&base_redirect) 228 { 229 true => base_redirect, 230 false => state.app_config.default_successful_redirect_url.clone(), 231 }; 232 233 // Redirect to client app with code and state 234 let redirect_url = format!( 235 "{}/?code={}&state={}", 236 base_redirect, 237 url_encode(&code), 238 url_encode(&params.state) 239 ); 240 241 Redirect::to(&redirect_url).into_response() 242} 243 244/// Simple URL encode function 245fn url_encode(s: &str) -> String { 246 urlencoding::encode(s).to_string() 247}