Microservice to bring 2FA to self hosted PDSes
91
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 247 lines 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/signup - 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/signup - 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/signup?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/signup?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/signup?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}