Skip to main content

cloudillo_auth/
qr_login.rs

1//! QR-code login: scan a QR on the login page with a phone that already
2//! has an active session to approve the desktop login.
3
4use 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
25/// Session expiry: 5 minutes
26const SESSION_EXPIRY_SECS: u64 = 300;
27
28/// Maximum number of concurrent QR login sessions
29const MAX_SESSIONS: usize = 10_000;
30
31// ============================================================================
32// In-memory session store
33// ============================================================================
34
35#[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
53/// Shared store for QR login sessions, registered as an extension on App.
54pub 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	/// Remove expired sessions.
70	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
78/// Hash a secret string with SHA-256 and return the hex digest.
79fn hash_secret(secret: &str) -> String {
80	let hash = Sha256::digest(secret.as_bytes());
81	format!("{:x}", hash)
82}
83
84// ============================================================================
85// POST /api/auth/qr-login/init
86// ============================================================================
87
88#[derive(Clone, Serialize)]
89pub struct InitResponse {
90	#[serde(rename = "sessionId")]
91	pub session_id: String,
92	pub secret: String,
93}
94
95/// Core QR session creation logic, extracted for reuse by `post_login_init`.
96pub 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	// Enforce maximum session count (cleanup expired first if at capacity)
105	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	// Extract User-Agent from the desktop browser request
113	let user_agent =
114		headers.get(header::USER_AGENT).and_then(|v| v.to_str().ok()).map(String::from);
115
116	// Generate session_id (16 bytes, public — goes into QR code)
117	let session_id_bytes: [u8; 16] = rand::rng().random();
118	let session_id = BASE64_URL.encode(session_id_bytes);
119
120	// Generate secret (32 bytes, private — kept by desktop browser only)
121	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
154// ============================================================================
155// GET /api/auth/qr-login/{session_id}/status
156// ============================================================================
157
158/// Maximum long-poll timeout in seconds
159const MAX_POLL_TIMEOUT_SECS: u64 = 30;
160
161/// Default long-poll timeout in seconds
162const DEFAULT_POLL_TIMEOUT_SECS: u64 = 15;
163
164#[derive(Deserialize)]
165pub struct StatusQuery {
166	/// Long-poll timeout in seconds (default 15, max 30)
167	timeout: Option<u64>,
168}
169
170/// Custom header for QR login secret (avoids leaking secret in query string / logs / Referer)
171const QR_SECRET_HEADER: &str = "x-qr-secret";
172
173#[derive(Serialize)]
174pub struct StatusResponse {
175	status: String,
176	/// Included when status is "approved"
177	#[serde(skip_serializing_if = "Option::is_none")]
178	login: Option<Login>,
179}
180
181type StatusResult = (StatusCode, Json<ApiResponse<StatusResponse>>);
182
183/// Verify that the caller knows the session secret.
184fn 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
192/// Try to resolve the current session status into a response.
193/// Returns `Ok(response)` if terminal or non-pending, `Err(notify)` if pending (for long-poll).
194fn 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	// Check expiry
208	if Instant::now() >= session.expires_at {
209		drop(session);
210		// Use remove_if to avoid TOCTOU race: post_respond could approve between
211		// drop and remove, so only remove if still expired.
212		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			// Safe to remove unconditionally: Approved is a terminal state
224			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			// Safe to remove unconditionally: Denied is a terminal state
235			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	// Extract secret from header
260	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 before revealing any session information
266	verify_secret(store, &session_id, secret)?;
267
268	// Check current status (reuses shared resolution logic)
269	let notify_and_wait = match try_resolve_status(store, &session_id, &req_id_str) {
270		Ok(response) => return Ok(response),
271		Err(notify) => {
272			// Still pending — compute long-poll wait time
273			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	// Long-poll: wait for notification or timeout
284	let (notify, wait) = notify_and_wait;
285	let _ = tokio::time::timeout(wait, notify.notified()).await;
286
287	// Re-check after wait
288	if let Ok(response) = try_resolve_status(store, &session_id, &req_id_str) {
289		Ok(response)
290	} else {
291		// Still pending after timeout
292		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// ============================================================================
300// GET /api/auth/qr-login/{session_id}/details (protected)
301// ============================================================================
302
303#[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	// Read and validate under short lock, clone needed fields, then drop guard
320	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		// Check expiry first (cheapest check)
327		if Instant::now() >= session.expires_at {
328			return Err(Error::NotFound);
329		}
330
331		// Verify tenant match
332		if auth.tn_id != session.tn_id {
333			return Err(Error::PermissionDenied);
334		}
335
336		// Clone needed fields before dropping the guard
337		(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// ============================================================================
347// POST /api/auth/qr-login/{session_id}/respond (protected)
348// ============================================================================
349
350#[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	// Read and validate under short lock — do NOT hold across .await
365	let notify = {
366		let entry = store.sessions.get(&session_id).ok_or(Error::NotFound)?;
367		let session = entry.value();
368
369		// Check expiry first (cheapest check)
370		if Instant::now() >= session.expires_at {
371			return Err(Error::NotFound);
372		}
373
374		// Verify tenant match
375		if auth.tn_id != session.tn_id {
376			return Err(Error::PermissionDenied);
377		}
378
379		// Must be pending
380		if session.status != QrLoginStatus::Pending {
381			return Err(Error::ValidationError("Session already responded".into()));
382		}
383
384		session.notify.clone()
385		// DashMap read guard dropped here
386	};
387
388	// Perform async work without holding any DashMap lock
389	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		// Re-acquire lock to update session — return error if session vanished or already responded
394		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	// Wake any long-polling get_status calls for this session
406	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// vim: ts=4