this repo has no description
1use crate::api::ApiError;
2use crate::auth::BearerAuth;
3use crate::state::{AppState, RateLimitKind};
4use axum::{
5 Json,
6 extract::State,
7 http::{HeaderMap, StatusCode},
8 response::{IntoResponse, Response},
9};
10use bcrypt::verify;
11use chrono::Utc;
12use serde::{Deserialize, Serialize};
13use serde_json::json;
14use tracing::{error, info, warn};
15
16fn extract_client_ip(headers: &HeaderMap) -> String {
17 if let Some(forwarded) = headers.get("x-forwarded-for")
18 && let Ok(value) = forwarded.to_str()
19 && let Some(first_ip) = value.split(',').next() {
20 return first_ip.trim().to_string();
21 }
22 if let Some(real_ip) = headers.get("x-real-ip")
23 && let Ok(value) = real_ip.to_str() {
24 return value.trim().to_string();
25 }
26 "unknown".to_string()
27}
28
29fn normalize_handle(identifier: &str, pds_hostname: &str) -> String {
30 let suffix = format!(".{}", pds_hostname);
31 if identifier.ends_with(&suffix) {
32 identifier[..identifier.len() - suffix.len()].to_string()
33 } else {
34 identifier.to_string()
35 }
36}
37
38#[derive(Deserialize)]
39pub struct CreateSessionInput {
40 pub identifier: String,
41 pub password: String,
42}
43
44#[derive(Serialize)]
45#[serde(rename_all = "camelCase")]
46pub struct CreateSessionOutput {
47 pub access_jwt: String,
48 pub refresh_jwt: String,
49 pub handle: String,
50 pub did: String,
51}
52
53pub async fn create_session(
54 State(state): State<AppState>,
55 headers: HeaderMap,
56 Json(input): Json<CreateSessionInput>,
57) -> Response {
58 info!("create_session called");
59 let client_ip = extract_client_ip(&headers);
60 if !state
61 .check_rate_limit(RateLimitKind::Login, &client_ip)
62 .await
63 {
64 warn!(ip = %client_ip, "Login rate limit exceeded");
65 return (
66 StatusCode::TOO_MANY_REQUESTS,
67 Json(json!({
68 "error": "RateLimitExceeded",
69 "message": "Too many login attempts. Please try again later."
70 })),
71 )
72 .into_response();
73 }
74 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
75 let normalized_identifier = normalize_handle(&input.identifier, &pds_hostname);
76 let row = match sqlx::query!(
77 r#"SELECT
78 u.id, u.did, u.handle, u.password_hash,
79 u.email_confirmed, u.discord_verified, u.telegram_verified, u.signal_verified,
80 k.key_bytes, k.encryption_version
81 FROM users u
82 JOIN user_keys k ON u.id = k.user_id
83 WHERE u.handle = $1 OR u.email = $1"#,
84 normalized_identifier
85 )
86 .fetch_optional(&state.db)
87 .await
88 {
89 Ok(Some(row)) => row,
90 Ok(None) => {
91 let _ = verify(
92 &input.password,
93 "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.VTtYw1ZzQKZqmK",
94 );
95 warn!("User not found for login attempt");
96 return ApiError::AuthenticationFailedMsg("Invalid identifier or password".into())
97 .into_response();
98 }
99 Err(e) => {
100 error!("Database error fetching user: {:?}", e);
101 return ApiError::InternalError.into_response();
102 }
103 };
104 let key_bytes = match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
105 Ok(k) => k,
106 Err(e) => {
107 error!("Failed to decrypt user key: {:?}", e);
108 return ApiError::InternalError.into_response();
109 }
110 };
111 let password_valid = if verify(&input.password, &row.password_hash).unwrap_or(false) {
112 true
113 } else {
114 let app_passwords = sqlx::query!(
115 "SELECT password_hash FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20",
116 row.id
117 )
118 .fetch_all(&state.db)
119 .await
120 .unwrap_or_default();
121 app_passwords
122 .iter()
123 .any(|app| verify(&input.password, &app.password_hash).unwrap_or(false))
124 };
125 if !password_valid {
126 warn!("Password verification failed for login attempt");
127 return ApiError::AuthenticationFailedMsg("Invalid identifier or password".into())
128 .into_response();
129 }
130 let is_verified =
131 row.email_confirmed || row.discord_verified || row.telegram_verified || row.signal_verified;
132 if !is_verified {
133 warn!("Login attempt for unverified account: {}", row.did);
134 return (
135 StatusCode::FORBIDDEN,
136 Json(json!({
137 "error": "AccountNotVerified",
138 "message": "Please verify your account before logging in",
139 "did": row.did
140 })),
141 )
142 .into_response();
143 }
144 let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) {
145 Ok(m) => m,
146 Err(e) => {
147 error!("Failed to create access token: {:?}", e);
148 return ApiError::InternalError.into_response();
149 }
150 };
151 let refresh_meta = match crate::auth::create_refresh_token_with_metadata(&row.did, &key_bytes) {
152 Ok(m) => m,
153 Err(e) => {
154 error!("Failed to create refresh token: {:?}", e);
155 return ApiError::InternalError.into_response();
156 }
157 };
158 if let Err(e) = sqlx::query!(
159 "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)",
160 row.did,
161 access_meta.jti,
162 refresh_meta.jti,
163 access_meta.expires_at,
164 refresh_meta.expires_at
165 )
166 .execute(&state.db)
167 .await
168 {
169 error!("Failed to insert session: {:?}", e);
170 return ApiError::InternalError.into_response();
171 }
172 let full_handle = format!("{}.{}", row.handle, pds_hostname);
173 Json(CreateSessionOutput {
174 access_jwt: access_meta.token,
175 refresh_jwt: refresh_meta.token,
176 handle: full_handle,
177 did: row.did,
178 })
179 .into_response()
180}
181
182pub async fn get_session(
183 State(state): State<AppState>,
184 BearerAuth(auth_user): BearerAuth,
185) -> Response {
186 match sqlx::query!(
187 r#"SELECT
188 handle, email, email_confirmed, is_admin,
189 preferred_notification_channel as "preferred_channel: crate::notifications::NotificationChannel",
190 discord_verified, telegram_verified, signal_verified
191 FROM users WHERE did = $1"#,
192 auth_user.did
193 )
194 .fetch_optional(&state.db)
195 .await
196 {
197 Ok(Some(row)) => {
198 let (preferred_channel, preferred_channel_verified) = match row.preferred_channel {
199 crate::notifications::NotificationChannel::Email => ("email", row.email_confirmed),
200 crate::notifications::NotificationChannel::Discord => ("discord", row.discord_verified),
201 crate::notifications::NotificationChannel::Telegram => ("telegram", row.telegram_verified),
202 crate::notifications::NotificationChannel::Signal => ("signal", row.signal_verified),
203 };
204 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
205 let full_handle = format!("{}.{}", row.handle, pds_hostname);
206 Json(json!({
207 "handle": full_handle,
208 "did": auth_user.did,
209 "email": row.email,
210 "emailConfirmed": row.email_confirmed,
211 "preferredChannel": preferred_channel,
212 "preferredChannelVerified": preferred_channel_verified,
213 "isAdmin": row.is_admin,
214 "active": true,
215 "didDoc": {}
216 })).into_response()
217 }
218 Ok(None) => ApiError::AuthenticationFailed.into_response(),
219 Err(e) => {
220 error!("Database error in get_session: {:?}", e);
221 ApiError::InternalError.into_response()
222 }
223 }
224}
225
226pub async fn delete_session(
227 State(state): State<AppState>,
228 headers: axum::http::HeaderMap,
229) -> Response {
230 let token = match crate::auth::extract_bearer_token_from_header(
231 headers.get("Authorization").and_then(|h| h.to_str().ok()),
232 ) {
233 Some(t) => t,
234 None => return ApiError::AuthenticationRequired.into_response(),
235 };
236 let jti = match crate::auth::get_jti_from_token(&token) {
237 Ok(jti) => jti,
238 Err(_) => return ApiError::AuthenticationFailed.into_response(),
239 };
240 let did = crate::auth::get_did_from_token(&token).ok();
241 match sqlx::query!("DELETE FROM session_tokens WHERE access_jti = $1", jti)
242 .execute(&state.db)
243 .await
244 {
245 Ok(res) if res.rows_affected() > 0 => {
246 if let Some(did) = did {
247 let session_cache_key = format!("auth:session:{}:{}", did, jti);
248 let _ = state.cache.delete(&session_cache_key).await;
249 }
250 Json(json!({})).into_response()
251 }
252 Ok(_) => ApiError::AuthenticationFailed.into_response(),
253 Err(e) => {
254 error!("Database error in delete_session: {:?}", e);
255 ApiError::AuthenticationFailed.into_response()
256 }
257 }
258}
259
260pub async fn refresh_session(
261 State(state): State<AppState>,
262 headers: axum::http::HeaderMap,
263) -> Response {
264 let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
265 if !state
266 .check_rate_limit(RateLimitKind::RefreshSession, &client_ip)
267 .await
268 {
269 tracing::warn!(ip = %client_ip, "Refresh session rate limit exceeded");
270 return (
271 axum::http::StatusCode::TOO_MANY_REQUESTS,
272 axum::Json(serde_json::json!({
273 "error": "RateLimitExceeded",
274 "message": "Too many requests. Please try again later."
275 })),
276 )
277 .into_response();
278 }
279 let refresh_token = match crate::auth::extract_bearer_token_from_header(
280 headers.get("Authorization").and_then(|h| h.to_str().ok()),
281 ) {
282 Some(t) => t,
283 None => return ApiError::AuthenticationRequired.into_response(),
284 };
285 let refresh_jti = match crate::auth::get_jti_from_token(&refresh_token) {
286 Ok(jti) => jti,
287 Err(_) => {
288 return ApiError::AuthenticationFailedMsg("Invalid token format".into())
289 .into_response();
290 }
291 };
292 let mut tx = match state.db.begin().await {
293 Ok(tx) => tx,
294 Err(e) => {
295 error!("Failed to begin transaction: {:?}", e);
296 return ApiError::InternalError.into_response();
297 }
298 };
299 if let Ok(Some(session_id)) = sqlx::query_scalar!(
300 "SELECT session_id FROM used_refresh_tokens WHERE refresh_jti = $1 FOR UPDATE",
301 refresh_jti
302 )
303 .fetch_optional(&mut *tx)
304 .await
305 {
306 warn!(
307 "Refresh token reuse detected! Revoking token family for session_id: {}",
308 session_id
309 );
310 let _ = sqlx::query!("DELETE FROM session_tokens WHERE id = $1", session_id)
311 .execute(&mut *tx)
312 .await;
313 let _ = tx.commit().await;
314 return ApiError::ExpiredTokenMsg(
315 "Refresh token has been revoked due to suspected compromise".into(),
316 )
317 .into_response();
318 }
319 let session_row = match sqlx::query!(
320 r#"SELECT st.id, st.did, k.key_bytes, k.encryption_version
321 FROM session_tokens st
322 JOIN users u ON st.did = u.did
323 JOIN user_keys k ON u.id = k.user_id
324 WHERE st.refresh_jti = $1 AND st.refresh_expires_at > NOW()
325 FOR UPDATE OF st"#,
326 refresh_jti
327 )
328 .fetch_optional(&mut *tx)
329 .await
330 {
331 Ok(Some(row)) => row,
332 Ok(None) => {
333 return ApiError::AuthenticationFailedMsg("Invalid refresh token".into())
334 .into_response();
335 }
336 Err(e) => {
337 error!("Database error fetching session: {:?}", e);
338 return ApiError::InternalError.into_response();
339 }
340 };
341 let key_bytes =
342 match crate::config::decrypt_key(&session_row.key_bytes, session_row.encryption_version) {
343 Ok(k) => k,
344 Err(e) => {
345 error!("Failed to decrypt user key: {:?}", e);
346 return ApiError::InternalError.into_response();
347 }
348 };
349 if crate::auth::verify_refresh_token(&refresh_token, &key_bytes).is_err() {
350 return ApiError::AuthenticationFailedMsg("Invalid refresh token".into()).into_response();
351 }
352 let new_access_meta =
353 match crate::auth::create_access_token_with_metadata(&session_row.did, &key_bytes) {
354 Ok(m) => m,
355 Err(e) => {
356 error!("Failed to create access token: {:?}", e);
357 return ApiError::InternalError.into_response();
358 }
359 };
360 let new_refresh_meta =
361 match crate::auth::create_refresh_token_with_metadata(&session_row.did, &key_bytes) {
362 Ok(m) => m,
363 Err(e) => {
364 error!("Failed to create refresh token: {:?}", e);
365 return ApiError::InternalError.into_response();
366 }
367 };
368 match sqlx::query!(
369 "INSERT INTO used_refresh_tokens (refresh_jti, session_id) VALUES ($1, $2) ON CONFLICT (refresh_jti) DO NOTHING",
370 refresh_jti,
371 session_row.id
372 )
373 .execute(&mut *tx)
374 .await
375 {
376 Ok(result) if result.rows_affected() == 0 => {
377 warn!("Concurrent refresh token reuse detected for session_id: {}", session_row.id);
378 let _ = sqlx::query!("DELETE FROM session_tokens WHERE id = $1", session_row.id)
379 .execute(&mut *tx)
380 .await;
381 let _ = tx.commit().await;
382 return ApiError::ExpiredTokenMsg("Refresh token has been revoked due to suspected compromise".into()).into_response();
383 }
384 Err(e) => {
385 error!("Failed to record used refresh token: {:?}", e);
386 return ApiError::InternalError.into_response();
387 }
388 Ok(_) => {}
389 }
390 if let Err(e) = sqlx::query!(
391 "UPDATE session_tokens SET access_jti = $1, refresh_jti = $2, access_expires_at = $3, refresh_expires_at = $4, updated_at = NOW() WHERE id = $5",
392 new_access_meta.jti,
393 new_refresh_meta.jti,
394 new_access_meta.expires_at,
395 new_refresh_meta.expires_at,
396 session_row.id
397 )
398 .execute(&mut *tx)
399 .await
400 {
401 error!("Database error updating session: {:?}", e);
402 return ApiError::InternalError.into_response();
403 }
404 if let Err(e) = tx.commit().await {
405 error!("Failed to commit transaction: {:?}", e);
406 return ApiError::InternalError.into_response();
407 }
408 match sqlx::query!(
409 r#"SELECT
410 handle, email, email_confirmed, is_admin,
411 preferred_notification_channel as "preferred_channel: crate::notifications::NotificationChannel",
412 discord_verified, telegram_verified, signal_verified
413 FROM users WHERE did = $1"#,
414 session_row.did
415 )
416 .fetch_optional(&state.db)
417 .await
418 {
419 Ok(Some(u)) => {
420 let (preferred_channel, preferred_channel_verified) = match u.preferred_channel {
421 crate::notifications::NotificationChannel::Email => ("email", u.email_confirmed),
422 crate::notifications::NotificationChannel::Discord => ("discord", u.discord_verified),
423 crate::notifications::NotificationChannel::Telegram => ("telegram", u.telegram_verified),
424 crate::notifications::NotificationChannel::Signal => ("signal", u.signal_verified),
425 };
426 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
427 let full_handle = format!("{}.{}", u.handle, pds_hostname);
428 Json(json!({
429 "accessJwt": new_access_meta.token,
430 "refreshJwt": new_refresh_meta.token,
431 "handle": full_handle,
432 "did": session_row.did,
433 "email": u.email,
434 "emailConfirmed": u.email_confirmed,
435 "preferredChannel": preferred_channel,
436 "preferredChannelVerified": preferred_channel_verified,
437 "isAdmin": u.is_admin,
438 "active": true
439 })).into_response()
440 }
441 Ok(None) => {
442 error!("User not found for existing session: {}", session_row.did);
443 ApiError::InternalError.into_response()
444 }
445 Err(e) => {
446 error!("Database error fetching user: {:?}", e);
447 ApiError::InternalError.into_response()
448 }
449 }
450}
451
452#[derive(Deserialize)]
453#[serde(rename_all = "camelCase")]
454pub struct ConfirmSignupInput {
455 pub did: String,
456 pub verification_code: String,
457}
458
459#[derive(Serialize)]
460#[serde(rename_all = "camelCase")]
461pub struct ConfirmSignupOutput {
462 pub access_jwt: String,
463 pub refresh_jwt: String,
464 pub handle: String,
465 pub did: String,
466 pub email: Option<String>,
467 pub email_confirmed: bool,
468 pub preferred_channel: String,
469 pub preferred_channel_verified: bool,
470}
471
472pub async fn confirm_signup(
473 State(state): State<AppState>,
474 Json(input): Json<ConfirmSignupInput>,
475) -> Response {
476 info!("confirm_signup called for DID: {}", input.did);
477 let row = match sqlx::query!(
478 r#"SELECT
479 u.id, u.did, u.handle, u.email,
480 u.preferred_notification_channel as "channel: crate::notifications::NotificationChannel",
481 k.key_bytes, k.encryption_version
482 FROM users u
483 JOIN user_keys k ON u.id = k.user_id
484 WHERE u.did = $1"#,
485 input.did
486 )
487 .fetch_optional(&state.db)
488 .await
489 {
490 Ok(Some(row)) => row,
491 Ok(None) => {
492 warn!("User not found for confirm_signup: {}", input.did);
493 return ApiError::InvalidRequest("Invalid DID or verification code".into()).into_response();
494 }
495 Err(e) => {
496 error!("Database error in confirm_signup: {:?}", e);
497 return ApiError::InternalError.into_response();
498 }
499 };
500
501 let verification = match sqlx::query!(
502 "SELECT code, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = 'email'",
503 row.id
504 )
505 .fetch_optional(&state.db)
506 .await
507 {
508 Ok(Some(v)) => v,
509 Ok(None) => {
510 warn!("No verification code found for user: {}", input.did);
511 return ApiError::InvalidRequest("No pending verification".into()).into_response();
512 }
513 Err(e) => {
514 error!("Database error fetching verification: {:?}", e);
515 return ApiError::InternalError.into_response();
516 }
517 };
518
519 if verification.code != input.verification_code {
520 warn!("Invalid verification code for user: {}", input.did);
521 return ApiError::InvalidRequest("Invalid verification code".into()).into_response();
522 }
523 if verification.expires_at < Utc::now() {
524 warn!("Verification code expired for user: {}", input.did);
525 return ApiError::ExpiredTokenMsg("Verification code has expired".into())
526 .into_response();
527 }
528
529 let key_bytes = match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
530 Ok(k) => k,
531 Err(e) => {
532 error!("Failed to decrypt user key: {:?}", e);
533 return ApiError::InternalError.into_response();
534 }
535 };
536 let verified_column = match row.channel {
537 crate::notifications::NotificationChannel::Email => "email_confirmed",
538 crate::notifications::NotificationChannel::Discord => "discord_verified",
539 crate::notifications::NotificationChannel::Telegram => "telegram_verified",
540 crate::notifications::NotificationChannel::Signal => "signal_verified",
541 };
542 let update_query = format!(
543 "UPDATE users SET {} = TRUE WHERE did = $1",
544 verified_column
545 );
546 if let Err(e) = sqlx::query(&update_query)
547 .bind(&input.did)
548 .execute(&state.db)
549 .await
550 {
551 error!("Failed to update verification status: {:?}", e);
552 return ApiError::InternalError.into_response();
553 }
554
555 if let Err(e) = sqlx::query!(
556 "DELETE FROM channel_verifications WHERE user_id = $1 AND channel = 'email'",
557 row.id
558 )
559 .execute(&state.db)
560 .await {
561 error!("Failed to delete verification record: {:?}", e);
562 }
563
564 let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) {
565 Ok(m) => m,
566 Err(e) => {
567 error!("Failed to create access token: {:?}", e);
568 return ApiError::InternalError.into_response();
569 }
570 };
571 let refresh_meta = match crate::auth::create_refresh_token_with_metadata(&row.did, &key_bytes) {
572 Ok(m) => m,
573 Err(e) => {
574 error!("Failed to create refresh token: {:?}", e);
575 return ApiError::InternalError.into_response();
576 }
577 };
578 if let Err(e) = sqlx::query!(
579 "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)",
580 row.did,
581 access_meta.jti,
582 refresh_meta.jti,
583 access_meta.expires_at,
584 refresh_meta.expires_at
585 )
586 .execute(&state.db)
587 .await
588 {
589 error!("Failed to insert session: {:?}", e);
590 return ApiError::InternalError.into_response();
591 }
592 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
593 if let Err(e) = crate::notifications::enqueue_welcome(&state.db, row.id, &hostname).await {
594 warn!("Failed to enqueue welcome notification: {:?}", e);
595 }
596 let email_confirmed = matches!(
597 row.channel,
598 crate::notifications::NotificationChannel::Email
599 );
600 let preferred_channel = match row.channel {
601 crate::notifications::NotificationChannel::Email => "email",
602 crate::notifications::NotificationChannel::Discord => "discord",
603 crate::notifications::NotificationChannel::Telegram => "telegram",
604 crate::notifications::NotificationChannel::Signal => "signal",
605 };
606 Json(ConfirmSignupOutput {
607 access_jwt: access_meta.token,
608 refresh_jwt: refresh_meta.token,
609 handle: row.handle,
610 did: row.did,
611 email: row.email,
612 email_confirmed,
613 preferred_channel: preferred_channel.to_string(),
614 preferred_channel_verified: true,
615 })
616 .into_response()
617}
618
619#[derive(Deserialize)]
620#[serde(rename_all = "camelCase")]
621pub struct ResendVerificationInput {
622 pub did: String,
623}
624
625pub async fn resend_verification(
626 State(state): State<AppState>,
627 Json(input): Json<ResendVerificationInput>,
628) -> Response {
629 info!("resend_verification called for DID: {}", input.did);
630 let row = match sqlx::query!(
631 r#"SELECT
632 id, handle, email,
633 preferred_notification_channel as "channel: crate::notifications::NotificationChannel",
634 discord_id, telegram_username, signal_number,
635 email_confirmed, discord_verified, telegram_verified, signal_verified
636 FROM users
637 WHERE did = $1"#,
638 input.did
639 )
640 .fetch_optional(&state.db)
641 .await
642 {
643 Ok(Some(row)) => row,
644 Ok(None) => {
645 return ApiError::InvalidRequest("User not found".into()).into_response();
646 }
647 Err(e) => {
648 error!("Database error in resend_verification: {:?}", e);
649 return ApiError::InternalError.into_response();
650 }
651 };
652 let is_verified =
653 row.email_confirmed || row.discord_verified || row.telegram_verified || row.signal_verified;
654 if is_verified {
655 return ApiError::InvalidRequest("Account is already verified".into()).into_response();
656 }
657 let verification_code = format!("{:06}", rand::random::<u32>() % 1_000_000);
658 let code_expires_at = Utc::now() + chrono::Duration::minutes(30);
659
660 let email = row.email.clone();
661
662 if let Err(e) = sqlx::query!(
663 r#"
664 INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at)
665 VALUES ($1, 'email', $2, $3, $4)
666 ON CONFLICT (user_id, channel) DO UPDATE
667 SET code = $2, pending_identifier = $3, expires_at = $4, created_at = NOW()
668 "#,
669 row.id,
670 verification_code,
671 email,
672 code_expires_at
673 )
674 .execute(&state.db)
675 .await
676 {
677 error!("Failed to update verification code: {:?}", e);
678 return ApiError::InternalError.into_response();
679 }
680 let (channel_str, recipient) = match row.channel {
681 crate::notifications::NotificationChannel::Email => {
682 ("email", row.email.unwrap_or_default())
683 }
684 crate::notifications::NotificationChannel::Discord => {
685 ("discord", row.discord_id.unwrap_or_default())
686 }
687 crate::notifications::NotificationChannel::Telegram => {
688 ("telegram", row.telegram_username.unwrap_or_default())
689 }
690 crate::notifications::NotificationChannel::Signal => {
691 ("signal", row.signal_number.unwrap_or_default())
692 }
693 };
694 if let Err(e) = crate::notifications::enqueue_signup_verification(
695 &state.db,
696 row.id,
697 channel_str,
698 &recipient,
699 &verification_code,
700 )
701 .await
702 {
703 warn!("Failed to enqueue verification notification: {:?}", e);
704 }
705 Json(json!({"success": true})).into_response()
706}
707
708#[derive(Serialize)]
709#[serde(rename_all = "camelCase")]
710pub struct SessionInfo {
711 pub id: String,
712 pub created_at: String,
713 pub expires_at: String,
714 pub is_current: bool,
715}
716
717#[derive(Serialize)]
718#[serde(rename_all = "camelCase")]
719pub struct ListSessionsOutput {
720 pub sessions: Vec<SessionInfo>,
721}
722
723pub async fn list_sessions(
724 State(state): State<AppState>,
725 headers: HeaderMap,
726 auth: BearerAuth,
727) -> Response {
728 let current_jti = headers
729 .get("authorization")
730 .and_then(|v| v.to_str().ok())
731 .and_then(|v| v.strip_prefix("Bearer "))
732 .and_then(|token| crate::auth::get_jti_from_token(token).ok());
733 let result = sqlx::query_as::<_, (i32, String, chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>)>(
734 r#"
735 SELECT id, access_jti, created_at, refresh_expires_at
736 FROM session_tokens
737 WHERE did = $1 AND refresh_expires_at > NOW()
738 ORDER BY created_at DESC
739 "#,
740 )
741 .bind(&auth.0.did)
742 .fetch_all(&state.db)
743 .await;
744 match result {
745 Ok(rows) => {
746 let sessions: Vec<SessionInfo> = rows
747 .into_iter()
748 .map(|(id, access_jti, created_at, expires_at)| SessionInfo {
749 id: id.to_string(),
750 created_at: created_at.to_rfc3339(),
751 expires_at: expires_at.to_rfc3339(),
752 is_current: current_jti.as_ref().map_or(false, |j| j == &access_jti),
753 })
754 .collect();
755 (StatusCode::OK, Json(ListSessionsOutput { sessions })).into_response()
756 }
757 Err(e) => {
758 error!("DB error in list_sessions: {:?}", e);
759 (
760 StatusCode::INTERNAL_SERVER_ERROR,
761 Json(json!({"error": "InternalError"})),
762 )
763 .into_response()
764 }
765 }
766}
767
768#[derive(Deserialize)]
769#[serde(rename_all = "camelCase")]
770pub struct RevokeSessionInput {
771 pub session_id: String,
772}
773
774pub async fn revoke_session(
775 State(state): State<AppState>,
776 auth: BearerAuth,
777 Json(input): Json<RevokeSessionInput>,
778) -> Response {
779 let session_id: i32 = match input.session_id.parse() {
780 Ok(id) => id,
781 Err(_) => {
782 return (
783 StatusCode::BAD_REQUEST,
784 Json(json!({"error": "InvalidRequest", "message": "Invalid session ID"})),
785 )
786 .into_response();
787 }
788 };
789 let session = sqlx::query_as::<_, (String,)>(
790 "SELECT access_jti FROM session_tokens WHERE id = $1 AND did = $2",
791 )
792 .bind(session_id)
793 .bind(&auth.0.did)
794 .fetch_optional(&state.db)
795 .await;
796 let access_jti = match session {
797 Ok(Some((jti,))) => jti,
798 Ok(None) => {
799 return (
800 StatusCode::NOT_FOUND,
801 Json(json!({"error": "SessionNotFound", "message": "Session not found"})),
802 )
803 .into_response();
804 }
805 Err(e) => {
806 error!("DB error in revoke_session: {:?}", e);
807 return (
808 StatusCode::INTERNAL_SERVER_ERROR,
809 Json(json!({"error": "InternalError"})),
810 )
811 .into_response();
812 }
813 };
814 if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE id = $1")
815 .bind(session_id)
816 .execute(&state.db)
817 .await
818 {
819 error!("DB error deleting session: {:?}", e);
820 return (
821 StatusCode::INTERNAL_SERVER_ERROR,
822 Json(json!({"error": "InternalError"})),
823 )
824 .into_response();
825 }
826 let cache_key = format!("auth:session:{}:{}", auth.0.did, access_jti);
827 if let Err(e) = state.cache.delete(&cache_key).await {
828 warn!("Failed to invalidate session cache: {:?}", e);
829 }
830 info!(did = %auth.0.did, session_id = %session_id, "Session revoked");
831 (StatusCode::OK, Json(json!({}))).into_response()
832}