+11
-7
Cargo.toml
+11
-7
Cargo.toml
···
3
4
5
6
+
[dependencies]
7
+
axum = { version = "0.8.4", features = ["macros", "json"] }
8
+
tokio = { version = "1.47.1", features = ["rt-multi-thread", "macros", "signal"] }
9
+
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "sqlite", "migrate", "chrono"] }
10
+
dotenvy = "0.15.7"
11
+
serde = { version = "1.0", features = ["derive"] }
12
+
serde_json = "1.0"
13
14
15
···
22
handlebars = { version = "6.3.2", features = ["rust-embed"] }
23
rust-embed = "8.7.2"
24
axum-template = { version = "3.0.0", features = ["handlebars"] }
25
+
rand = "0.9.2"
26
+
anyhow = "1.0.99"
27
+
chrono = "0.4.41"
28
+
sha2 = "0.10"
-3
migrations_bells_and_whistles/.keep
-3
migrations_bells_and_whistles/.keep
+339
-59
src/xrpc/helpers.rs
+339
-59
src/xrpc/helpers.rs
···
1
use axum::body::{Body, to_bytes};
2
use axum::extract::Request;
3
-
use axum::http::{HeaderMap, Method, StatusCode, Uri};
4
use axum::http::header::CONTENT_TYPE;
5
use axum::response::{IntoResponse, Response};
6
use serde::de::DeserializeOwned;
7
-
use tracing::error;
8
-
9
-
use crate::AppState;
10
11
/// The result of a proxied call that attempts to parse JSON.
12
pub enum ProxiedResult<T> {
···
16
17
18
19
20
21
···
57
58
59
60
61
62
63
···
74
75
76
77
78
79
80
81
82
-
83
-
84
-
85
-
86
-
87
-
88
-
89
-
90
-
91
-
92
-
93
-
94
-
95
-
96
-
97
-
98
-
99
-
100
-
101
-
102
-
103
-
104
-
105
-
106
-
107
-
108
-
109
-
110
-
111
-
112
-
113
-
114
-
115
-
116
-
117
-
118
-
119
-
120
-
121
-
122
-
123
-
124
-
125
}
126
}
127
128
129
-
/// Build a JSON error response with the required Content-Type header
130
-
/// Content-Type: application/json;charset=utf-8
131
-
/// Body shape: { "error": string, "message": string }
132
-
133
-
134
-
135
-
136
-
137
-
138
-
139
140
141
142
143
144
145
146
147
148
-
.body(Body::from(body_str))
149
-
.map_err(|_| StatusCode::BAD_REQUEST)
150
}
···
1
+
use anyhow::anyhow;
2
+
use crate::AppState;
3
+
use crate::xrpc::helpers::TokenCheckError::InvalidToken;
4
use axum::body::{Body, to_bytes};
5
use axum::extract::Request;
6
use axum::http::header::CONTENT_TYPE;
7
+
use axum::http::{HeaderMap, StatusCode, Uri};
8
use axum::response::{IntoResponse, Response};
9
+
use axum_template::TemplateEngine;
10
+
use chrono::Utc;
11
+
use lettre::message::{MultiPart, SinglePart, header};
12
+
use lettre::{AsyncTransport, Message};
13
+
use rand::distr::{Alphanumeric, SampleString};
14
use serde::de::DeserializeOwned;
15
+
use serde_json::{Map, Value};
16
+
use sqlx::SqlitePool;
17
+
use tracing::{error, log};
18
19
/// The result of a proxied call that attempts to parse JSON.
20
pub enum ProxiedResult<T> {
···
24
25
26
27
+
/// Proxy the incoming request to the PDS base URL plus the provided path and attempt to parse
28
+
/// the successful response body as JSON into `T`.
29
+
///
30
+
pub async fn proxy_get_json<T>(
31
+
state: &AppState,
32
+
mut req: Request,
33
34
35
···
71
72
73
74
+
}
75
+
}
76
77
+
/// Build a JSON error response with the required Content-Type header
78
+
/// Content-Type: application/json;charset=utf-8
79
+
/// Body shape: { "error": string, "message": string }
80
81
82
···
93
94
95
96
+
.body(Body::from(body_str))
97
+
.map_err(|_| StatusCode::BAD_REQUEST)
98
+
}
99
100
+
/// Creates a random token of 10 characters for email 2FA
101
+
pub fn get_random_token() -> String {
102
+
let full_code = Alphanumeric.sample_string(&mut rand::rng(), 10);
103
+
//The PDS implementation creates in lowercase, then converts to uppercase.
104
+
//Just going a head and doing uppercase here.
105
+
let slice_one = &full_code[0..5].to_ascii_uppercase();
106
+
let slice_two = &full_code[5..10].to_ascii_uppercase();
107
+
format!("{slice_one}-{slice_two}")
108
+
}
109
110
+
pub enum TokenCheckError {
111
+
InvalidToken,
112
+
ExpiredToken,
113
+
}
114
115
+
pub enum AuthResult {
116
+
WrongIdentityOrPassword,
117
+
TwoFactorRequired,
118
+
/// User does not have 2FA enabled, or using an app password, or passes it
119
+
ProxyThrough,
120
+
TokenCheckFailed(TokenCheckError),
121
+
}
122
123
+
pub enum IdentifierType {
124
+
Email,
125
+
Did,
126
+
Handle,
127
+
}
128
129
+
impl IdentifierType {
130
+
fn what_is_it(identifier: String) -> Self {
131
+
if identifier.contains("@") {
132
+
IdentifierType::Email
133
+
} else if identifier.contains("did:") {
134
+
IdentifierType::Did
135
+
} else {
136
+
IdentifierType::Handle
137
+
}
138
}
139
}
140
141
+
fn scrypt_hex(password: &str, salt: &str) -> anyhow::Result<String> {
142
+
let params = scrypt::Params::new(14, 8, 1, 64)?;
143
+
let mut derived = [0u8; 64];
144
+
scrypt::scrypt(password.as_bytes(), salt.as_bytes(), ¶ms, &mut derived)?;
145
+
Ok(hex::encode(derived))
146
+
}
147
148
+
pub fn hash_app_password(did: &str, password: &str) -> anyhow::Result<String> {
149
+
use sha2::{Digest, Sha256};
150
+
let mut hasher = Sha256::new();
151
+
hasher.update(did.as_bytes());
152
+
let sha = hasher.finalize();
153
+
let salt = hex::encode(&sha[..16]);
154
+
let hash_hex = scrypt_hex(password, &salt)?;
155
+
Ok(format!("{salt}:{hash_hex}"))
156
+
}
157
158
+
async fn verify_password(password: &str, password_scrypt: &str) -> anyhow::Result<bool> {
159
+
// Expected format: "salt:hash" where hash is hex of scrypt(password, salt, 64 bytes)
160
+
let mut parts = password_scrypt.splitn(2, ':');
161
+
let salt = match parts.next() {
162
+
Some(s) if !s.is_empty() => s,
163
+
_ => return Ok(false),
164
+
};
165
+
let stored_hash_hex = match parts.next() {
166
+
Some(h) if !h.is_empty() => h,
167
+
_ => return Ok(false),
168
+
};
169
+
170
+
// Derive using the shared helper and compare
171
+
let derived_hex = match scrypt_hex(password, salt) {
172
+
Ok(h) => h,
173
+
Err(_) => return Ok(false),
174
+
};
175
176
+
Ok(derived_hex.as_str() == stored_hash_hex)
177
+
}
178
179
+
/// Handles the auth checks along with sending a 2fa email
180
+
pub async fn preauth_check(
181
+
state: &AppState,
182
+
identifier: &str,
183
+
password: &str,
184
+
two_factor_code: Option<String>,
185
+
) -> anyhow::Result<AuthResult> {
186
+
// Determine identifier type
187
+
let id_type = IdentifierType::what_is_it(identifier.to_string());
188
+
189
+
// Query account DB for did and passwordScrypt based on identifier type
190
+
let account_row: Option<(String, String, String, String)> = match id_type {
191
+
IdentifierType::Email => sqlx::query_as::<_, (String, String, String, String)>(
192
+
"SELECT account.did, account.passwordScrypt, account.email, actor.handle
193
+
FROM actor
194
+
LEFT JOIN account ON actor.did = account.did
195
+
where account.email = ? LIMIT 1",
196
+
)
197
+
.bind(identifier)
198
+
.fetch_optional(&state.account_pool)
199
+
.await?,
200
+
IdentifierType::Handle => sqlx::query_as::<_, (String, String, String, String)>(
201
+
"SELECT account.did, account.passwordScrypt, account.email, actor.handle
202
+
FROM actor
203
+
LEFT JOIN account ON actor.did = account.did
204
+
where actor.handle = ? LIMIT 1",
205
+
)
206
+
.bind(identifier)
207
+
.fetch_optional(&state.account_pool)
208
+
.await?,
209
+
IdentifierType::Did => sqlx::query_as::<_, (String, String, String, String)>(
210
+
"SELECT account.did, account.passwordScrypt, account.email, actor.handle
211
+
FROM actor
212
+
LEFT JOIN account ON actor.did = account.did
213
+
where account.did = ? LIMIT 1",
214
+
)
215
+
.bind(identifier)
216
+
.fetch_optional(&state.account_pool)
217
+
.await?,
218
+
};
219
+
220
+
if let Some((did, password_scrypt, email, handle)) = account_row {
221
+
// Check two-factor requirement for this DID in the gatekeeper DB
222
+
let required_opt = sqlx::query_as::<_, (u8,)>(
223
+
"SELECT required FROM two_factor_accounts WHERE did = ? LIMIT 1",
224
+
)
225
+
.bind(did.clone())
226
+
.fetch_optional(&state.pds_gatekeeper_pool)
227
+
.await?;
228
+
229
+
let two_factor_required = match required_opt {
230
+
Some(row) => row.0 != 0,
231
+
None => false,
232
+
};
233
+
234
+
if two_factor_required {
235
+
// Verify password before proceeding to 2FA email step
236
+
let verified = verify_password(password, &password_scrypt).await?;
237
+
if !verified {
238
+
//Theres a chance it could be an app password so check that as well
239
+
return match verify_app_password(&state.account_pool, &did, password).await {
240
+
Ok(valid) => {
241
+
if valid {
242
+
//Was a valid app password up to the PDS now
243
+
Ok(AuthResult::ProxyThrough)
244
+
} else {
245
+
Ok(AuthResult::WrongIdentityOrPassword)
246
+
}
247
+
}
248
+
Err(err) => {
249
+
log::error!("Error checking the app password: {err}");
250
+
Err(err)
251
+
}
252
+
};
253
+
}
254
+
//Two factor is required and a taken was provided
255
+
if let Some(two_factor_code) = two_factor_code {
256
+
//It seems it sends over a empty on login without it set? As in no input is shown on the ui for the first login try
257
+
if !two_factor_code.is_empty() {
258
+
return match assert_valid_token(
259
+
&state.account_pool,
260
+
did.clone(),
261
+
two_factor_code,
262
+
)
263
+
.await
264
+
{
265
+
Ok(_) => {
266
+
let result_of_cleanup = delete_all_email_tokens(&state.account_pool, did.clone()).await;
267
+
if result_of_cleanup.is_err(){
268
+
log::error!("There was an error deleting the email tokens after login: {:?}", result_of_cleanup.err())
269
+
}
270
+
Ok(AuthResult::ProxyThrough)
271
+
}
272
+
Err(err) => Ok(AuthResult::TokenCheckFailed(err)),
273
+
};
274
+
}
275
+
}
276
+
277
+
return match create_two_factor_token(&state.account_pool, did).await {
278
+
Ok(code) => {
279
+
let mut email_data = Map::new();
280
+
email_data.insert("token".to_string(), Value::from(code.clone()));
281
+
email_data.insert("handle".to_string(), Value::from(handle.clone()));
282
+
let email_body = state
283
+
.template_engine
284
+
.render("two_factor_code.hbs", email_data)?;
285
+
286
+
let email = Message::builder()
287
+
//TODO prob get the proper type in the state
288
+
.from(state.mailer_from.parse()?)
289
+
.to(email.parse()?)
290
+
.subject("Sign in to Bluesky")
291
+
.multipart(
292
+
MultiPart::alternative() // This is composed of two parts.
293
+
.singlepart(
294
+
SinglePart::builder()
295
+
.header(header::ContentType::TEXT_PLAIN)
296
+
.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.
297
+
)
298
+
.singlepart(
299
+
SinglePart::builder()
300
+
.header(header::ContentType::TEXT_HTML)
301
+
.body(email_body),
302
+
),
303
+
)?;
304
+
match state.mailer.send(email).await {
305
+
Ok(_) => Ok(AuthResult::TwoFactorRequired),
306
+
Err(err) => {
307
+
log::error!("Error sending the 2FA email: {err}");
308
+
Err(anyhow!(err))
309
+
}
310
+
}
311
+
}
312
+
Err(err) => {
313
+
log::error!("error on creating a 2fa token: {err}");
314
+
Err(anyhow!(err))
315
+
}
316
+
};
317
+
}
318
+
}
319
320
+
// No local 2FA requirement (or account not found)
321
+
Ok(AuthResult::ProxyThrough)
322
+
}
323
324
+
pub async fn create_two_factor_token(
325
+
account_db: &SqlitePool,
326
+
did: String,
327
+
) -> anyhow::Result<String> {
328
+
let purpose = "2fa_code";
329
+
330
+
let token = get_random_token();
331
+
let right_now = Utc::now();
332
+
333
+
let res = sqlx::query(
334
+
"INSERT INTO email_token (purpose, did, token, requestedAt)
335
+
VALUES (?, ?, ?, ?)
336
+
ON CONFLICT(purpose, did) DO UPDATE SET
337
+
token=excluded.token,
338
+
requestedAt=excluded.requestedAt",
339
+
)
340
+
.bind(purpose)
341
+
.bind(&did)
342
+
.bind(&token)
343
+
.bind(right_now)
344
+
.execute(account_db)
345
+
.await;
346
+
347
+
match res {
348
+
Ok(_) => Ok(token),
349
+
Err(err) => {
350
+
log::error!("Error creating a two factor token: {err}");
351
+
Err(anyhow::anyhow!(err))
352
+
}
353
+
}
354
+
}
355
356
+
pub async fn delete_all_email_tokens(account_db: &SqlitePool, did: String) -> anyhow::Result<()> {
357
+
sqlx::query("DELETE FROM email_token WHERE did = ?")
358
+
.bind(did)
359
+
.execute(account_db)
360
+
.await?;
361
+
Ok(())
362
+
}
363
364
+
pub async fn assert_valid_token(
365
+
account_db: &SqlitePool,
366
+
did: String,
367
+
token: String,
368
+
) -> Result<(), TokenCheckError> {
369
+
let token_upper = token.to_ascii_uppercase();
370
+
let purpose = "2fa_code";
371
+
372
+
let row: Option<(String,)> = sqlx::query_as(
373
+
"SELECT requestedAt FROM email_token WHERE purpose = ? AND did = ? AND token = ? LIMIT 1",
374
+
)
375
+
.bind(purpose)
376
+
.bind(did)
377
+
.bind(token_upper)
378
+
.fetch_optional(account_db)
379
+
.await
380
+
.map_err(|err| {
381
+
log::error!("Error getting the 2fa token: {err}");
382
+
InvalidToken
383
+
})?;
384
+
385
+
match row {
386
+
None => Err(InvalidToken),
387
+
Some(row) => {
388
+
// Token lives for 15 minutes
389
+
let expiration_ms = 15 * 60_000;
390
+
391
+
let requested_at_utc = match chrono::DateTime::parse_from_rfc3339(&row.0) {
392
+
Ok(dt) => dt.with_timezone(&Utc),
393
+
Err(_) => {
394
+
return Err(TokenCheckError::InvalidToken);
395
+
}
396
+
};
397
+
398
+
let now = Utc::now();
399
+
let age_ms = (now - requested_at_utc).num_milliseconds();
400
+
let expired = age_ms > expiration_ms;
401
+
if expired {
402
+
return Err(TokenCheckError::ExpiredToken);
403
+
}
404
405
+
Ok(())
406
+
}
407
+
}
408
+
}
409
410
+
/// We just need to confirm if it's there or not. Will let the PDS do the actual figuring of permissions
411
+
pub async fn verify_app_password(
412
+
account_db: &SqlitePool,
413
+
did: &str,
414
+
password: &str,
415
+
) -> anyhow::Result<bool> {
416
+
let password_scrypt = hash_app_password(did, password)?;
417
+
418
+
let row: Option<(i64,)> = sqlx::query_as(
419
+
"SELECT Count(*) FROM app_password WHERE did = ? AND passwordScrypt = ? LIMIT 1",
420
+
)
421
+
.bind(did)
422
+
.bind(password_scrypt)
423
+
.fetch_optional(account_db)
424
+
.await?;
425
+
426
+
Ok(match row {
427
+
None => false,
428
+
Some((count,)) => count > 0,
429
+
})
430
}
+25
-40
src/middleware.rs
+25
-40
src/middleware.rs
···
1
-
2
-
3
-
4
-
5
6
7
use jwt_compact::{AlgorithmExt, Claims, Token, UntrustedToken, ValidationError};
8
use serde::{Deserialize, Serialize};
9
use std::env;
10
11
#[derive(Clone, Debug)]
12
pub struct Did(pub Option<String>);
···
22
match token {
23
Ok(token) => {
24
match token {
25
-
None => {
26
-
return json_error_response(
27
-
StatusCode::BAD_REQUEST,
28
-
"TokenRequired",
29
-
"",
30
-
).unwrap();
31
-
}
32
Some(token) => {
33
let token = UntrustedToken::new(&token);
34
-
//Doing weird unwraps cause I can't do Result for middleware?
35
if token.is_err() {
36
-
return json_error_response(
37
-
StatusCode::BAD_REQUEST,
38
-
"TokenRequired",
39
-
"",
40
-
).unwrap();
41
}
42
-
let parsed_token = token.unwrap();
43
let claims: Result<Claims<TokenClaims>, ValidationError> =
44
parsed_token.deserialize_claims_unchecked();
45
if claims.is_err() {
46
-
return json_error_response(
47
-
StatusCode::BAD_REQUEST,
48
-
"TokenRequired",
49
-
"",
50
-
).unwrap();
51
}
52
53
-
let key = Hs256Key::new(env::var("PDS_JWT_SECRET").unwrap());
54
let token: Result<Token<TokenClaims>, ValidationError> =
55
Hs256.validator(&key).validate(&parsed_token);
56
if token.is_err() {
57
-
return json_error_response(
58
-
StatusCode::BAD_REQUEST,
59
-
"InvalidToken",
60
-
"",
61
-
).unwrap();
62
}
63
-
let token = token.unwrap();
64
//Not going to worry about expiration since it still goes to the PDS
65
-
66
-
67
-
68
-
69
}
70
}
71
}
72
-
Err(_) => {
73
-
return json_error_response(
74
-
StatusCode::BAD_REQUEST,
75
-
"InvalidToken",
76
-
"",
77
-
).unwrap();
78
}
79
}
80
}
···
1
+
use crate::helpers::json_error_response;
2
+
use axum::extract::Request;
3
+
use axum::http::{HeaderMap, StatusCode};
4
+
use axum::middleware::Next;
5
6
7
use jwt_compact::{AlgorithmExt, Claims, Token, UntrustedToken, ValidationError};
8
use serde::{Deserialize, Serialize};
9
use std::env;
10
+
use tracing::log;
11
12
#[derive(Clone, Debug)]
13
pub struct Did(pub Option<String>);
···
23
match token {
24
Ok(token) => {
25
match token {
26
+
None => json_error_response(StatusCode::BAD_REQUEST, "TokenRequired", "")
27
+
.expect("Error creating an error response"),
28
Some(token) => {
29
let token = UntrustedToken::new(&token);
30
if token.is_err() {
31
+
return json_error_response(StatusCode::BAD_REQUEST, "TokenRequired", "")
32
+
.expect("Error creating an error response");
33
}
34
+
let parsed_token = token.expect("Already checked for error");
35
let claims: Result<Claims<TokenClaims>, ValidationError> =
36
parsed_token.deserialize_claims_unchecked();
37
if claims.is_err() {
38
+
return json_error_response(StatusCode::BAD_REQUEST, "TokenRequired", "")
39
+
.expect("Error creating an error response");
40
}
41
42
+
let key = Hs256Key::new(
43
+
env::var("PDS_JWT_SECRET").expect("PDS_JWT_SECRET not set in the pds.env"),
44
+
);
45
let token: Result<Token<TokenClaims>, ValidationError> =
46
Hs256.validator(&key).validate(&parsed_token);
47
if token.is_err() {
48
+
return json_error_response(StatusCode::BAD_REQUEST, "InvalidToken", "")
49
+
.expect("Error creating an error response");
50
}
51
+
let token = token.expect("Already checked for error,");
52
//Not going to worry about expiration since it still goes to the PDS
53
+
req.extensions_mut()
54
+
.insert(Did(Some(token.claims().custom.sub.clone())));
55
+
next.run(req).await
56
}
57
}
58
}
59
+
Err(err) => {
60
+
log::error!("Error extracting token: {err}");
61
+
json_error_response(StatusCode::BAD_REQUEST, "InvalidToken", "")
62
+
.expect("Error creating an error response")
63
}
64
}
65
}
+141
src/oauth_provider.rs
+141
src/oauth_provider.rs
···
···
1
+
use crate::AppState;
2
+
use crate::helpers::{AuthResult, oauth_json_error_response, preauth_check};
3
+
use axum::body::Body;
4
+
use axum::extract::State;
5
+
use axum::http::header::CONTENT_TYPE;
6
+
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
7
+
use axum::response::{IntoResponse, Response};
8
+
use axum::{Json, extract};
9
+
use serde::{Deserialize, Serialize};
10
+
use tracing::log;
11
+
12
+
#[derive(Serialize, Deserialize, Clone)]
13
+
pub struct SignInRequest {
14
+
pub username: String,
15
+
pub password: String,
16
+
pub remember: bool,
17
+
pub locale: String,
18
+
#[serde(skip_serializing_if = "Option::is_none", rename = "emailOtp")]
19
+
pub email_otp: Option<String>,
20
+
}
21
+
22
+
pub async fn sign_in(
23
+
State(state): State<AppState>,
24
+
headers: HeaderMap,
25
+
Json(mut payload): extract::Json<SignInRequest>,
26
+
) -> Result<Response<Body>, StatusCode> {
27
+
let identifier = payload.username.clone();
28
+
let password = payload.password.clone();
29
+
let auth_factor_token = payload.email_otp.clone();
30
+
31
+
match preauth_check(&state, &identifier, &password, auth_factor_token, true).await {
32
+
Ok(result) => match result {
33
+
AuthResult::WrongIdentityOrPassword => oauth_json_error_response(
34
+
StatusCode::BAD_REQUEST,
35
+
"invalid_request",
36
+
"Invalid identifier or password",
37
+
),
38
+
AuthResult::TwoFactorRequired(masked_email) => {
39
+
// Email sending step can be handled here if needed in the future.
40
+
41
+
// {"error":"second_authentication_factor_required","error_description":"emailOtp authentication factor required (hint: 2***0@p***m)","type":"emailOtp","hint":"2***0@p***m"}
42
+
let body_str = match serde_json::to_string(&serde_json::json!({
43
+
"error": "second_authentication_factor_required",
44
+
"error_description": format!("emailOtp authentication factor required (hint: {})", masked_email),
45
+
"type": "emailOtp",
46
+
"hint": masked_email,
47
+
})) {
48
+
Ok(s) => s,
49
+
Err(_) => return Err(StatusCode::BAD_REQUEST),
50
+
};
51
+
52
+
Response::builder()
53
+
.status(StatusCode::BAD_REQUEST)
54
+
.header(CONTENT_TYPE, "application/json")
55
+
.body(Body::from(body_str))
56
+
.map_err(|_| StatusCode::BAD_REQUEST)
57
+
}
58
+
AuthResult::ProxyThrough => {
59
+
//No 2FA or already passed
60
+
let uri = format!(
61
+
"{}{}",
62
+
state.pds_base_url, "/@atproto/oauth-provider/~api/sign-in"
63
+
);
64
+
65
+
let mut req = axum::http::Request::post(uri);
66
+
if let Some(req_headers) = req.headers_mut() {
67
+
// Copy headers but remove problematic ones. There was an issue with the PDS not parsing the body fully if i forwarded all headers
68
+
copy_filtered_headers(&headers, req_headers);
69
+
//Setting the content type to application/json manually
70
+
req_headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
71
+
}
72
+
73
+
//Clears the email_otp because the pds will reject a request with it.
74
+
payload.email_otp = None;
75
+
let payload_bytes =
76
+
serde_json::to_vec(&payload).map_err(|_| StatusCode::BAD_REQUEST)?;
77
+
78
+
let req = req
79
+
.body(Body::from(payload_bytes))
80
+
.map_err(|_| StatusCode::BAD_REQUEST)?;
81
+
82
+
let proxied = state
83
+
.reverse_proxy_client
84
+
.request(req)
85
+
.await
86
+
.map_err(|_| StatusCode::BAD_REQUEST)?
87
+
.into_response();
88
+
89
+
Ok(proxied)
90
+
}
91
+
//Ignoring the type of token check failure. Looks like oauth on the entry treads them the same.
92
+
AuthResult::TokenCheckFailed(_) => oauth_json_error_response(
93
+
StatusCode::BAD_REQUEST,
94
+
"invalid_request",
95
+
"Unable to sign-in due to an unexpected server error",
96
+
),
97
+
},
98
+
Err(err) => {
99
+
log::error!(
100
+
"Error during pre-auth check. This happens on the create_session endpoint when trying to decide if the user has access:\n {err}"
101
+
);
102
+
oauth_json_error_response(
103
+
StatusCode::BAD_REQUEST,
104
+
"pds_gatekeeper_error",
105
+
"This error was not generated by the PDS, but PDS Gatekeeper. Please contact your PDS administrator for help and for them to review the server logs.",
106
+
)
107
+
}
108
+
}
109
+
}
110
+
111
+
fn is_disallowed_header(name: &HeaderName) -> bool {
112
+
// possible problematic headers with proxying
113
+
matches!(
114
+
name.as_str(),
115
+
"connection"
116
+
| "keep-alive"
117
+
| "proxy-authenticate"
118
+
| "proxy-authorization"
119
+
| "te"
120
+
| "trailer"
121
+
| "transfer-encoding"
122
+
| "upgrade"
123
+
| "host"
124
+
| "content-length"
125
+
| "content-encoding"
126
+
| "expect"
127
+
| "accept-encoding"
128
+
)
129
+
}
130
+
131
+
fn copy_filtered_headers(src: &HeaderMap, dst: &mut HeaderMap) {
132
+
for (name, value) in src.iter() {
133
+
if is_disallowed_header(name) {
134
+
continue;
135
+
}
136
+
// Only copy valid headers
137
+
if let Ok(hv) = HeaderValue::from_bytes(value.as_bytes()) {
138
+
dst.insert(name.clone(), hv);
139
+
}
140
+
}
141
+
}
-1
src/xrpc/mod.rs
-1
src/xrpc/mod.rs
+377
-1
src/helpers.rs
+377
-1
src/helpers.rs
···
3
use anyhow::anyhow;
4
use axum::body::{Body, to_bytes};
5
use axum::extract::Request;
6
+
use axum::http::header::CONTENT_TYPE;
7
use axum::http::{HeaderMap, StatusCode, Uri};
8
use axum::response::{IntoResponse, Response};
9
use axum_template::TemplateEngine;
10
+
use chrono::Utc;
11
+
use lettre::message::{MultiPart, SinglePart, header};
12
+
use lettre::{AsyncTransport, Message};
13
+
use rand::Rng;
14
+
use serde::de::DeserializeOwned;
15
+
use serde_json::{Map, Value};
16
+
use sha2::{Digest, Sha256};
17
+
use sqlx::SqlitePool;
18
+
use tracing::{error, log};
19
+
20
+
///Used to generate the email 2fa code
21
+
const UPPERCASE_BASE32_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
22
+
23
+
/// The result of a proxied call that attempts to parse JSON.
24
+
pub enum ProxiedResult<T> {
25
+
/// Successfully parsed JSON body along with original response headers.
26
+
27
+
28
+
29
+
30
+
31
+
32
+
33
+
34
+
35
+
36
+
37
+
38
+
39
+
40
+
41
+
42
+
43
+
44
+
45
+
46
+
47
+
48
+
49
+
50
+
51
+
52
+
53
+
54
+
55
+
56
+
57
+
58
+
59
+
60
+
61
+
62
+
63
+
64
+
65
+
66
+
67
+
68
+
69
+
70
+
71
+
72
+
73
+
74
+
75
+
76
+
77
+
78
+
79
+
80
+
81
+
82
+
83
+
84
+
85
+
86
+
87
+
88
+
89
+
90
+
91
+
92
+
93
+
94
+
95
+
96
+
97
+
98
+
99
+
100
+
101
+
.map_err(|_| StatusCode::BAD_REQUEST)
102
+
}
103
+
104
+
/// Build a JSON error response with the required Content-Type header
105
+
/// Content-Type: application/json (oauth endpoint does not like utf ending)
106
+
/// Body shape: { "error": string, "error_description": string }
107
+
pub fn oauth_json_error_response(
108
+
status: StatusCode,
109
+
error: impl Into<String>,
110
+
111
+
112
+
113
+
114
+
115
+
116
+
117
+
118
+
119
+
120
+
121
+
122
+
123
+
124
+
125
+
}
126
+
127
+
/// Creates a random token of 10 characters for email 2FA
128
+
pub fn get_random_token() -> String {
129
+
let mut rng = rand::rng();
130
+
131
+
let mut full_code = String::with_capacity(10);
132
+
for _ in 0..10 {
133
+
let idx = rng.random_range(0..UPPERCASE_BASE32_CHARS.len());
134
+
full_code.push(UPPERCASE_BASE32_CHARS[idx] as char);
135
+
}
136
+
137
+
//The PDS implementation creates in lowercase, then converts to uppercase.
138
+
//Just going a head and doing uppercase here.
139
+
let slice_one = &full_code[0..5].to_ascii_uppercase();
140
+
141
+
142
+
143
+
144
+
145
+
146
+
147
+
148
+
149
+
150
+
151
+
152
+
153
+
154
+
155
+
156
+
157
+
158
+
159
+
160
+
161
+
162
+
163
+
164
+
165
+
166
+
167
+
168
+
169
+
170
+
171
+
172
+
173
+
}
174
+
}
175
+
176
+
/// Creates a hex string from the password and salt to find app passwords
177
+
fn scrypt_hex(password: &str, salt: &str) -> anyhow::Result<String> {
178
+
let params = scrypt::Params::new(14, 8, 1, 64)?;
179
+
let mut derived = [0u8; 64];
180
+
181
+
Ok(hex::encode(derived))
182
+
}
183
+
184
+
/// Hashes the app password. did is used as the salt.
185
+
pub fn hash_app_password(did: &str, password: &str) -> anyhow::Result<String> {
186
+
let mut hasher = Sha256::new();
187
+
hasher.update(did.as_bytes());
188
+
let sha = hasher.finalize();
189
+
190
+
191
+
192
+
193
+
194
+
195
+
196
+
197
+
198
+
199
+
200
+
201
+
202
+
203
+
204
+
205
+
206
+
207
+
208
+
209
+
210
+
211
+
212
+
213
+
214
+
215
+
216
+
217
+
218
+
219
+
220
+
221
+
222
+
223
+
224
+
225
+
226
+
227
+
228
+
229
+
230
+
231
+
232
+
233
+
234
+
235
+
236
+
237
+
238
+
239
+
240
+
241
+
242
+
243
+
244
+
245
+
246
+
247
+
248
+
249
+
250
+
251
+
252
+
253
+
254
+
255
+
256
+
257
+
258
+
259
+
260
+
261
+
262
+
263
+
264
+
265
+
let verified = verify_password(password, &password_scrypt).await?;
266
+
if !verified {
267
+
if oauth {
268
+
//OAuth does not allow app password logins so just go ahead and send it along it's way
269
+
return Ok(AuthResult::WrongIdentityOrPassword);
270
+
}
271
+
//Theres a chance it could be an app password so check that as well
272
+
273
+
274
+
275
+
276
+
277
+
278
+
279
+
280
+
281
+
282
+
283
+
284
+
285
+
286
+
287
+
288
+
289
+
290
+
291
+
292
+
293
+
294
+
295
+
296
+
297
+
298
+
299
+
300
+
301
+
if two_factor_required {
302
+
//Two factor is required and a taken was provided
303
+
if let Some(two_factor_code) = two_factor_code {
304
+
//if the two_factor_code is set need to see if we have a valid token
305
+
if !two_factor_code.is_empty() {
306
+
return match assert_valid_token(
307
+
&state.account_pool,
308
+
309
+
310
+
311
+
312
+
313
+
314
+
315
+
316
+
317
+
318
+
319
+
320
+
321
+
322
+
323
+
324
+
325
+
326
+
}
327
+
}
328
+
329
+
return match create_two_factor_token(&state.account_pool, did).await {
330
+
Ok(code) => {
331
+
let mut email_data = Map::new();
332
+
email_data.insert("token".to_string(), Value::from(code.clone()));
333
+
334
+
335
+
336
+
337
+
338
+
339
+
340
+
341
+
342
+
343
+
344
+
345
+
346
+
347
+
348
+
349
+
350
+
351
+
352
+
353
+
354
+
355
+
356
+
357
+
358
+
359
+
360
+
361
+
362
+
363
+
364
+
365
+
366
+
367
+
368
+
369
+
370
+
371
+
372
+
373
+
374
+
375
+
376
+
pub async fn create_two_factor_token(
377
+
account_db: &SqlitePool,
378
+
did: String,
379
+
) -> anyhow::Result<String> {
380
+
let purpose = "2fa_code";
381
+
382
+
let token = get_random_token();
383
+
let right_now = Utc::now();
384
+
385
+
let res = sqlx::query(
+39
-20
src/main.rs
+39
-20
src/main.rs
···
22
use tower_governor::governor::GovernorConfigBuilder;
23
use tower_http::compression::CompressionLayer;
24
use tower_http::cors::{Any, CorsLayer};
25
-
use tracing::error;
26
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
27
28
pub mod helpers;
···
88
#[tokio::main]
89
async fn main() -> Result<(), Box<dyn std::error::Error>> {
90
setup_tracing();
91
-
//TODO may need to change where this reads from? Like an env variable for it's location?
92
dotenvy::from_path(Path::new("./pds.env"))?;
93
let pds_root = env::var("PDS_DATA_DIRECTORY")?;
94
let account_db_url = format!("{pds_root}/account.sqlite");
95
96
let account_options = SqliteConnectOptions::new()
97
-
.journal_mode(SqliteJournalMode::Wal)
98
-
.filename(account_db_url);
99
100
let account_pool = SqlitePoolOptions::new()
101
.max_connections(5)
···
106
let options = SqliteConnectOptions::new()
107
.journal_mode(SqliteJournalMode::Wal)
108
.filename(bells_db_url)
109
-
.create_if_missing(true);
110
let pds_gatekeeper_pool = SqlitePoolOptions::new()
111
.max_connections(5)
112
.connect_with(options)
···
129
130
131
132
133
-
134
-
135
-
//TODO add an override to manually load in the hbs templates
136
-
let _ = hbs.register_embed_templates::<EmailTemplates>();
137
138
let state = AppState {
139
account_pool,
140
pds_gatekeeper_pool,
141
reverse_proxy_client: client,
142
-
//TODO should be env prob
143
-
pds_base_url: "http://localhost:3000".to_string(),
144
mailer,
145
mailer_from: sent_from,
146
template_engine: Engine::from(hbs),
···
148
149
// Rate limiting
150
//Allows 5 within 60 seconds, and after 60 should drop one off? So hit 5, then goes to 4 after 60 seconds.
151
-
let governor_conf = GovernorConfigBuilder::default()
152
.per_second(60)
153
.burst_size(5)
154
.finish()
155
.expect("failed to create governor config. this should not happen and is a bug");
156
157
-
let governor_limiter = governor_conf.limiter().clone();
158
let interval = Duration::from_secs(60);
159
// a separate background task to clean up
160
std::thread::spawn(move || {
161
loop {
162
std::thread::sleep(interval);
163
-
tracing::info!("rate limiting storage size: {}", governor_limiter.len());
164
-
governor_limiter.retain_recent();
165
}
166
});
167
···
182
)
183
.route(
184
"/@atproto/oauth-provider/~api/sign-in",
185
-
post(sign_in), // .layer(GovernorLayer::new(governor_conf.clone()))),
186
)
187
.route(
188
"/xrpc/com.atproto.server.createSession",
189
-
post(create_session.layer(GovernorLayer::new(governor_conf))),
190
)
191
.layer(CompressionLayer::new())
192
.layer(cors)
193
.with_state(state);
194
195
-
let host = env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
196
-
let port: u16 = env::var("PORT")
197
.ok()
198
.and_then(|s| s.parse().ok())
199
.unwrap_or(8080);
···
210
.with_graceful_shutdown(shutdown_signal());
211
212
if let Err(err) = server.await {
213
-
error!(error = %err, "server error");
214
}
215
216
Ok(())
···
22
use tower_governor::governor::GovernorConfigBuilder;
23
use tower_http::compression::CompressionLayer;
24
use tower_http::cors::{Any, CorsLayer};
25
+
use tracing::log;
26
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
27
28
pub mod helpers;
···
88
#[tokio::main]
89
async fn main() -> Result<(), Box<dyn std::error::Error>> {
90
setup_tracing();
91
+
//TODO may need to change where this reads from? Like an env variable for it's location? Or arg?
92
dotenvy::from_path(Path::new("./pds.env"))?;
93
let pds_root = env::var("PDS_DATA_DIRECTORY")?;
94
let account_db_url = format!("{pds_root}/account.sqlite");
95
96
let account_options = SqliteConnectOptions::new()
97
+
.filename(account_db_url)
98
+
.busy_timeout(Duration::from_secs(5));
99
100
let account_pool = SqlitePoolOptions::new()
101
.max_connections(5)
···
106
let options = SqliteConnectOptions::new()
107
.journal_mode(SqliteJournalMode::Wal)
108
.filename(bells_db_url)
109
+
.create_if_missing(true)
110
+
.busy_timeout(Duration::from_secs(5));
111
let pds_gatekeeper_pool = SqlitePoolOptions::new()
112
.max_connections(5)
113
.connect_with(options)
···
130
131
132
133
+
AsyncSmtpTransport::<Tokio1Executor>::from_url(smtp_url.as_str())?.build();
134
+
//Email templates setup
135
+
let mut hbs = Handlebars::new();
136
+
137
+
let users_email_directory = env::var("GATEKEEPER_EMAIL_TEMPLATES_DIRECTORY");
138
+
if let Ok(users_email_directory) = users_email_directory {
139
+
hbs.register_template_file(
140
+
"two_factor_code.hbs",
141
+
format!("{users_email_directory}/two_factor_code.hbs"),
142
+
)?;
143
+
} else {
144
+
let _ = hbs.register_embed_templates::<EmailTemplates>();
145
+
}
146
147
+
let pds_base_url =
148
+
env::var("PDS_BASE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
149
150
let state = AppState {
151
account_pool,
152
pds_gatekeeper_pool,
153
reverse_proxy_client: client,
154
+
pds_base_url,
155
mailer,
156
mailer_from: sent_from,
157
template_engine: Engine::from(hbs),
···
159
160
// Rate limiting
161
//Allows 5 within 60 seconds, and after 60 should drop one off? So hit 5, then goes to 4 after 60 seconds.
162
+
let create_session_governor_conf = GovernorConfigBuilder::default()
163
+
.per_second(60)
164
+
.burst_size(5)
165
+
.finish()
166
+
.expect("failed to create governor config. this should not happen and is a bug");
167
+
168
+
// Create a second config with the same settings for the other endpoint
169
+
let sign_in_governor_conf = GovernorConfigBuilder::default()
170
.per_second(60)
171
.burst_size(5)
172
.finish()
173
.expect("failed to create governor config. this should not happen and is a bug");
174
175
+
let create_session_governor_limiter = create_session_governor_conf.limiter().clone();
176
+
let sign_in_governor_limiter = sign_in_governor_conf.limiter().clone();
177
let interval = Duration::from_secs(60);
178
// a separate background task to clean up
179
std::thread::spawn(move || {
180
loop {
181
std::thread::sleep(interval);
182
+
create_session_governor_limiter.retain_recent();
183
+
sign_in_governor_limiter.retain_recent();
184
}
185
});
186
···
201
)
202
.route(
203
"/@atproto/oauth-provider/~api/sign-in",
204
+
post(sign_in).layer(GovernorLayer::new(sign_in_governor_conf)),
205
)
206
.route(
207
"/xrpc/com.atproto.server.createSession",
208
+
post(create_session.layer(GovernorLayer::new(create_session_governor_conf))),
209
)
210
.layer(CompressionLayer::new())
211
.layer(cors)
212
.with_state(state);
213
214
+
let host = env::var("GATEKEEPER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
215
+
let port: u16 = env::var("GATEKEEPER_PORT")
216
.ok()
217
.and_then(|s| s.parse().ok())
218
.unwrap_or(8080);
···
229
.with_graceful_shutdown(shutdown_signal());
230
231
if let Err(err) = server.await {
232
+
log::error!("server error:{err}");
233
}
234
235
Ok(())
+5
-6
README.md
+5
-6
README.md
···
12
13
## 2FA
14
15
-
- [x] Ability to turn on/off 2FA
16
-
- [x] getSession overwrite to set the `emailAuthFactor` flag if the user has 2FA turned on
17
-
- [x] send an email using the `PDS_EMAIL_SMTP_URL` with a handlebar email template like Bluesky's 2FA sign in email.
18
-
- [ ] generate a 2FA code
19
-
- [ ] createSession gatekeeping (It does stop logins, just eh, doesn't actually send a real code or check it yet)
20
-
- [ ] oauth endpoint gatekeeping
21
22
## Captcha on Create Account
23
···
25
26
# Setup
27
28
Nothing here yet! If you are brave enough to try before full release, let me know and I'll help you set it up.
29
But I want to run it locally on my own PDS first to test run it a bit.
30
···
37
path /xrpc/com.atproto.server.getSession
38
path /xrpc/com.atproto.server.updateEmail
39
path /xrpc/com.atproto.server.createSession
40
}
41
42
handle @gatekeeper {
···
12
13
## 2FA
14
15
+
- Overrides The login endpoint to add 2FA for both Bluesky client logged in and OAuth logins
16
+
- Overrides the settings endpoints as well. As long as you have a confirmed email you can turn on 2FA
17
18
## Captcha on Create Account
19
···
21
22
# Setup
23
24
+
We are getting close! Testing now
25
+
26
Nothing here yet! If you are brave enough to try before full release, let me know and I'll help you set it up.
27
But I want to run it locally on my own PDS first to test run it a bit.
28
···
35
path /xrpc/com.atproto.server.getSession
36
path /xrpc/com.atproto.server.updateEmail
37
path /xrpc/com.atproto.server.createSession
38
+
path /@atproto/oauth-provider/~api/sign-in
39
}
40
41
handle @gatekeeper {
History
3 rounds
0 comments
baileytownsend.dev
submitted
#2
1 commit
expand
collapse
2FA gatekeeping
expand 0 comments
pull request successfully merged
baileytownsend.dev
submitted
#1
12 commits
expand
collapse
Added rng code and place holder for db call. wont build
token create and all that
little clean up
app password support
Some clippy warning clean ups
Started better error handling. DOES NOT BUILD
clippy warnings and unwrap cleanups
oauth wip
wip finally returning an okay error for the ui
Crashes again
wip
HOLY COW THAT WORKED
Some more clean ups
custom email directory
Base 32 email tokens now
Final clean ups
expand 0 comments
baileytownsend.dev
submitted
#0
16 commits
expand
collapse
Added rng code and place holder for db call. wont build
token create and all that
little clean up
app password support
Some clippy warning clean ups
Started better error handling. DOES NOT BUILD
clippy warnings and unwrap cleanups
oauth wip
wip finally returning an okay error for the ui
Crashes again
wip
HOLY COW THAT WORKED
Some more clean ups
custom email directory
Base 32 email tokens now
Final clean ups