+205
-327
Diff
round #0
+81
-104
crates/tranquil-api/src/server/account_status.rs
+81
-104
crates/tranquil-api/src/server/account_status.rs
···
1
-
use axum::{
2
-
Json,
3
-
extract::State,
4
-
http::StatusCode,
5
-
response::{IntoResponse, Response},
6
-
};
1
+
use axum::{Json, extract::State};
7
2
use backon::{ExponentialBuilder, Retryable};
8
-
use bcrypt::verify;
9
3
use chrono::{Duration, Utc};
10
4
use cid::Cid;
11
5
use jacquard_repo::commit::Commit;
12
6
use jacquard_repo::storage::BlockStore;
13
7
use k256::ecdsa::SigningKey;
14
8
use serde::{Deserialize, Serialize};
9
+
use serde_json::Value;
15
10
use std::str::FromStr;
16
11
use std::sync::Arc;
17
12
use std::sync::atomic::{AtomicUsize, Ordering};
···
20
15
use tranquil_pds::api::error::{ApiError, DbResultExt};
21
16
use tranquil_pds::auth::{Auth, NotTakendown, Permissive, require_legacy_session_mfa};
22
17
use tranquil_pds::cache::Cache;
18
+
use tranquil_pds::oauth::scopes::{AccountAction, AccountAttr};
23
19
use tranquil_pds::plc::PlcClient;
24
20
use tranquil_pds::state::AppState;
25
21
use tranquil_pds::types::PlainPassword;
···
42
38
pub async fn check_account_status(
43
39
State(state): State<AppState>,
44
40
auth: Auth<Permissive>,
45
-
) -> Result<Response, ApiError> {
41
+
) -> Result<Json<CheckAccountStatusOutput>, ApiError> {
46
42
let did = &auth.did;
47
43
let user_id = state
48
44
.user_repo
49
45
.get_id_by_did(did)
50
46
.await
51
-
.map_err(|_| ApiError::InternalError(None))?
47
+
.log_db_err("fetching user ID for account status")?
52
48
.ok_or(ApiError::InternalError(None))?;
53
49
let is_active = state
54
50
.user_repo
···
97
93
.unwrap_or(0);
98
94
let valid_did =
99
95
is_valid_did_for_service(state.user_repo.as_ref(), state.cache.clone(), did).await;
100
-
Ok((
101
-
StatusCode::OK,
102
-
Json(CheckAccountStatusOutput {
103
-
activated: is_active,
104
-
valid_did,
105
-
repo_commit: repo_commit.clone(),
106
-
repo_rev,
107
-
repo_blocks: block_count,
108
-
indexed_records: record_count,
109
-
private_state_values: 0,
110
-
expected_blobs,
111
-
imported_blobs,
112
-
}),
113
-
)
114
-
.into_response())
96
+
Ok(Json(CheckAccountStatusOutput {
97
+
activated: is_active,
98
+
valid_did,
99
+
repo_commit: repo_commit.clone(),
100
+
repo_rev,
101
+
repo_blocks: block_count,
102
+
indexed_records: record_count,
103
+
private_state_values: 0,
104
+
expected_blobs,
105
+
imported_blobs,
106
+
}))
115
107
}
116
108
117
109
async fn is_valid_did_for_service(
···
204
196
if let Some(ref expected_rotation_key) = server_rotation_key {
205
197
let rotation_keys = doc_data
206
198
.get("rotationKeys")
207
-
.and_then(|v| v.as_array())
208
-
.map(|arr| arr.iter().filter_map(|k| k.as_str()).collect::<Vec<_>>())
199
+
.and_then(Value::as_array)
200
+
.map(|arr| arr.iter().filter_map(Value::as_str).collect::<Vec<_>>())
209
201
.unwrap_or_default();
210
202
if !rotation_keys.contains(&expected_rotation_key.as_str()) {
211
203
return Err(ApiError::InvalidRequest(
···
217
209
let doc_signing_key = doc_data
218
210
.get("verificationMethods")
219
211
.and_then(|v| v.get("atproto"))
220
-
.and_then(|k| k.as_str());
212
+
.and_then(Value::as_str);
221
213
222
214
let user_key = user_repo
223
215
.get_user_key_by_did(did)
···
279
271
280
272
let pds_endpoint = doc
281
273
.get("service")
282
-
.and_then(|s| s.as_array())
274
+
.and_then(Value::as_array)
283
275
.and_then(|arr| {
284
276
arr.iter().find(|svc| {
285
277
svc.get("id").and_then(|id| id.as_str()) == Some("#atproto_pds")
286
-
|| svc.get("type").and_then(|t| t.as_str())
278
+
|| svc.get("type").and_then(Value::as_str)
287
279
== Some(tranquil_pds::plc::ServiceType::Pds.as_str())
288
280
})
289
281
})
290
282
.and_then(|svc| svc.get("serviceEndpoint"))
291
-
.and_then(|e| e.as_str());
283
+
.and_then(Value::as_str);
292
284
293
285
if pds_endpoint != Some(&expected_endpoint) {
294
286
warn!(
···
307
299
pub async fn activate_account(
308
300
State(state): State<AppState>,
309
301
auth: Auth<Permissive>,
310
-
) -> Result<Response, ApiError> {
302
+
) -> Result<Json<EmptyResponse>, ApiError> {
311
303
info!("[MIGRATION] activateAccount called");
312
304
info!(
313
305
"[MIGRATION] activateAccount: Authenticated user did={}",
314
306
auth.did
315
307
);
316
308
317
-
if let Err(e) = tranquil_pds::auth::scope_check::check_account_scope(
318
-
&auth.auth_source,
319
-
auth.scope.as_deref(),
320
-
tranquil_pds::oauth::scopes::AccountAttr::Repo,
321
-
tranquil_pds::oauth::scopes::AccountAction::Manage,
322
-
) {
323
-
info!("[MIGRATION] activateAccount: Scope check failed");
324
-
return Ok(e);
325
-
}
309
+
auth.check_account_scope(AccountAttr::Repo, AccountAction::Manage)
310
+
.inspect_err(|_| {
311
+
info!("[MIGRATION] activateAccount: Scope check failed");
312
+
})?;
326
313
327
314
let did = auth.did.clone();
328
315
···
460
447
);
461
448
}
462
449
info!("[MIGRATION] activateAccount: SUCCESS for did={}", did);
463
-
Ok(EmptyResponse::ok().into_response())
450
+
Ok(Json(EmptyResponse {}))
464
451
}
465
452
Err(e) => {
466
453
error!(
···
482
469
State(state): State<AppState>,
483
470
auth: Auth<Permissive>,
484
471
Json(input): Json<DeactivateAccountInput>,
485
-
) -> Result<Response, ApiError> {
486
-
if let Err(e) = tranquil_pds::auth::scope_check::check_account_scope(
487
-
&auth.auth_source,
488
-
auth.scope.as_deref(),
489
-
tranquil_pds::oauth::scopes::AccountAttr::Repo,
490
-
tranquil_pds::oauth::scopes::AccountAction::Manage,
491
-
) {
492
-
return Ok(e);
493
-
}
472
+
) -> Result<Json<EmptyResponse>, ApiError> {
473
+
auth.check_account_scope(AccountAttr::Repo, AccountAction::Manage)?;
494
474
495
475
let delete_after: Option<chrono::DateTime<chrono::Utc>> = input
496
476
.delete_after
···
521
501
{
522
502
warn!("Failed to sequence account deactivated event: {}", e);
523
503
}
524
-
Ok(EmptyResponse::ok().into_response())
504
+
Ok(Json(EmptyResponse {}))
525
505
}
526
-
Ok(false) => Ok(EmptyResponse::ok().into_response()),
506
+
Ok(false) => Ok(Json(EmptyResponse {})),
527
507
Err(e) => {
528
508
error!("DB error deactivating account: {:?}", e);
529
509
Err(ApiError::InternalError(None))
···
534
514
pub async fn request_account_delete(
535
515
State(state): State<AppState>,
536
516
auth: Auth<NotTakendown>,
537
-
) -> Result<Response, ApiError> {
538
-
let session_mfa = match require_legacy_session_mfa(&state, &auth).await {
539
-
Ok(proof) => proof,
540
-
Err(response) => return Ok(response),
541
-
};
517
+
) -> Result<Json<EmptyResponse>, ApiError> {
518
+
let session_mfa = require_legacy_session_mfa(&state, &auth).await?;
542
519
543
520
let user_id = state
544
521
.user_repo
···
567
544
warn!("Failed to enqueue account deletion notification: {:?}", e);
568
545
}
569
546
info!("Account deletion requested for user {}", session_mfa.did());
570
-
Ok(EmptyResponse::ok().into_response())
547
+
Ok(Json(EmptyResponse {}))
571
548
}
572
549
573
550
#[derive(Deserialize)]
···
580
557
pub async fn delete_account(
581
558
State(state): State<AppState>,
582
559
Json(input): Json<DeleteAccountInput>,
583
-
) -> Response {
560
+
) -> Result<Json<EmptyResponse>, ApiError> {
584
561
let did = &input.did;
585
562
let password = &input.password;
586
563
let token = input.token.trim();
587
564
if password.is_empty() {
588
-
return ApiError::InvalidRequest("password is required".into()).into_response();
565
+
return Err(ApiError::InvalidRequest("password is required".into()));
589
566
}
590
567
const OLD_PASSWORD_MAX_LENGTH: usize = 512;
591
568
if password.len() > OLD_PASSWORD_MAX_LENGTH {
592
-
return ApiError::InvalidRequest("Invalid password length".into()).into_response();
569
+
return Err(ApiError::InvalidRequest("Invalid password length".into()));
593
570
}
594
571
if token.is_empty() {
595
-
return ApiError::InvalidToken(Some("token is required".into())).into_response();
572
+
return Err(ApiError::InvalidToken(Some("token is required".into())));
596
573
}
597
-
let user = match state.user_repo.get_user_for_deletion(did).await {
598
-
Ok(Some(u)) => u,
599
-
Ok(None) => {
600
-
return ApiError::InvalidRequest("account not found".into()).into_response();
601
-
}
602
-
Err(e) => {
574
+
let user = state
575
+
.user_repo
576
+
.get_user_for_deletion(did)
577
+
.await
578
+
.map_err(|e| {
603
579
error!("DB error in delete_account: {:?}", e);
604
-
return ApiError::InternalError(None).into_response();
605
-
}
606
-
};
580
+
ApiError::InternalError(None)
581
+
})?
582
+
.ok_or(ApiError::InvalidRequest("account not found".into()))?;
607
583
let (user_id, password_hash, handle) = (user.id, user.password_hash, user.handle);
608
-
let password_valid = if password_hash
609
-
.as_ref()
610
-
.map(|h| verify(password, h).unwrap_or(false))
611
-
.unwrap_or(false)
584
+
if crate::common::verify_credential(
585
+
state.session_repo.as_ref(),
586
+
user_id,
587
+
password,
588
+
password_hash.as_deref(),
589
+
)
590
+
.await
591
+
.is_none()
612
592
{
613
-
true
614
-
} else {
615
-
let app_pass_hashes = state
616
-
.session_repo
617
-
.get_app_password_hashes_by_did(did)
618
-
.await
619
-
.unwrap_or_default();
620
-
app_pass_hashes
621
-
.iter()
622
-
.any(|h| verify(password, h).unwrap_or(false))
623
-
};
624
-
if !password_valid {
625
-
return ApiError::AuthenticationFailed(Some("Invalid password".into())).into_response();
593
+
return Err(ApiError::AuthenticationFailed(Some(
594
+
"Invalid password".into(),
595
+
)));
626
596
}
627
-
let deletion_request = match state.infra_repo.get_deletion_request(token).await {
628
-
Ok(Some(req)) => req,
629
-
Ok(None) => {
630
-
return ApiError::InvalidToken(Some("Invalid or expired token".into())).into_response();
631
-
}
632
-
Err(e) => {
597
+
let deletion_request = state
598
+
.infra_repo
599
+
.get_deletion_request(token)
600
+
.await
601
+
.map_err(|e| {
633
602
error!("DB error fetching deletion token: {:?}", e);
634
-
return ApiError::InternalError(None).into_response();
635
-
}
636
-
};
603
+
ApiError::InternalError(None)
604
+
})?
605
+
.ok_or(ApiError::InvalidToken(Some(
606
+
"Invalid or expired token".into(),
607
+
)))?;
637
608
if &deletion_request.did != did {
638
-
return ApiError::InvalidToken(Some("Token does not match account".into())).into_response();
609
+
return Err(ApiError::InvalidToken(Some(
610
+
"Token does not match account".into(),
611
+
)));
639
612
}
640
613
if Utc::now() > deletion_request.expires_at {
641
614
let _ = state.infra_repo.delete_deletion_request(token).await;
642
-
return ApiError::ExpiredToken(None).into_response();
643
-
}
644
-
if let Err(e) = state.user_repo.delete_account_complete(user_id, did).await {
645
-
error!("DB error deleting account: {:?}", e);
646
-
return ApiError::InternalError(None).into_response();
615
+
return Err(ApiError::ExpiredToken(None));
647
616
}
617
+
state
618
+
.user_repo
619
+
.delete_account_complete(user_id, did)
620
+
.await
621
+
.map_err(|e| {
622
+
error!("DB error deleting account: {:?}", e);
623
+
ApiError::InternalError(None)
624
+
})?;
648
625
let account_seq = tranquil_pds::repo_ops::sequence_account_event(
649
626
&state,
650
627
did,
···
672
649
.delete(&tranquil_pds::cache_keys::handle_key(&handle))
673
650
.await;
674
651
info!("Account {} deleted successfully", did);
675
-
EmptyResponse::ok().into_response()
652
+
Ok(Json(EmptyResponse {}))
676
653
}
+50
-55
crates/tranquil-api/src/server/password.rs
+50
-55
crates/tranquil-api/src/server/password.rs
···
1
-
use axum::{
2
-
Json,
3
-
extract::State,
4
-
response::{IntoResponse, Response},
5
-
};
1
+
use axum::{Json, extract::State};
6
2
use bcrypt::{DEFAULT_COST, hash};
7
3
use chrono::{Duration, Utc};
8
4
use serde::Deserialize;
9
5
use tracing::{error, info, warn};
10
6
use tranquil_pds::api::error::{ApiError, DbResultExt};
11
-
use tranquil_pds::api::{EmptyResponse, HasPasswordResponse, SuccessResponse};
7
+
use tranquil_pds::api::{EmptyResponse, HasPasswordResponse, PasswordResetOutput, SuccessResponse};
12
8
use tranquil_pds::auth::{
13
9
Active, Auth, NormalizedLoginIdentifier, require_legacy_session_mfa, require_reauth_window,
14
10
require_reauth_window_if_available,
···
32
28
State(state): State<AppState>,
33
29
_rate_limit: RateLimited<PasswordResetLimit>,
34
30
Json(input): Json<RequestPasswordResetInput>,
35
-
) -> Response {
31
+
) -> Result<Json<PasswordResetOutput>, ApiError> {
36
32
let identifier = input.email.trim();
37
33
if identifier.is_empty() {
38
-
return ApiError::InvalidRequest("email or handle is required".into()).into_response();
34
+
return Err(ApiError::InvalidRequest(
35
+
"email or handle is required".into(),
36
+
));
39
37
}
40
38
let hostname_for_handles = tranquil_config::get().server.hostname_without_port();
41
39
let normalized = identifier.to_lowercase();
···
60
58
Ok(Some(id)) => id,
61
59
Ok(None) => {
62
60
info!("Password reset requested for unknown identifier");
63
-
return Json(serde_json::json!({ "success": true })).into_response();
61
+
return Ok(Json(PasswordResetOutput {
62
+
success: true,
63
+
multiple_accounts: None,
64
+
account_count: None,
65
+
message: None,
66
+
}));
64
67
}
65
68
Err(e) => {
66
69
error!("DB error in request_password_reset: {:?}", e);
67
-
return ApiError::InternalError(None).into_response();
70
+
return Err(ApiError::InternalError(None));
68
71
}
69
72
};
70
73
let code = generate_reset_code();
···
75
78
.await
76
79
{
77
80
error!("DB error setting reset code: {:?}", e);
78
-
return ApiError::InternalError(None).into_response();
81
+
return Err(ApiError::InternalError(None));
79
82
}
80
83
let hostname = &tranquil_config::get().server.hostname;
81
84
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_password_reset(
···
92
95
info!("Password reset requested for user {}", user_id);
93
96
94
97
match multiple_accounts_warning {
95
-
Some(count) => Json(serde_json::json!({
96
-
"success": true,
97
-
"multipleAccounts": true,
98
-
"accountCount": count,
99
-
"message": "Multiple accounts share this email. Reset link sent to the most recent account. Use your handle for a specific account."
100
-
}))
101
-
.into_response(),
102
-
None => Json(serde_json::json!({ "success": true })).into_response(),
98
+
Some(count) => Ok(Json(PasswordResetOutput {
99
+
success: true,
100
+
multiple_accounts: Some(true),
101
+
account_count: Some(count),
102
+
message: Some("Multiple accounts share this email. Reset link sent to the most recent account. Use your handle for a specific account.".into()),
103
+
})),
104
+
None => Ok(Json(PasswordResetOutput {
105
+
success: true,
106
+
multiple_accounts: None,
107
+
account_count: None,
108
+
message: None,
109
+
})),
103
110
}
104
111
}
105
112
···
113
120
State(state): State<AppState>,
114
121
_rate_limit: RateLimited<ResetPasswordLimit>,
115
122
Json(input): Json<ResetPasswordInput>,
116
-
) -> Response {
123
+
) -> Result<Json<EmptyResponse>, ApiError> {
117
124
let token = input.token.trim();
118
125
let password = &input.password;
119
126
if token.is_empty() {
120
-
return ApiError::InvalidToken(None).into_response();
127
+
return Err(ApiError::InvalidToken(None));
121
128
}
122
129
if password.is_empty() {
123
-
return ApiError::InvalidRequest("password is required".into()).into_response();
130
+
return Err(ApiError::InvalidRequest("password is required".into()));
124
131
}
125
132
if let Err(e) = validate_password(password) {
126
-
return ApiError::InvalidRequest(e.to_string()).into_response();
133
+
return Err(ApiError::InvalidRequest(e.to_string()));
127
134
}
128
135
let user = match state.user_repo.get_user_by_reset_code(token).await {
129
136
Ok(Some(u)) => u,
130
137
Ok(None) => {
131
-
return ApiError::InvalidToken(None).into_response();
138
+
return Err(ApiError::InvalidToken(None));
132
139
}
133
140
Err(e) => {
134
141
error!("DB error in reset_password: {:?}", e);
135
-
return ApiError::InternalError(None).into_response();
142
+
return Err(ApiError::InternalError(None));
136
143
}
137
144
};
138
145
let user_id = user.id;
139
146
let Some(exp) = user.expires_at else {
140
-
return ApiError::InvalidToken(None).into_response();
147
+
return Err(ApiError::InvalidToken(None));
141
148
};
142
149
if Utc::now() > exp {
143
150
if let Err(e) = state.user_repo.clear_password_reset_code(user_id).await {
144
151
error!("Failed to clear expired reset code: {:?}", e);
145
152
}
146
-
return ApiError::ExpiredToken(None).into_response();
153
+
return Err(ApiError::ExpiredToken(None));
147
154
}
148
155
let password_clone = password.to_string();
149
156
let password_hash =
···
151
158
Ok(Ok(h)) => h,
152
159
Ok(Err(e)) => {
153
160
error!("Failed to hash password: {:?}", e);
154
-
return ApiError::InternalError(None).into_response();
161
+
return Err(ApiError::InternalError(None));
155
162
}
156
163
Err(e) => {
157
164
error!("Failed to spawn blocking task: {:?}", e);
158
-
return ApiError::InternalError(None).into_response();
165
+
return Err(ApiError::InternalError(None));
159
166
}
160
167
};
161
168
let result = match state
···
166
173
Ok(r) => r,
167
174
Err(e) => {
168
175
error!("Failed to reset password: {:?}", e);
169
-
return ApiError::InternalError(None).into_response();
176
+
return Err(ApiError::InternalError(None));
170
177
}
171
178
};
172
179
futures::future::join_all(result.session_jtis.iter().map(|jti| {
···
197
204
}
198
205
}
199
206
info!("Password reset completed for user {}", user_id);
200
-
EmptyResponse::ok().into_response()
207
+
Ok(Json(EmptyResponse {}))
201
208
}
202
209
203
210
#[derive(Deserialize)]
···
211
218
State(state): State<AppState>,
212
219
auth: Auth<Active>,
213
220
Json(input): Json<ChangePasswordInput>,
214
-
) -> Result<Response, ApiError> {
221
+
) -> Result<Json<EmptyResponse>, ApiError> {
215
222
use tranquil_pds::auth::verify_password_mfa;
216
223
217
-
let session_mfa = match require_legacy_session_mfa(&state, &auth).await {
218
-
Ok(proof) => proof,
219
-
Err(response) => return Ok(response),
220
-
};
224
+
let session_mfa = require_legacy_session_mfa(&state, &auth).await?;
221
225
222
226
if input.current_password.is_empty() {
223
227
return Err(ApiError::InvalidRequest(
···
259
263
.log_db_err("updating password")?;
260
264
261
265
info!(did = %session_mfa.did(), "Password changed successfully");
262
-
Ok(EmptyResponse::ok().into_response())
266
+
Ok(Json(EmptyResponse {}))
263
267
}
264
268
265
269
pub async fn get_password_status(
266
270
State(state): State<AppState>,
267
271
auth: Auth<Active>,
268
-
) -> Result<Response, ApiError> {
272
+
) -> Result<Json<HasPasswordResponse>, ApiError> {
269
273
let has = state
270
274
.user_repo
271
275
.has_password_by_did(&auth.did)
272
276
.await
273
277
.log_db_err("checking password status")?
274
278
.ok_or(ApiError::AccountNotFound)?;
275
-
Ok(HasPasswordResponse::response(has).into_response())
279
+
Ok(Json(HasPasswordResponse { has_password: has }))
276
280
}
277
281
278
282
pub async fn remove_password(
279
283
State(state): State<AppState>,
280
284
auth: Auth<Active>,
281
-
) -> Result<Response, ApiError> {
282
-
let session_mfa = match require_legacy_session_mfa(&state, &auth).await {
283
-
Ok(proof) => proof,
284
-
Err(response) => return Ok(response),
285
-
};
285
+
) -> Result<Json<SuccessResponse>, ApiError> {
286
+
let session_mfa = require_legacy_session_mfa(&state, &auth).await?;
286
287
287
-
let reauth_mfa = match require_reauth_window(&state, &auth).await {
288
-
Ok(proof) => proof,
289
-
Err(response) => return Ok(response),
290
-
};
288
+
let reauth_mfa = require_reauth_window(&state, &auth).await?;
291
289
292
290
let has_passkeys = state
293
291
.user_repo
···
320
318
.log_db_err("removing password")?;
321
319
322
320
info!(did = %session_mfa.did(), "Password removed - account is now passkey-only");
323
-
Ok(SuccessResponse::ok().into_response())
321
+
Ok(Json(SuccessResponse { success: true }))
324
322
}
325
323
326
324
#[derive(Deserialize)]
···
333
331
State(state): State<AppState>,
334
332
auth: Auth<Active>,
335
333
Json(input): Json<SetPasswordInput>,
336
-
) -> Result<Response, ApiError> {
337
-
let reauth_mfa = match require_reauth_window_if_available(&state, &auth).await {
338
-
Ok(proof) => proof,
339
-
Err(response) => return Ok(response),
340
-
};
334
+
) -> Result<Json<SuccessResponse>, ApiError> {
335
+
let reauth_mfa = require_reauth_window_if_available(&state, &auth).await?;
341
336
342
337
let new_password = &input.new_password;
343
338
if new_password.is_empty() {
···
381
376
.log_db_err("setting password")?;
382
377
383
378
info!(did = %did, "Password set for passkey-only account");
384
-
Ok(SuccessResponse::ok().into_response())
379
+
Ok(Json(SuccessResponse { success: true }))
385
380
}
+24
-89
crates/tranquil-api/src/server/reauth.rs
+24
-89
crates/tranquil-api/src/server/reauth.rs
···
1
-
use axum::{
2
-
Json,
3
-
extract::State,
4
-
http::StatusCode,
5
-
response::{IntoResponse, Response},
6
-
};
1
+
use axum::{Json, extract::State};
7
2
use chrono::{DateTime, Utc};
8
3
use serde::{Deserialize, Serialize};
9
4
use tracing::{error, info, warn};
···
27
22
28
23
#[derive(Serialize)]
29
24
#[serde(rename_all = "camelCase")]
30
-
pub struct ReauthStatusResponse {
25
+
pub struct ReauthStatusOutput {
31
26
pub last_reauth_at: Option<DateTime<Utc>>,
32
27
pub reauth_required: bool,
33
28
pub available_methods: Vec<ReauthMethod>,
···
36
31
pub async fn get_reauth_status(
37
32
State(state): State<AppState>,
38
33
auth: Auth<Active>,
39
-
) -> Result<Response, ApiError> {
34
+
) -> Result<Json<ReauthStatusOutput>, ApiError> {
40
35
let last_reauth_at = state
41
36
.session_repo
42
37
.get_last_reauth_at(&auth.did)
···
44
39
.log_db_err("getting last reauth")?;
45
40
46
41
let reauth_required = is_reauth_required(last_reauth_at);
47
-
let available_methods =
48
-
get_available_reauth_methods(&*state.user_repo, &*state.session_repo, &auth.did).await;
42
+
let available_methods = get_available_reauth_methods(&*state.user_repo, &auth.did).await;
49
43
50
-
Ok(Json(ReauthStatusResponse {
44
+
Ok(Json(ReauthStatusOutput {
51
45
last_reauth_at,
52
46
reauth_required,
53
47
available_methods,
54
-
})
55
-
.into_response())
48
+
}))
56
49
}
57
50
58
51
#[derive(Deserialize)]
···
63
56
64
57
#[derive(Serialize)]
65
58
#[serde(rename_all = "camelCase")]
66
-
pub struct ReauthResponse {
59
+
pub struct ReauthOutput {
67
60
pub reauthed_at: DateTime<Utc>,
68
61
}
69
62
···
71
64
State(state): State<AppState>,
72
65
auth: Auth<Active>,
73
66
Json(input): Json<PasswordReauthInput>,
74
-
) -> Result<Response, ApiError> {
67
+
) -> Result<Json<ReauthOutput>, ApiError> {
75
68
let password_hash = state
76
69
.user_repo
77
70
.get_password_hash_by_did(&auth.did)
···
103
96
.log_db_err("updating reauth")?;
104
97
105
98
info!(did = %&auth.did, "Re-auth successful via password");
106
-
Ok(Json(ReauthResponse { reauthed_at }).into_response())
99
+
Ok(Json(ReauthOutput { reauthed_at }))
107
100
}
108
101
109
102
#[derive(Deserialize)]
···
116
109
State(state): State<AppState>,
117
110
auth: Auth<Active>,
118
111
Json(input): Json<TotpReauthInput>,
119
-
) -> Result<Response, ApiError> {
112
+
) -> Result<Json<ReauthOutput>, ApiError> {
120
113
let _rate_limit = check_user_rate_limit_with_message::<TotpVerifyLimit>(
121
114
&state,
122
115
&auth.did,
···
139
132
.log_db_err("updating reauth")?;
140
133
141
134
info!(did = %&auth.did, "Re-auth successful via TOTP");
142
-
Ok(Json(ReauthResponse { reauthed_at }).into_response())
135
+
Ok(Json(ReauthOutput { reauthed_at }))
143
136
}
144
137
145
138
#[derive(Serialize)]
146
139
#[serde(rename_all = "camelCase")]
147
-
pub struct PasskeyReauthStartResponse {
140
+
pub struct PasskeyReauthStartOutput {
148
141
pub options: serde_json::Value,
149
142
}
150
143
151
144
pub async fn reauth_passkey_start(
152
145
State(state): State<AppState>,
153
146
auth: Auth<Active>,
154
-
) -> Result<Response, ApiError> {
147
+
) -> Result<Json<PasskeyReauthStartOutput>, ApiError> {
155
148
let stored_passkeys = state
156
149
.user_repo
157
150
.get_passkeys_for_user(&auth.did)
···
196
189
.log_db_err("saving authentication state")?;
197
190
198
191
let options = serde_json::to_value(&rcr).unwrap_or(serde_json::json!({}));
199
-
Ok(Json(PasskeyReauthStartResponse { options }).into_response())
192
+
Ok(Json(PasskeyReauthStartOutput { options }))
200
193
}
201
194
202
195
#[derive(Deserialize)]
···
209
202
State(state): State<AppState>,
210
203
auth: Auth<Active>,
211
204
Json(input): Json<PasskeyReauthFinishInput>,
212
-
) -> Result<Response, ApiError> {
205
+
) -> Result<Json<ReauthOutput>, ApiError> {
213
206
let auth_state_json = state
214
207
.user_repo
215
208
.load_webauthn_challenge(&auth.did, WebauthnChallengeType::Authentication)
···
270
263
.log_db_err("updating reauth")?;
271
264
272
265
info!(did = %&auth.did, "Re-auth successful via passkey");
273
-
Ok(Json(ReauthResponse { reauthed_at }).into_response())
266
+
Ok(Json(ReauthOutput { reauthed_at }))
274
267
}
275
268
276
269
pub async fn update_last_reauth_cached(
···
302
295
303
296
async fn get_available_reauth_methods(
304
297
user_repo: &dyn UserRepository,
305
-
_session_repo: &dyn SessionRepository,
306
298
did: &tranquil_pds::types::Did,
307
299
) -> Vec<ReauthMethod> {
308
-
let mut methods = Vec::new();
309
-
310
300
let has_password = user_repo
311
301
.get_password_hash_by_did(did)
312
302
.await
313
303
.ok()
314
304
.flatten()
315
305
.is_some();
316
-
317
-
if has_password {
318
-
methods.push(ReauthMethod::Password);
319
-
}
320
-
321
306
let has_totp = user_repo.has_totp_enabled(did).await.unwrap_or(false);
322
-
if has_totp {
323
-
methods.push(ReauthMethod::Totp);
324
-
}
325
-
326
307
let has_passkeys = user_repo.has_passkeys(did).await.unwrap_or(false);
327
-
if has_passkeys {
328
-
methods.push(ReauthMethod::Passkey);
329
-
}
330
308
331
-
methods
309
+
[
310
+
(has_password, ReauthMethod::Password),
311
+
(has_totp, ReauthMethod::Totp),
312
+
(has_passkeys, ReauthMethod::Passkey),
313
+
]
314
+
.into_iter()
315
+
.filter_map(|(enabled, method)| enabled.then_some(method))
316
+
.collect()
332
317
}
333
318
334
319
pub async fn check_reauth_required(
···
364
349
}
365
350
}
366
351
367
-
#[derive(Serialize)]
368
-
#[serde(rename_all = "camelCase")]
369
-
pub struct ReauthRequiredError {
370
-
pub error: String,
371
-
pub message: String,
372
-
pub reauth_methods: Vec<ReauthMethod>,
373
-
}
374
-
375
-
pub async fn reauth_required_response(
376
-
user_repo: &dyn UserRepository,
377
-
session_repo: &dyn SessionRepository,
378
-
did: &tranquil_pds::types::Did,
379
-
) -> Response {
380
-
let methods = get_available_reauth_methods(user_repo, session_repo, did).await;
381
-
(
382
-
StatusCode::UNAUTHORIZED,
383
-
Json(ReauthRequiredError {
384
-
error: "ReauthRequired".to_string(),
385
-
message: "Re-authentication required for this action".to_string(),
386
-
reauth_methods: methods,
387
-
}),
388
-
)
389
-
.into_response()
390
-
}
391
-
392
352
pub async fn check_legacy_session_mfa(
393
353
session_repo: &dyn SessionRepository,
394
354
did: &tranquil_pds::types::Did,
···
419
379
) -> Result<(), tranquil_db_traits::DbError> {
420
380
session_repo.update_mfa_verified(did).await
421
381
}
422
-
423
-
pub async fn legacy_mfa_required_response(
424
-
user_repo: &dyn UserRepository,
425
-
session_repo: &dyn SessionRepository,
426
-
did: &tranquil_pds::types::Did,
427
-
) -> Response {
428
-
let methods = get_available_reauth_methods(user_repo, session_repo, did).await;
429
-
(
430
-
StatusCode::FORBIDDEN,
431
-
Json(MfaVerificationRequiredError {
432
-
error: "MfaVerificationRequired".to_string(),
433
-
message: "This sensitive operation requires MFA verification. Your session was created via a legacy app that doesn't support MFA during login.".to_string(),
434
-
reauth_methods: methods,
435
-
}),
436
-
)
437
-
.into_response()
438
-
}
439
-
440
-
#[derive(Serialize)]
441
-
#[serde(rename_all = "camelCase")]
442
-
pub struct MfaVerificationRequiredError {
443
-
pub error: String,
444
-
pub message: String,
445
-
pub reauth_methods: Vec<ReauthMethod>,
446
-
}
+18
-27
crates/tranquil-api/src/server/totp.rs
+18
-27
crates/tranquil-api/src/server/totp.rs
···
1
-
use axum::{
2
-
Json,
3
-
extract::State,
4
-
response::{IntoResponse, Response},
5
-
};
1
+
use axum::{Json, extract::State};
6
2
use serde::{Deserialize, Serialize};
7
3
use tracing::{error, info, warn};
8
4
use tranquil_pds::api::EmptyResponse;
···
21
17
22
18
#[derive(Serialize)]
23
19
#[serde(rename_all = "camelCase")]
24
-
pub struct CreateTotpSecretResponse {
20
+
pub struct CreateTotpSecretOutput {
25
21
pub secret: String,
26
22
pub uri: String,
27
23
pub qr_base64: String,
···
30
26
pub async fn create_totp_secret(
31
27
State(state): State<AppState>,
32
28
auth: Auth<Active>,
33
-
) -> Result<Response, ApiError> {
29
+
) -> Result<Json<CreateTotpSecretOutput>, ApiError> {
34
30
use tranquil_db_traits::TotpRecordState;
35
31
36
32
match state.user_repo.get_totp_record_state(&auth.did).await {
···
74
70
75
71
info!(did = %&auth.did, "TOTP secret created (pending verification)");
76
72
77
-
Ok(Json(CreateTotpSecretResponse {
73
+
Ok(Json(CreateTotpSecretOutput {
78
74
secret: secret_base32,
79
75
uri,
80
76
qr_base64: qr_code,
81
-
})
82
-
.into_response())
77
+
}))
83
78
}
84
79
85
80
#[derive(Deserialize)]
···
89
84
90
85
#[derive(Serialize)]
91
86
#[serde(rename_all = "camelCase")]
92
-
pub struct EnableTotpResponse {
87
+
pub struct EnableTotpOutput {
93
88
pub backup_codes: Vec<String>,
94
89
}
95
90
···
97
92
State(state): State<AppState>,
98
93
auth: Auth<Active>,
99
94
Json(input): Json<EnableTotpInput>,
100
-
) -> Result<Response, ApiError> {
95
+
) -> Result<Json<EnableTotpOutput>, ApiError> {
101
96
use tranquil_db_traits::TotpRecordState;
102
97
103
98
let _rate_limit = check_user_rate_limit_with_message::<TotpVerifyLimit>(
···
151
146
152
147
info!(did = %&auth.did, "TOTP enabled with {} backup codes", backup_codes.len());
153
148
154
-
Ok(Json(EnableTotpResponse { backup_codes }).into_response())
149
+
Ok(Json(EnableTotpOutput { backup_codes }))
155
150
}
156
151
157
152
#[derive(Deserialize)]
···
164
159
State(state): State<AppState>,
165
160
auth: Auth<Active>,
166
161
Json(input): Json<DisableTotpInput>,
167
-
) -> Result<Response, ApiError> {
168
-
let session_mfa = match require_legacy_session_mfa(&state, &auth).await {
169
-
Ok(proof) => proof,
170
-
Err(response) => return Ok(response),
171
-
};
162
+
) -> Result<Json<EmptyResponse>, ApiError> {
163
+
let session_mfa = require_legacy_session_mfa(&state, &auth).await?;
172
164
173
165
let _rate_limit = check_user_rate_limit_with_message::<TotpVerifyLimit>(
174
166
&state,
···
190
182
191
183
info!(did = %session_mfa.did(), "TOTP disabled (verified via {} and {})", password_mfa.method(), totp_mfa.method());
192
184
193
-
Ok(EmptyResponse::ok().into_response())
185
+
Ok(Json(EmptyResponse {}))
194
186
}
195
187
196
188
#[derive(Serialize)]
197
189
#[serde(rename_all = "camelCase")]
198
-
pub struct GetTotpStatusResponse {
190
+
pub struct GetTotpStatusOutput {
199
191
pub enabled: bool,
200
192
pub has_backup_codes: bool,
201
193
pub backup_codes_remaining: i64,
···
204
196
pub async fn get_totp_status(
205
197
State(state): State<AppState>,
206
198
auth: Auth<Active>,
207
-
) -> Result<Response, ApiError> {
199
+
) -> Result<Json<GetTotpStatusOutput>, ApiError> {
208
200
use tranquil_db_traits::TotpRecordState;
209
201
210
202
let enabled = match state.user_repo.get_totp_record_state(&auth.did).await {
···
222
214
.await
223
215
.log_db_err("counting backup codes")?;
224
216
225
-
Ok(Json(GetTotpStatusResponse {
217
+
Ok(Json(GetTotpStatusOutput {
226
218
enabled,
227
219
has_backup_codes: backup_count > 0,
228
220
backup_codes_remaining: backup_count,
229
-
})
230
-
.into_response())
221
+
}))
231
222
}
232
223
233
224
#[derive(Deserialize)]
···
238
229
239
230
#[derive(Serialize)]
240
231
#[serde(rename_all = "camelCase")]
241
-
pub struct RegenerateBackupCodesResponse {
232
+
pub struct RegenerateBackupCodesOutput {
242
233
pub backup_codes: Vec<String>,
243
234
}
244
235
···
246
237
State(state): State<AppState>,
247
238
auth: Auth<Active>,
248
239
Json(input): Json<RegenerateBackupCodesInput>,
249
-
) -> Result<Response, ApiError> {
240
+
) -> Result<Json<RegenerateBackupCodesOutput>, ApiError> {
250
241
let _rate_limit = check_user_rate_limit_with_message::<TotpVerifyLimit>(
251
242
&state,
252
243
&auth.did,
···
275
266
276
267
info!(did = %password_mfa.did(), "Backup codes regenerated (verified via {} and {})", password_mfa.method(), totp_mfa.method());
277
268
278
-
Ok(Json(RegenerateBackupCodesResponse { backup_codes }).into_response())
269
+
Ok(Json(RegenerateBackupCodesOutput { backup_codes }))
279
270
}
280
271
281
272
async fn verify_backup_code_for_user(
+32
-52
crates/tranquil-api/src/server/verify_token.rs
+32
-52
crates/tranquil-api/src/server/verify_token.rs
···
1
-
use axum::{Json, extract::State};
1
+
use crate::common;
2
+
use axum::{
3
+
Json,
4
+
extract::State,
5
+
response::{IntoResponse, Response},
6
+
};
2
7
use serde::{Deserialize, Serialize};
3
8
use tracing::{info, warn};
9
+
use tranquil_pds::api::SuccessResponse;
4
10
use tranquil_pds::api::error::{ApiError, DbResultExt};
5
11
use tranquil_pds::comms::comms_repo;
6
12
use tranquil_pds::types::Did;
···
92
98
.log_db_err("updating email_verified status")?;
93
99
}
94
100
}
95
-
CommsChannel::Discord => {
96
-
state
97
-
.user_repo
98
-
.set_discord_verified_flag(user.id)
99
-
.await
100
-
.log_db_err("updating discord verified status")?;
101
-
}
102
-
CommsChannel::Telegram => {
103
-
state
104
-
.user_repo
105
-
.set_telegram_verified_flag(user.id)
106
-
.await
107
-
.log_db_err("updating telegram verified status")?;
108
-
}
109
-
CommsChannel::Signal => {
110
-
state
111
-
.user_repo
112
-
.set_signal_verified_flag(user.id)
113
-
.await
114
-
.log_db_err("updating signal verified status")?;
115
-
}
101
+
_ => common::set_channel_verified_flag(state.user_repo.as_ref(), user.id, channel).await?,
116
102
};
117
103
118
104
info!(did = %did, channel = ?channel, "Migration verification completed successfully");
···
239
225
}));
240
226
}
241
227
242
-
match channel {
243
-
CommsChannel::Email => {
244
-
state
245
-
.user_repo
246
-
.set_email_verified_flag(user.id)
247
-
.await
248
-
.log_db_err("updating email verified status")?;
249
-
}
250
-
CommsChannel::Discord => {
251
-
state
252
-
.user_repo
253
-
.set_discord_verified_flag(user.id)
254
-
.await
255
-
.log_db_err("updating discord verified status")?;
256
-
}
257
-
CommsChannel::Telegram => {
258
-
state
259
-
.user_repo
260
-
.set_telegram_verified_flag(user.id)
261
-
.await
262
-
.log_db_err("updating telegram verified status")?;
263
-
}
264
-
CommsChannel::Signal => {
265
-
state
266
-
.user_repo
267
-
.set_signal_verified_flag(user.id)
268
-
.await
269
-
.log_db_err("updating signal verified status")?;
270
-
}
271
-
};
228
+
common::set_channel_verified_flag(state.user_repo.as_ref(), user.id, channel).await?;
272
229
273
230
info!(did = %did, channel = ?channel, "Signup verified successfully");
274
231
···
293
250
channel,
294
251
}))
295
252
}
253
+
254
+
#[derive(Deserialize)]
255
+
#[serde(rename_all = "camelCase")]
256
+
pub struct ConfirmChannelVerificationInput {
257
+
pub channel: CommsChannel,
258
+
pub identifier: String,
259
+
pub code: String,
260
+
}
261
+
262
+
pub async fn confirm_channel_verification(
263
+
State(state): State<AppState>,
264
+
Json(input): Json<ConfirmChannelVerificationInput>,
265
+
) -> Response {
266
+
let token_input = VerifyTokenInput {
267
+
token: input.code,
268
+
identifier: input.identifier,
269
+
};
270
+
271
+
match verify_token_internal(&state, token_input).await {
272
+
Ok(_output) => SuccessResponse::ok().into_response(),
273
+
Err(e) => e.into_response(),
274
+
}
275
+
}
History
1 round
0 comments
oyster.cafe
submitted
#0
1 commit
expand
collapse
refactor(api): update password, reauth, verify, account_status, and totp endpoints
expand 0 comments
pull request successfully merged