Skip to main content

cloudillo_auth/
qr_login.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! QR-code login: scan a QR on the login page with a phone that already
5//! has an active session to approve the desktop login.
6
7use 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
28/// Session expiry: 5 minutes
29const SESSION_EXPIRY_SECS: u64 = 300;
30
31/// Maximum number of concurrent QR login sessions
32const MAX_SESSIONS: usize = 10_000;
33
34// ============================================================================
35// In-memory session store
36// ============================================================================
37
38#[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
56/// Shared store for QR login sessions, registered as an extension on App.
57pub 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	/// Remove expired sessions.
73	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
81/// Hash a secret string with SHA-256 and return the hex digest.
82fn 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// ============================================================================
92// POST /api/auth/qr-login/init
93// ============================================================================
94
95#[derive(Clone, Serialize)]
96pub struct InitResponse {
97	#[serde(rename = "sessionId")]
98	pub session_id: String,
99	pub secret: String,
100}
101
102/// Core QR session creation logic, extracted for reuse by `post_login_init`.
103pub 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	// Enforce maximum session count (cleanup expired first if at capacity)
112	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	// Extract User-Agent from the desktop browser request
120	let user_agent =
121		headers.get(header::USER_AGENT).and_then(|v| v.to_str().ok()).map(String::from);
122
123	// Generate session_id (16 bytes, public — goes into QR code)
124	let session_id_bytes: [u8; 16] = rand::rng().random();
125	let session_id = BASE64_URL.encode(session_id_bytes);
126
127	// Generate secret (32 bytes, private — kept by desktop browser only)
128	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
161// ============================================================================
162// GET /api/auth/qr-login/{session_id}/status
163// ============================================================================
164
165/// Maximum long-poll timeout in seconds
166const MAX_POLL_TIMEOUT_SECS: u64 = 30;
167
168/// Default long-poll timeout in seconds
169const DEFAULT_POLL_TIMEOUT_SECS: u64 = 15;
170
171#[derive(Deserialize)]
172pub struct StatusQuery {
173	/// Long-poll timeout in seconds (default 15, max 30)
174	timeout: Option<u64>,
175}
176
177/// Custom header for QR login secret (avoids leaking secret in query string / logs / Referer)
178const QR_SECRET_HEADER: &str = "x-qr-secret";
179
180#[derive(Serialize)]
181pub struct StatusResponse {
182	status: String,
183	/// Included when status is "approved"
184	#[serde(skip_serializing_if = "Option::is_none")]
185	login: Option<Login>,
186}
187
188type StatusResult = (StatusCode, Json<ApiResponse<StatusResponse>>);
189
190/// Verify that the caller knows the session secret.
191fn 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
199/// Try to resolve the current session status into a response.
200/// Returns `Ok(response)` if terminal or non-pending, `Err(notify)` if pending (for long-poll).
201fn 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	// Check expiry
215	if Instant::now() >= session.expires_at {
216		drop(session);
217		// Use remove_if to avoid TOCTOU race: post_respond could approve between
218		// drop and remove, so only remove if still expired.
219		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			// Safe to remove unconditionally: Approved is a terminal state
231			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			// Safe to remove unconditionally: Denied is a terminal state
242			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	// Extract secret from header
267	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 before revealing any session information
273	verify_secret(store, &session_id, secret)?;
274
275	// Check current status (reuses shared resolution logic)
276	let notify_and_wait = match try_resolve_status(store, &session_id, &req_id_str) {
277		Ok(response) => return Ok(response),
278		Err(notify) => {
279			// Still pending — compute long-poll wait time
280			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	// Long-poll: wait for notification or timeout
291	let (notify, wait) = notify_and_wait;
292	let _ = tokio::time::timeout(wait, notify.notified()).await;
293
294	// Re-check after wait
295	if let Ok(response) = try_resolve_status(store, &session_id, &req_id_str) {
296		Ok(response)
297	} else {
298		// Still pending after timeout
299		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// ============================================================================
307// GET /api/auth/qr-login/{session_id}/details (protected)
308// ============================================================================
309
310#[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	// Read and validate under short lock, clone needed fields, then drop guard
327	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		// Check expiry first (cheapest check)
334		if Instant::now() >= session.expires_at {
335			return Err(Error::NotFound);
336		}
337
338		// Verify tenant match
339		if auth.tn_id != session.tn_id {
340			return Err(Error::PermissionDenied);
341		}
342
343		// Clone needed fields before dropping the guard
344		(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// ============================================================================
354// POST /api/auth/qr-login/{session_id}/respond (protected)
355// ============================================================================
356
357#[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	// Read and validate under short lock — do NOT hold across .await
372	let notify = {
373		let entry = store.sessions.get(&session_id).ok_or(Error::NotFound)?;
374		let session = entry.value();
375
376		// Check expiry first (cheapest check)
377		if Instant::now() >= session.expires_at {
378			return Err(Error::NotFound);
379		}
380
381		// Verify tenant match
382		if auth.tn_id != session.tn_id {
383			return Err(Error::PermissionDenied);
384		}
385
386		// Must be pending
387		if session.status != QrLoginStatus::Pending {
388			return Err(Error::ValidationError("Session already responded".into()));
389		}
390
391		session.notify.clone()
392		// DashMap read guard dropped here
393	};
394
395	// Perform async work without holding any DashMap lock
396	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		// Re-acquire lock to update session — return error if session vanished or already responded
401		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	// Wake any long-polling get_status calls for this session
413	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// vim: ts=4