forked from
baileytownsend.dev/pds-gatekeeper
Microservice to bring 2FA to self hosted PDSes
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(¶ms.handle),
139 url_encode(¶ms.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(¶ms.handle),
154 url_encode(¶ms.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(¶ms.handle),
170 url_encode(¶ms.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(¶ms.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(¶ms.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(¶ms.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}