1use axum::{
5 extract::{ConnectInfo, Path, Query, State},
6 http::{header, HeaderMap, StatusCode},
7 Json,
8};
9use base64::{engine::general_purpose::URL_SAFE_NO_PAD as BASE64_URL, Engine};
10use dashmap::DashMap;
11use rand::RngExt;
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14use std::net::SocketAddr;
15use std::sync::Arc;
16use std::time::{Duration, Instant};
17use tokio::sync::Notify;
18
19use cloudillo_core::{extract::OptionalRequestId, Auth};
20use cloudillo_types::types::ApiResponse;
21
22use crate::handler::{return_login, Login};
23use crate::prelude::*;
24
25const SESSION_EXPIRY_SECS: u64 = 300;
27
28const MAX_SESSIONS: usize = 10_000;
30
31#[derive(Debug, PartialEq, Eq)]
36pub enum QrLoginStatus {
37 Pending,
38 Approved,
39 Denied,
40}
41
42pub struct QrLoginSession {
43 tn_id: TnId,
44 status: QrLoginStatus,
45 secret_hash: String,
46 user_agent: Option<String>,
47 ip_address: Option<String>,
48 login_data: Option<Login>,
49 expires_at: Instant,
50 notify: Arc<Notify>,
51}
52
53pub struct QrLoginStore {
55 sessions: DashMap<String, QrLoginSession>,
56}
57
58impl Default for QrLoginStore {
59 fn default() -> Self {
60 Self { sessions: DashMap::new() }
61 }
62}
63
64impl QrLoginStore {
65 pub fn new() -> Self {
66 Self::default()
67 }
68
69 pub fn cleanup_expired(&self) -> usize {
71 let now = Instant::now();
72 let before = self.sessions.len();
73 self.sessions.retain(|_, session| now < session.expires_at);
74 before - self.sessions.len()
75 }
76}
77
78fn hash_secret(secret: &str) -> String {
80 let hash = Sha256::digest(secret.as_bytes());
81 format!("{:x}", hash)
82}
83
84#[derive(Clone, Serialize)]
89pub struct InitResponse {
90 #[serde(rename = "sessionId")]
91 pub session_id: String,
92 pub secret: String,
93}
94
95pub fn create_session(
97 app: &App,
98 tn_id: TnId,
99 addr: &SocketAddr,
100 headers: &HeaderMap,
101) -> ClResult<InitResponse> {
102 let store = app.ext::<QrLoginStore>()?;
103
104 if store.sessions.len() >= MAX_SESSIONS {
106 store.cleanup_expired();
107 if store.sessions.len() >= MAX_SESSIONS {
108 return Err(Error::ServiceUnavailable("too many QR login sessions".into()));
109 }
110 }
111
112 let user_agent =
114 headers.get(header::USER_AGENT).and_then(|v| v.to_str().ok()).map(String::from);
115
116 let session_id_bytes: [u8; 16] = rand::rng().random();
118 let session_id = BASE64_URL.encode(session_id_bytes);
119
120 let secret_bytes: [u8; 32] = rand::rng().random();
122 let secret = BASE64_URL.encode(secret_bytes);
123
124 let session = QrLoginSession {
125 tn_id,
126 status: QrLoginStatus::Pending,
127 secret_hash: hash_secret(&secret),
128 user_agent,
129 ip_address: Some(addr.ip().to_string()),
130 login_data: None,
131 expires_at: Instant::now() + Duration::from_secs(SESSION_EXPIRY_SECS),
132 notify: Arc::new(Notify::new()),
133 };
134
135 store.sessions.insert(session_id.clone(), session);
136
137 Ok(InitResponse { session_id, secret })
138}
139
140pub async fn post_init(
141 State(app): State<App>,
142 tn_id: TnId,
143 ConnectInfo(addr): ConnectInfo<SocketAddr>,
144 OptionalRequestId(req_id): OptionalRequestId,
145 headers: HeaderMap,
146) -> ClResult<(StatusCode, Json<ApiResponse<InitResponse>>)> {
147 let result = create_session(&app, tn_id, &addr, &headers)?;
148
149 let response = ApiResponse::new(result).with_req_id(req_id.unwrap_or_default());
150
151 Ok((StatusCode::CREATED, Json(response)))
152}
153
154const MAX_POLL_TIMEOUT_SECS: u64 = 30;
160
161const DEFAULT_POLL_TIMEOUT_SECS: u64 = 15;
163
164#[derive(Deserialize)]
165pub struct StatusQuery {
166 timeout: Option<u64>,
168}
169
170const QR_SECRET_HEADER: &str = "x-qr-secret";
172
173#[derive(Serialize)]
174pub struct StatusResponse {
175 status: String,
176 #[serde(skip_serializing_if = "Option::is_none")]
178 login: Option<Login>,
179}
180
181type StatusResult = (StatusCode, Json<ApiResponse<StatusResponse>>);
182
183fn verify_secret(store: &QrLoginStore, session_id: &str, secret: &str) -> ClResult<()> {
185 let entry = store.sessions.get(session_id).ok_or(Error::NotFound)?;
186 if entry.secret_hash != hash_secret(secret) {
187 return Err(Error::NotFound);
188 }
189 Ok(())
190}
191
192fn try_resolve_status(
195 store: &QrLoginStore,
196 session_id: &str,
197 req_id: &str,
198) -> Result<StatusResult, Arc<Notify>> {
199 let entry = store.sessions.get(session_id);
200 let Some(session) = entry else {
201 let response =
202 ApiResponse::new(StatusResponse { status: "expired".to_string(), login: None })
203 .with_req_id(req_id.to_string());
204 return Ok((StatusCode::OK, Json(response)));
205 };
206
207 if Instant::now() >= session.expires_at {
209 drop(session);
210 store.sessions.remove_if(session_id, |_, s| Instant::now() >= s.expires_at);
213 let response =
214 ApiResponse::new(StatusResponse { status: "expired".to_string(), login: None })
215 .with_req_id(req_id.to_string());
216 return Ok((StatusCode::OK, Json(response)));
217 }
218
219 match session.status {
220 QrLoginStatus::Approved => {
221 let login_data = session.login_data.clone();
222 drop(session);
223 store.sessions.remove(session_id);
225 let response = ApiResponse::new(StatusResponse {
226 status: "approved".to_string(),
227 login: login_data,
228 })
229 .with_req_id(req_id.to_string());
230 Ok((StatusCode::OK, Json(response)))
231 }
232 QrLoginStatus::Denied => {
233 drop(session);
234 store.sessions.remove(session_id);
236 let response =
237 ApiResponse::new(StatusResponse { status: "denied".to_string(), login: None })
238 .with_req_id(req_id.to_string());
239 Ok((StatusCode::OK, Json(response)))
240 }
241 QrLoginStatus::Pending => {
242 let notify = session.notify.clone();
243 drop(session);
244 Err(notify)
245 }
246 }
247}
248
249pub async fn get_status(
250 State(app): State<App>,
251 Path(session_id): Path<String>,
252 Query(query): Query<StatusQuery>,
253 OptionalRequestId(req_id): OptionalRequestId,
254 headers: HeaderMap,
255) -> ClResult<(StatusCode, Json<ApiResponse<StatusResponse>>)> {
256 let store = app.ext::<QrLoginStore>()?;
257 let req_id_str = req_id.unwrap_or_default();
258
259 let secret = headers
261 .get(QR_SECRET_HEADER)
262 .and_then(|v| v.to_str().ok())
263 .ok_or(Error::Unauthorized)?;
264
265 verify_secret(store, &session_id, secret)?;
267
268 let notify_and_wait = match try_resolve_status(store, &session_id, &req_id_str) {
270 Ok(response) => return Ok(response),
271 Err(notify) => {
272 let timeout_secs =
274 query.timeout.unwrap_or(DEFAULT_POLL_TIMEOUT_SECS).min(MAX_POLL_TIMEOUT_SECS);
275 let wait = store.sessions.get(&session_id).map_or(Duration::ZERO, |s| {
276 Duration::from_secs(timeout_secs)
277 .min(s.expires_at.saturating_duration_since(Instant::now()))
278 });
279 (notify, wait)
280 }
281 };
282
283 let (notify, wait) = notify_and_wait;
285 let _ = tokio::time::timeout(wait, notify.notified()).await;
286
287 if let Ok(response) = try_resolve_status(store, &session_id, &req_id_str) {
289 Ok(response)
290 } else {
291 let response =
293 ApiResponse::new(StatusResponse { status: "pending".to_string(), login: None })
294 .with_req_id(req_id_str);
295 Ok((StatusCode::OK, Json(response)))
296 }
297}
298
299#[derive(Serialize)]
304pub struct DetailsResponse {
305 #[serde(rename = "userAgent")]
306 user_agent: Option<String>,
307 #[serde(rename = "ipAddress")]
308 ip_address: Option<String>,
309}
310
311pub async fn get_details(
312 State(app): State<App>,
313 Auth(auth): Auth,
314 Path(session_id): Path<String>,
315 OptionalRequestId(req_id): OptionalRequestId,
316) -> ClResult<(StatusCode, Json<ApiResponse<DetailsResponse>>)> {
317 let store = app.ext::<QrLoginStore>()?;
318
319 let (user_agent, ip_address) = {
321 let entry = store.sessions.get(&session_id);
322 let Some(session) = entry else {
323 return Err(Error::NotFound);
324 };
325
326 if Instant::now() >= session.expires_at {
328 return Err(Error::NotFound);
329 }
330
331 if auth.tn_id != session.tn_id {
333 return Err(Error::PermissionDenied);
334 }
335
336 (session.user_agent.clone(), session.ip_address.clone())
338 };
339
340 let response = ApiResponse::new(DetailsResponse { user_agent, ip_address })
341 .with_req_id(req_id.unwrap_or_default());
342
343 Ok((StatusCode::OK, Json(response)))
344}
345
346#[derive(Deserialize)]
351pub struct RespondRequest {
352 approved: bool,
353}
354
355pub async fn post_respond(
356 State(app): State<App>,
357 Auth(auth): Auth,
358 Path(session_id): Path<String>,
359 OptionalRequestId(req_id): OptionalRequestId,
360 Json(body): Json<RespondRequest>,
361) -> ClResult<(StatusCode, Json<ApiResponse<StatusResponse>>)> {
362 let store = app.ext::<QrLoginStore>()?;
363
364 let notify = {
366 let entry = store.sessions.get(&session_id).ok_or(Error::NotFound)?;
367 let session = entry.value();
368
369 if Instant::now() >= session.expires_at {
371 return Err(Error::NotFound);
372 }
373
374 if auth.tn_id != session.tn_id {
376 return Err(Error::PermissionDenied);
377 }
378
379 if session.status != QrLoginStatus::Pending {
381 return Err(Error::ValidationError("Session already responded".into()));
382 }
383
384 session.notify.clone()
385 };
387
388 if body.approved {
390 let auth_login = app.auth_adapter.create_tenant_login(&auth.id_tag).await?;
391 let (_status, Json(login_data)) = return_login(&app, auth_login).await?;
392
393 let mut entry = store.sessions.get_mut(&session_id).ok_or(Error::NotFound)?;
395 if entry.status != QrLoginStatus::Pending {
396 return Err(Error::ValidationError("Session already responded".into()));
397 }
398 entry.login_data = Some(login_data);
399 entry.status = QrLoginStatus::Approved;
400 } else {
401 let mut entry = store.sessions.get_mut(&session_id).ok_or(Error::NotFound)?;
402 entry.status = QrLoginStatus::Denied;
403 }
404
405 notify.notify_waiters();
407
408 let status_str = if body.approved { "approved" } else { "denied" };
409
410 let response = ApiResponse::new(StatusResponse { status: status_str.to_string(), login: None })
411 .with_req_id(req_id.unwrap_or_default());
412
413 Ok((StatusCode::OK, Json(response)))
414}
415
416