Skip to content

Commit ca95239

Browse files
committed
Add CAPTCHA verification support
Verifies cap-token from registration requests using the configured Cap service when CAP_INSTANCE_URI and CAP_SECRET are set; otherwise falls back to a no-op implementation.
1 parent 9f6badf commit ca95239

10 files changed

Lines changed: 472 additions & 65 deletions

File tree

Cargo.lock

Lines changed: 254 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ edition = "2024"
77
axum = { version = "0.8", features = ["tokio"] }
88
chrono = { version = "0.4", features = ["serde"] }
99
constant_time_eq = "0.5"
10+
hyper = "1.10"
11+
reqwest = { version = "0.13", default-features = false, features = ["http2", "json"] }
1012
serde = { version = "1.0", features = ["derive"] }
1113
serde_json = "1.0"
1214
sqlx = { version = "0.9", features = [

README.md

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@ SQLite database.
88

99
The following environment variables are used for configuration:
1010

11-
| Variable | Description | Default value |
12-
|-------------|-------------------------------------|----------------|
13-
| API_KEY | Key protecting the /admin endpoints | |
14-
| CORS_ORIGIN | CORS preflight URL restriction | * |
15-
| SQLITE_DB | Path to SQLite database file | data.db |
16-
| LISTEN_ADDR | IP and port to listen on | 127.0.0.1:3000 |
11+
| Variable | Description | Default value |
12+
|------------------|-------------------------------------|----------------|
13+
| API_KEY | Key protecting the /admin endpoints | |
14+
| CORS_ORIGIN | CORS preflight URL restriction | * |
15+
| SQLITE_DB | Path to SQLite database file | data.db |
16+
| LISTEN_ADDR | IP and port to listen on | 127.0.0.1:3000 |
17+
| CAP_INSTANCE_URI | Cap site endpoint | |
18+
| CAP_SECRET | Cap site secret | |
1719

1820
### Sample Docker Compose
1921

@@ -65,6 +67,11 @@ date: Sat, 10 Jun 2023 19:16:20 GMT
6567
6668
### Registering as a visitor
6769
70+
Due to problems with bots making spam registrations the `/register` endpoint can be configured to require a
71+
[proof-of-work](https://en.wikipedia.org/wiki/Proof_of_work) to be provided by the client using the self-hostable
72+
CAPTCHA tool [Cap](https://trycap.dev/). This is only enabled/required if **both** `CAP_INSTANCE_URI` and `CAP_SECRET`
73+
are set.
74+
6875
Note that the fields `email` and `extra` are not shown in the public `GET /visitors` listing, but are intended only
6976
for the party organizers.
7077
@@ -81,6 +88,27 @@ content-length: 0
8188
date: Sat, 10 Jun 2023 19:17:23 GMT
8289
```
8390

91+
#### With proof-of-work
92+
93+
See the [Cap Quickstart](https://trycap.dev/guide/) for detailed information about how to add the Cap widget to your
94+
frontend.
95+
96+
The `CAP_INSTANCE_URI` should point to the actual verification endpoint, like
97+
`https://<your-instance>/<site-key>/siteverify`. `CAP_SECRET` is your generated secret key, like `sk-xxx`.
98+
99+
```sh
100+
curl -i -H 'Content-Type: application/json' \
101+
-X POST \
102+
-d '{"nick":"Lorem","group":"Ipsum","email":"lorem@example.com","extra":"Allergic to metaballs","cap-token":"fb69400c43:1ab132ceaeb7be79:d33ea92570f08527ba917c8ba24cc3"}' \
103+
http://localhost:3000/register
104+
```
105+
106+
```
107+
HTTP/1.1 201 Created
108+
content-length: 0
109+
date: Sat, 10 Jun 2023 19:17:23 GMT
110+
```
111+
84112
### Fetching full visitor data
85113

86114
This is only available for organizers, authorized by API_KEY.

src/admin.rs

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use constant_time_eq::constant_time_eq;
1010
use tower::ServiceBuilder;
1111
use tower_http::validate_request::{ValidateRequest, ValidateRequestHeaderLayer};
1212

13-
use crate::{ApiState, db, error::ApiError, time::TimeService};
13+
use crate::{ApiState, captcha::CaptchaService, db, error::ApiError, time::TimeService};
1414

1515
#[derive(Clone)]
1616
struct BearerTokenHeader {
@@ -20,7 +20,7 @@ struct BearerTokenHeader {
2020
impl BearerTokenHeader {
2121
fn new(key: &str) -> Self {
2222
Self {
23-
key: HeaderValue::from_str(&format!("Bearer {}", key)).unwrap(),
23+
key: HeaderValue::from_str(&format!("Bearer {key}")).unwrap(),
2424
}
2525
}
2626

@@ -44,7 +44,7 @@ impl<B> ValidateRequest<B> for BearerTokenHeader {
4444
}
4545
}
4646

47-
pub fn routes<T: TimeService>() -> Router<ApiState<T>> {
47+
pub fn routes<T: TimeService, C: CaptchaService>() -> Router<ApiState<T, C>> {
4848
match env::var("API_KEY") {
4949
Err(_) => {
5050
eprintln!("API_KEY not set, /admin endpoints will be disabled");
@@ -57,21 +57,21 @@ pub fn routes<T: TimeService>() -> Router<ApiState<T>> {
5757
}
5858
}
5959

60-
async fn list_visitors<T: TimeService>(
61-
State(state): State<ApiState<T>>,
60+
async fn list_visitors<T: TimeService, C: CaptchaService>(
61+
State(state): State<ApiState<T, C>>,
6262
) -> Result<(StatusCode, Json<Vec<db::Visitor>>), ApiError> {
63-
let visitors = sqlx::query_as::<_, db::Visitor>(r#"SELECT * FROM visitor ORDER BY id"#)
63+
let visitors = sqlx::query_as::<_, db::Visitor>(r"SELECT * FROM visitor ORDER BY id")
6464
.fetch_all(&state.db)
6565
.await?;
6666

6767
Ok((StatusCode::OK, Json(visitors)))
6868
}
6969

70-
async fn delete_visitor<T: TimeService>(
70+
async fn delete_visitor<T: TimeService, C: CaptchaService>(
7171
Path(id): Path<i32>,
72-
State(state): State<ApiState<T>>,
72+
State(state): State<ApiState<T, C>>,
7373
) -> Result<StatusCode, ApiError> {
74-
let rows = sqlx::query(r#"DELETE FROM visitor WHERE id = ?"#)
74+
let rows = sqlx::query(r"DELETE FROM visitor WHERE id = ?")
7575
.bind(id)
7676
.execute(&state.db)
7777
.await?
@@ -93,7 +93,7 @@ mod test {
9393
use tower::ServiceExt;
9494

9595
use crate::{
96-
testing,
96+
captcha, testing,
9797
time::{ConstantTimeService, TimeService},
9898
};
9999

@@ -103,7 +103,7 @@ mod test {
103103

104104
let time = ConstantTimeService::new();
105105
let db = testing::database().await;
106-
let api = crate::api(time.clone(), db.clone());
106+
let api = crate::api(time.clone(), captcha::NoOp::new(), db.clone());
107107

108108
let response = api
109109
.oneshot(
@@ -126,11 +126,11 @@ mod test {
126126

127127
let time = ConstantTimeService::new();
128128
let db = testing::database().await;
129-
let api = crate::api(time.clone(), db.clone());
129+
let api = crate::api(time.clone(), captcha::NoOp::new(), db.clone());
130130

131131
testing::insert_visitor(&db, "Groupless", None).await;
132132

133-
testing::insert_visitor(&db, "With Group", Some("Awesome".into())).await;
133+
testing::insert_visitor(&db, "With Group", Some("Awesome")).await;
134134

135135
let response = api
136136
.oneshot(
@@ -171,7 +171,7 @@ mod test {
171171

172172
let time = ConstantTimeService::new();
173173
let db = testing::database().await;
174-
let api = crate::api(time.clone(), db.clone());
174+
let api = crate::api(time.clone(), captcha::NoOp::new(), db.clone());
175175

176176
let response = api
177177
.oneshot(
@@ -194,7 +194,7 @@ mod test {
194194

195195
let time = ConstantTimeService::new();
196196
let db = testing::database().await;
197-
let api = crate::api(time.clone(), db.clone());
197+
let api = crate::api(time.clone(), captcha::NoOp::new(), db.clone());
198198

199199
testing::insert_visitor(&db, "Groupless", None).await;
200200

src/captcha.rs

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
use hyper::Uri;
2+
use serde::{Deserialize, Serialize};
3+
4+
pub use cap::Cap;
5+
6+
pub enum Error {
7+
InvalidToken,
8+
ServiceError(reqwest::Error),
9+
}
10+
11+
impl From<reqwest::Error> for Error {
12+
fn from(value: reqwest::Error) -> Self {
13+
Error::ServiceError(value)
14+
}
15+
}
16+
17+
pub trait CaptchaService: Clone + Send + Sync + 'static {
18+
fn verify(&self, token: &str) -> impl Future<Output = Result<(), Error>> + Send;
19+
}
20+
21+
#[derive(Clone)]
22+
pub struct NoOp {}
23+
24+
impl NoOp {
25+
pub fn new() -> Self {
26+
Self {}
27+
}
28+
}
29+
30+
impl CaptchaService for NoOp {
31+
async fn verify(&self, _token: &str) -> Result<(), Error> {
32+
Ok(())
33+
}
34+
}
35+
36+
mod cap {
37+
#[allow(clippy::wildcard_imports)]
38+
use super::*;
39+
40+
#[derive(Serialize)]
41+
struct VerificationRequest<'a> {
42+
secret: &'a str,
43+
response: &'a str,
44+
}
45+
46+
#[derive(Deserialize)]
47+
struct VerificationResponse {
48+
success: bool,
49+
}
50+
51+
#[derive(Clone)]
52+
pub struct Cap {
53+
uri: Uri,
54+
secret: String,
55+
}
56+
57+
impl Cap {
58+
pub fn new<S: Into<String>>(uri: Uri, secret: S) -> Self {
59+
Self {
60+
uri,
61+
secret: secret.into(),
62+
}
63+
}
64+
}
65+
66+
impl CaptchaService for Cap {
67+
async fn verify(&self, token: &str) -> Result<(), Error> {
68+
reqwest::Client::new()
69+
.post(self.uri.to_string())
70+
.json(&VerificationRequest {
71+
secret: &self.secret,
72+
response: token,
73+
})
74+
.send()
75+
.await
76+
.map_err(Error::from)?
77+
.json::<VerificationResponse>()
78+
.await
79+
.map_err(Error::from)
80+
.and_then(|response| {
81+
if response.success {
82+
Ok(())
83+
} else {
84+
Err(Error::InvalidToken)
85+
}
86+
})
87+
}
88+
}
89+
}

src/cors.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,13 @@ mod test {
3030
use hyper::{Request, StatusCode};
3131
use tower::ServiceExt;
3232

33-
use crate::{testing, time::ConstantTimeService};
33+
use crate::{captcha, testing, time::ConstantTimeService};
3434

3535
#[tokio::test]
3636
async fn should_allow_any_by_default() {
3737
let time = ConstantTimeService::new();
3838
let db = testing::database().await;
39-
let api = crate::api(time.clone(), db.clone());
39+
let api = crate::api(time.clone(), captcha::NoOp::new(), db.clone());
4040

4141
let response = api
4242
.oneshot(
@@ -79,7 +79,7 @@ mod test {
7979

8080
let time = ConstantTimeService::new();
8181
let db = testing::database().await;
82-
let api = crate::api(time.clone(), db.clone());
82+
let api = crate::api(time.clone(), captcha::NoOp::new(), db.clone());
8383

8484
let response = api
8585
.oneshot(

src/error.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
use std::borrow::Cow;
22

33
use axum::{
4+
Json,
45
http::StatusCode,
56
response::{IntoResponse, Response},
6-
Json,
77
};
88
use serde::Serialize;
99
use tower_governor::GovernorError;
1010

11+
use crate::captcha;
12+
1113
#[derive(Serialize)]
1214
pub(crate) struct ApiError {
1315
#[serde(skip_serializing)]
@@ -21,6 +23,21 @@ impl IntoResponse for ApiError {
2123
}
2224
}
2325

26+
impl From<captcha::Error> for ApiError {
27+
fn from(error: captcha::Error) -> Self {
28+
match error {
29+
captcha::Error::InvalidToken => Self {
30+
code: StatusCode::BAD_REQUEST,
31+
error: "captcha validation failed".to_owned(),
32+
},
33+
captcha::Error::ServiceError(_) => Self {
34+
code: StatusCode::INTERNAL_SERVER_ERROR,
35+
error: "captcha service unreachable".to_owned(),
36+
},
37+
}
38+
}
39+
}
40+
2441
impl From<sqlx::Error> for ApiError {
2542
fn from(error: sqlx::Error) -> Self {
2643
match error {

0 commit comments

Comments
 (0)