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