Skip to main content

tuwunel_service/uiaa/
mod.rs

1use std::{
2	collections::BTreeMap,
3	ops::ControlFlow,
4	sync::{Arc, RwLock},
5};
6
7use futures::{TryStreamExt, pin_mut};
8use ruma::{
9	CanonicalJsonValue, DeviceId, OwnedDeviceId, OwnedUserId, UserId,
10	api::{
11		client::uiaa::{
12			AuthData, AuthType, EmailIdentity, Password, ThirdpartyIdCredentials, UiaaInfo,
13			UserIdentifier,
14		},
15		error::{ErrorKind, StandardErrorBody},
16	},
17};
18use tuwunel_core::{
19	Err, Result, err, error, extract, implement,
20	utils::{self, BoolExt, hash::verify_password, string::EMPTY},
21};
22use tuwunel_database::{Deserialized, Json, Map};
23
24pub struct Service {
25	userdevicesessionid_uiaarequest: RwLock<RequestMap>,
26	db: Data,
27	services: Arc<crate::services::OnceServices>,
28}
29
30struct Data {
31	userdevicesessionid_uiaainfo: Arc<Map>,
32}
33
34type RequestMap = BTreeMap<RequestKey, CanonicalJsonValue>;
35type RequestKey = (OwnedUserId, OwnedDeviceId, String);
36
37pub const SESSION_ID_LENGTH: usize = 32;
38
39#[derive(Clone, Copy)]
40enum EmailIdentityMode {
41	Validate,
42	Claim,
43}
44
45impl crate::Service for Service {
46	fn build(args: &crate::Args<'_>) -> Result<Arc<Self>> {
47		Ok(Arc::new(Self {
48			userdevicesessionid_uiaarequest: RwLock::new(RequestMap::new()),
49			db: Data {
50				userdevicesessionid_uiaainfo: args.db["userdevicesessionid_uiaainfo"].clone(),
51			},
52			services: args.services.clone(),
53		}))
54	}
55
56	fn name(&self) -> &str { crate::service::make_name(std::module_path!()) }
57}
58
59/// Creates a new Uiaa session. Make sure the session token is unique.
60#[implement(Service)]
61pub fn create(
62	&self,
63	user_id: &UserId,
64	device_id: &DeviceId,
65	uiaainfo: &UiaaInfo,
66	json_body: &CanonicalJsonValue,
67) {
68	// TODO: better session error handling (why is uiaainfo.session optional in
69	// ruma?)
70	let session = uiaainfo
71		.session
72		.as_ref()
73		.expect("session should be set");
74
75	self.set_uiaa_request(user_id, device_id, session, json_body);
76
77	self.update_uiaa_session(user_id, device_id, session, Some(uiaainfo));
78}
79
80/// Authenticate one stage without taking ownership of an email proof.
81///
82/// Generic UIAA consumers may validate email identity, but only registration
83/// assigns a durable owner to that proof.
84#[implement(Service)]
85pub async fn try_auth(
86	&self,
87	user_id: &UserId,
88	device_id: &DeviceId,
89	auth: &AuthData,
90	uiaainfo: &UiaaInfo,
91) -> Result<(bool, UiaaInfo)> {
92	self.try_auth_inner(user_id, device_id, auth, uiaainfo, EmailIdentityMode::Validate)
93		.await
94}
95
96/// Authenticate one registration stage and claim an email proof when present.
97///
98/// The claim is tied to the exact user, device, and UIAA session tuple before
99/// the email stage is recorded as complete.
100#[implement(Service)]
101pub async fn try_auth_registration(
102	&self,
103	user_id: &UserId,
104	device_id: &DeviceId,
105	auth: &AuthData,
106	uiaainfo: &UiaaInfo,
107) -> Result<(bool, UiaaInfo)> {
108	self.try_auth_inner(user_id, device_id, auth, uiaainfo, EmailIdentityMode::Claim)
109		.await
110}
111
112#[implement(Service)]
113async fn try_auth_inner(
114	&self,
115	user_id: &UserId,
116	device_id: &DeviceId,
117	auth: &AuthData,
118	uiaainfo: &UiaaInfo,
119	email_identity_mode: EmailIdentityMode,
120) -> Result<(bool, UiaaInfo)> {
121	let mut uiaainfo = if let Some(session) = auth.session() {
122		self.get_uiaa_session(user_id, device_id, session)
123			.await?
124	} else {
125		uiaainfo.clone()
126	};
127
128	if uiaainfo.session.is_none() {
129		uiaainfo.session = Some(utils::random_string(SESSION_ID_LENGTH));
130	}
131
132	match auth {
133		// Find out what the user completed
134		| AuthData::Password(password) => {
135			if let ControlFlow::Break(authed) = self
136				.verify_password(user_id, &mut uiaainfo, password)
137				.await?
138			{
139				return Ok((authed, uiaainfo));
140			}
141		},
142		| AuthData::RegistrationToken(t) => {
143			let token = t.token.trim();
144			if self
145				.services
146				.registration_tokens
147				.try_consume(token)
148				.await
149				.is_ok()
150			{
151				uiaainfo
152					.completed
153					.push(AuthType::RegistrationToken);
154			} else {
155				uiaainfo.auth_error = Some(Box::new(StandardErrorBody {
156					kind: ErrorKind::forbidden(),
157					message: "Invalid registration token.".to_owned(),
158				}));
159
160				return Ok((false, uiaainfo));
161			}
162		},
163		| AuthData::FallbackAcknowledgement(_session) => {
164			// A fallback acknowledgement is a session re-poll. The fallback
165			// web handler (e.g. the SSO callback) is what records completion.
166		},
167		| AuthData::OAuth(_) => {
168			// MSC4312: OAuth cross-signing reset uses SSO re-authentication.
169			// If a bypass was granted via SSO re-auth, mark OAuth as completed.
170			if !uiaainfo.completed.contains(&AuthType::OAuth) {
171				if self
172					.services
173					.users
174					.can_replace_cross_signing_keys(user_id)
175					.await
176				{
177					uiaainfo.completed.push(AuthType::OAuth);
178				} else {
179					uiaainfo.auth_error = Some(Box::new(StandardErrorBody {
180						kind: ErrorKind::forbidden(),
181						message: "OAuth cross-signing reset not approved for this session."
182							.to_owned(),
183					}));
184
185					return Ok((false, uiaainfo));
186				}
187			}
188		},
189		| AuthData::Dummy(_) => {
190			uiaainfo.completed.push(AuthType::Dummy);
191		},
192		| AuthData::Terms(_) => {
193			// MSC1692: an empty auth dict accepts every presented policy.
194			uiaainfo.completed.push(AuthType::Terms);
195		},
196		| AuthData::EmailIdentity(EmailIdentity { thirdparty_id_creds, .. }) => {
197			// A stray id_server is tolerated and id_access_token is never required.
198			let validated = self
199				.authenticate_email_identity(
200					user_id,
201					device_id,
202					&uiaainfo,
203					thirdparty_id_creds,
204					email_identity_mode,
205				)
206				.await?;
207
208			if !validated {
209				uiaainfo.auth_error = Some(Box::new(StandardErrorBody {
210					kind: ErrorKind::forbidden(),
211					message: "Email address has not been validated.".to_owned(),
212				}));
213
214				return Ok((false, uiaainfo));
215			}
216
217			uiaainfo.completed.push(AuthType::EmailIdentity);
218		},
219		| auth => error!("AuthData type not supported: {auth:?}"),
220	}
221
222	// Check if a flow now succeeds
223	let mut completed = false;
224	'flows: for flow in &mut uiaainfo.flows {
225		for stage in &flow.stages {
226			if !uiaainfo.completed.contains(stage) {
227				continue 'flows;
228			}
229		}
230		// We didn't break, so this flow succeeded!
231		completed = true;
232	}
233
234	let session = uiaainfo
235		.session
236		.as_ref()
237		.expect("session is always set");
238
239	if matches!(email_identity_mode, EmailIdentityMode::Claim)
240		&& !matches!(auth, AuthData::EmailIdentity(_))
241		&& uiaainfo
242			.completed
243			.contains(&AuthType::EmailIdentity)
244	{
245		let claim = (user_id.to_owned(), device_id.to_owned(), session.as_str().into());
246
247		if !self
248			.services
249			.threepid
250			.refresh_claim(&claim)
251			.await?
252		{
253			uiaainfo
254				.completed
255				.retain(|stage| stage != &AuthType::EmailIdentity);
256
257			uiaainfo.auth_error = Some(Box::new(StandardErrorBody {
258				kind: ErrorKind::forbidden(),
259				message: "Email address has not been validated.".to_owned(),
260			}));
261
262			self.update_uiaa_session(user_id, device_id, session, Some(&uiaainfo));
263
264			return Ok((false, uiaainfo));
265		}
266	}
267
268	if !completed {
269		self.update_uiaa_session(user_id, device_id, session, Some(&uiaainfo));
270
271		return Ok((false, uiaainfo));
272	}
273
274	// Retain the session until registration spends its email claim.
275	let retain_session = matches!(email_identity_mode, EmailIdentityMode::Claim)
276		&& uiaainfo
277			.completed
278			.contains(&AuthType::EmailIdentity);
279
280	self.update_uiaa_session(user_id, device_id, session, retain_session.then_some(&uiaainfo));
281
282	Ok((true, uiaainfo))
283}
284
285#[implement(Service)]
286async fn authenticate_email_identity(
287	&self,
288	user_id: &UserId,
289	device_id: &DeviceId,
290	uiaainfo: &UiaaInfo,
291	creds: &ThirdpartyIdCredentials,
292	mode: EmailIdentityMode,
293) -> Result<bool> {
294	match mode {
295		| EmailIdentityMode::Validate => Ok(self
296			.services
297			.threepid
298			.session_validated(creds.sid.as_str(), creds.client_secret.as_str())
299			.await),
300		| EmailIdentityMode::Claim => {
301			let session = uiaainfo
302				.session
303				.as_ref()
304				.expect("session is always set");
305
306			let claim = (user_id.to_owned(), device_id.to_owned(), session.as_str().into());
307
308			self.services
309				.threepid
310				.claim_validated(creds.sid.as_str(), creds.client_secret.as_str(), claim)
311				.await
312		},
313	}
314}
315
316#[implement(Service)]
317async fn verify_password(
318	&self,
319	user_id: &UserId,
320	uiaainfo: &mut UiaaInfo,
321	password: &Password,
322) -> Result<ControlFlow<bool>> {
323	let Password { identifier, password, user, .. } = password;
324
325	let username = extract!(identifier, x in Some(UserIdentifier::Matrix(ruma::api::client::uiaa::MatrixUserIdentifier { user: x, .. })))
326		.or_else(|| cfg!(feature = "element_hacks").and(user.as_ref()))
327		.ok_or(err!(Request(Unrecognized("Identifier type not recognized."))))?;
328
329	let user_id_from_username =
330		UserId::parse_with_server_name(username.clone(), self.services.globals.server_name())
331			.map_err(|_| err!(Request(InvalidParam("User ID is invalid."))))?;
332
333	// Check if the access token being used matches the credentials used for UIAA
334	if user_id.localpart() != user_id_from_username.localpart() {
335		return Err!(Request(Forbidden("User ID and access token mismatch.")));
336	}
337
338	let user_id = user_id_from_username;
339	// First try local password hash verification
340	let password_verified = self
341		.services
342		.users
343		.password_hash(&user_id)
344		.await
345		.is_ok_and(|hash| verify_password(password, &hash).is_ok());
346
347	// Only LDAP-origin accounts fall back to LDAP; others would trigger a
348	// directory-wide search.
349	#[cfg(feature = "ldap")]
350	let password_verified = if !password_verified
351		&& self.services.server.config.ldap.enable
352		&& self
353			.services
354			.users
355			.origin(&user_id)
356			.await
357			.is_ok_and(|origin| origin == "ldap")
358		&& let Ok(dns) = self.services.users.search_ldap(&user_id).await
359		&& let Some((user_dn, _is_admin)) = dns.first()
360	{
361		self.services
362			.users
363			.auth_ldap(user_dn, password)
364			.await
365			.is_ok()
366	} else {
367		password_verified
368	};
369
370	if !password_verified {
371		uiaainfo.auth_error = Some(Box::new(StandardErrorBody {
372			kind: ErrorKind::forbidden(),
373			message: "Invalid username or password.".to_owned(),
374		}));
375
376		return Ok(ControlFlow::Break(false));
377	}
378
379	uiaainfo.completed.push(AuthType::Password);
380
381	Ok(ControlFlow::Continue(()))
382}
383
384#[implement(Service)]
385fn set_uiaa_request(
386	&self,
387	user_id: &UserId,
388	device_id: &DeviceId,
389	session: &str,
390	request: &CanonicalJsonValue,
391) {
392	let key = (user_id.to_owned(), device_id.to_owned(), session.to_owned());
393
394	self.userdevicesessionid_uiaarequest
395		.write()
396		.expect("locked for writing")
397		.insert(key, request.to_owned());
398}
399
400#[implement(Service)]
401pub fn get_uiaa_request(
402	&self,
403	user_id: &UserId,
404	device_id: Option<&DeviceId>,
405	session: &str,
406) -> Option<CanonicalJsonValue> {
407	let device_id = device_id.unwrap_or_else(|| EMPTY.into());
408	let key = (user_id.to_owned(), device_id.to_owned(), session.to_owned());
409
410	self.userdevicesessionid_uiaarequest
411		.read()
412		.expect("locked for reading")
413		.get(&key)
414		.cloned()
415}
416
417#[implement(Service)]
418pub fn update_uiaa_session(
419	&self,
420	user_id: &UserId,
421	device_id: &DeviceId,
422	session: &str,
423	uiaainfo: Option<&UiaaInfo>,
424) {
425	let key = (user_id, device_id, session);
426
427	if let Some(uiaainfo) = uiaainfo {
428		self.db
429			.userdevicesessionid_uiaainfo
430			.put(key, Json(uiaainfo));
431	} else {
432		self.db.userdevicesessionid_uiaainfo.del(key);
433	}
434}
435
436#[implement(Service)]
437async fn get_uiaa_session(
438	&self,
439	user_id: &UserId,
440	device_id: &DeviceId,
441	session: &str,
442) -> Result<UiaaInfo> {
443	let key = (user_id, device_id, session);
444
445	self.db
446		.userdevicesessionid_uiaainfo
447		.qry(&key)
448		.await
449		.deserialized()
450		.map_err(|_| err!(Request(Forbidden("UIAA session does not exist."))))
451}
452
453#[implement(Service)]
454pub async fn get_uiaa_session_by_session_id(
455	&self,
456	session_id: &str,
457) -> Option<(OwnedUserId, OwnedDeviceId, UiaaInfo)> {
458	// Iterate over keys only (fastest way without a secondary index)
459	let stream = self
460		.db
461		.userdevicesessionid_uiaainfo
462		.keys::<(OwnedUserId, OwnedDeviceId, String)>();
463
464	pin_mut!(stream);
465	while let Ok(Some((user_id, device_id, session))) = stream.try_next().await {
466		if session == session_id {
467			// Found the key, now fetch the actual UiaaInfo
468			if let Ok(uiaainfo) = self
469				.get_uiaa_session(&user_id, &device_id, session_id)
470				.await
471			{
472				return Some((user_id, device_id, uiaainfo));
473			}
474		}
475	}
476
477	None
478}