Skip to main content

tuwunel_service/users/
mod.rs

1mod create;
2mod dehydrated_device;
3pub mod device;
4mod invite_filter;
5mod keys;
6mod ldap;
7mod register;
8mod server_user;
9
10use std::sync::Arc;
11
12use futures::{Stream, StreamExt, TryFutureExt};
13use ruma::{
14	MilliSecondsSinceUnixEpoch, OwnedUserId, UserId,
15	api::client::filter::FilterDefinition,
16	events::{
17		GlobalAccountDataEventType,
18		ignored_user_list::{IgnoredUserListEvent, IgnoredUserListEventContent},
19	},
20};
21use serde::{Deserialize, Serialize};
22use tuwunel_core::{
23	Err, Result, debug_warn, err, is_equal_to,
24	matrix::pdu::PduCount,
25	trace,
26	utils::{
27		self, BoolExt, MutexMap, ReadyExt, hash::password as hash_password, result::NotFound,
28		stream::TryIgnore,
29	},
30	warn,
31};
32use tuwunel_database::{Deserialized, Json, Map};
33
34pub use self::{
35	dehydrated_device::DehydratedDevice,
36	invite_filter::InviteFilter,
37	keys::{DeviceListChange, DeviceListRecord, parse_master_key},
38	register::Register,
39	server_user::SERVER_USER_KEY,
40};
41
42pub const PASSWORD_SENTINEL: &str = "*";
43pub const PASSWORD_DISABLED: &str = "";
44
45/// Forensic record for a moderation action (MSC3823 suspend, MSC3939 lock).
46/// Presence of the row is the load-bearing fact; this body is written but
47/// never read on the hot path.
48#[derive(Clone, Debug, Serialize, Deserialize)]
49pub struct Moderation {
50	pub when: MilliSecondsSinceUnixEpoch,
51	pub by: OwnedUserId,
52}
53
54pub struct Service {
55	services: Arc<crate::services::OnceServices>,
56	db: Data,
57	device_list_mutex: MutexMap<OwnedUserId, ()>,
58}
59
60struct Data {
61	keychangeid_devicechange: Arc<Map>,
62	keychangeid_userid: Arc<Map>,
63	keyid_key: Arc<Map>,
64	onetimekeyid4225_otk: Option<Arc<Map>>,
65	openidtoken_expiresatuserid: Arc<Map>,
66	logintoken_expiresatuserid: Arc<Map>,
67	todeviceid_events: Arc<Map>,
68	spentrefresh_userdeviceid: Arc<Map>,
69	token_userdeviceid: Arc<Map>,
70	userdeviceid_metadata: Arc<Map>,
71	userdeviceid_token: Arc<Map>,
72	userdeviceidtoken_index: Arc<Map>,
73	userdeviceid_refresh: Arc<Map>,
74	userdeviceid_spentrefresh: Arc<Map>,
75	userdeviceidalgorithm_fallback: Arc<Map>,
76	oidcdevice_userdeviceid: Arc<Map>,
77	oidccskeybypass_userid: Arc<Map>,
78	userfilterid_filter: Arc<Map>,
79	userid_dehydrateddevice: Arc<Map>,
80	userid_devicelistversion: Arc<Map>,
81	userid_erased: Arc<Map>,
82	userid_lastonetimekeyupdate: Arc<Map>,
83	userid_locked: Arc<Map>,
84	userid_masterkeyid: Arc<Map>,
85	userid_password: Arc<Map>,
86	userid_origin: Arc<Map>,
87	userid_selfsigningkeyid: Arc<Map>,
88	userid_suspended: Arc<Map>,
89	userid_usersigningkeyid: Arc<Map>,
90}
91
92impl crate::Service for Service {
93	fn build(args: &crate::Args<'_>) -> Result<Arc<Self>> {
94		Ok(Arc::new(Self {
95			services: args.services.clone(),
96			device_list_mutex: MutexMap::new(),
97			db: Data {
98				keychangeid_devicechange: args.db["keychangeid_devicechange"].clone(),
99				keychangeid_userid: args.db["keychangeid_userid"].clone(),
100				keyid_key: args.db["keyid_key"].clone(),
101				onetimekeyid4225_otk: args.db.get("onetimekeyid4225_otk").ok().cloned(),
102				openidtoken_expiresatuserid: args.db["openidtoken_expiresatuserid"].clone(),
103				logintoken_expiresatuserid: args.db["logintoken_expiresatuserid"].clone(),
104				oidcdevice_userdeviceid: args.db["oidcdevice_userdeviceid"].clone(),
105				oidccskeybypass_userid: args.db["oidccskeybypass_userid"].clone(),
106				todeviceid_events: args.db["todeviceid_events"].clone(),
107				spentrefresh_userdeviceid: args.db["spentrefresh_userdeviceid"].clone(),
108				token_userdeviceid: args.db["token_userdeviceid"].clone(),
109				userdeviceid_metadata: args.db["userdeviceid_metadata"].clone(),
110				userdeviceid_token: args.db["userdeviceid_token"].clone(),
111				userdeviceidtoken_index: args.db["userdeviceidtoken_index"].clone(),
112				userdeviceid_refresh: args.db["userdeviceid_refresh"].clone(),
113				userdeviceid_spentrefresh: args.db["userdeviceid_spentrefresh"].clone(),
114				userdeviceidalgorithm_fallback: args.db["userdeviceidalgorithm_fallback"].clone(),
115				userfilterid_filter: args.db["userfilterid_filter"].clone(),
116				userid_dehydrateddevice: args.db["userid_dehydrateddevice"].clone(),
117				userid_devicelistversion: args.db["userid_devicelistversion"].clone(),
118				userid_erased: args.db["userid_erased"].clone(),
119				userid_lastonetimekeyupdate: args.db["userid_lastonetimekeyupdate"].clone(),
120				userid_locked: args.db["userid_locked"].clone(),
121				userid_masterkeyid: args.db["userid_masterkeyid"].clone(),
122				userid_password: args.db["userid_password"].clone(),
123				userid_origin: args.db["userid_origin"].clone(),
124				userid_selfsigningkeyid: args.db["userid_selfsigningkeyid"].clone(),
125				userid_suspended: args.db["userid_suspended"].clone(),
126				userid_usersigningkeyid: args.db["userid_usersigningkeyid"].clone(),
127			},
128		}))
129	}
130
131	fn name(&self) -> &str { crate::service::make_name(std::module_path!()) }
132}
133
134impl Service {
135	/// Returns true/false based on whether the recipient/receiving user has
136	/// blocked the sender
137	pub async fn user_is_ignored(&self, sender_user: &UserId, recipient_user: &UserId) -> bool {
138		self.ignored_users(recipient_user)
139			.await
140			.is_some_and(|ignored| {
141				ignored
142					.ignored_users
143					.keys()
144					.any(|blocked_user| blocked_user == sender_user)
145			})
146	}
147
148	/// The users a user ignores account-wide.
149	///
150	/// Callers testing one list against many users take this rather than
151	/// [`Self::user_is_ignored`], which re-reads the list per test.
152	pub async fn ignored_users(&self, user_id: &UserId) -> Option<IgnoredUserListEventContent> {
153		self.services
154			.account_data
155			.get_global(user_id, GlobalAccountDataEventType::IgnoredUserList)
156			.await
157			.map(|ignored: IgnoredUserListEvent| ignored.content)
158			.ok()
159	}
160
161	/// Create a new user account on this homeserver.
162	///
163	/// User origin is by default "password" (meaning that it will login using
164	/// its user_id/password). Users with other origins (currently only "ldap"
165	/// is available) have special login processes.
166	#[inline]
167	pub async fn create(
168		&self,
169		user_id: &UserId,
170		password: Option<&str>,
171		origin: Option<&str>,
172	) -> Result {
173		self.check_creation(user_id).await?;
174
175		let origin = origin.unwrap_or("password");
176
177		if password.is_none() {
178			self.create_disabled(user_id, origin);
179			return Ok(());
180		}
181
182		self.db.userid_origin.insert(user_id, origin);
183		self.set_password(user_id, password).await
184	}
185
186	/// Refuses a local user ID that once joined the admin room but has no account.
187	///
188	/// Refusals return `M_USER_IN_USE` and log the user ID without changing account
189	/// or membership state. Existing accounts and the server user remain eligible.
190	pub async fn check_creation(&self, user_id: &UserId) -> Result {
191		if self.services.globals.user_is_local(user_id)
192			&& user_id != self.services.globals.server_user
193			&& let Some(admin_room) = self
194				.services
195				.alias
196				.resolve_local_alias(&self.services.admin.admin_alias)
197				.await
198				.optional()?
199			&& self
200				.services
201				.state_cache
202				.once_joined(user_id, &admin_room)
203				.await
204			&& !self.exists(user_id).await
205		{
206			warn!(%user_id, "Refusing a name previously joined to the admin room");
207			return Err!(Request(UserInUse("User ID is not available.")));
208		}
209
210		Ok(())
211	}
212
213	/// Deactivate account
214	pub async fn deactivate_account(&self, user_id: &UserId) -> Result {
215		// Held until the password is cleared; see `lock_admin_room`.
216		let admin_lock = self.services.admin.lock_admin_room().await;
217
218		// Nobody would be left to reactivate this account or any other. Every
219		// deactivation path reaches here first, while the admins room refuses the
220		// last admin's departure only after the account is already deactivated.
221		if self
222			.services
223			.admin
224			.user_is_last_admin(user_id)
225			.await
226		{
227			return Err!(Request(Forbidden(
228				"Cannot deactivate the last admin of this server. Make another user an admin \
229				 first."
230			)));
231		}
232
233		// Remove all associated devices
234		self.all_device_ids(user_id)
235			.for_each(|device_id| self.remove_device(user_id, device_id))
236			.await;
237
238		// Set the password to "" to indicate a deactivated account. Hashes will never
239		// result in an empty string, so the user will not be able to log in again.
240		// Systems like changing the password without logging in should check if the
241		// account is deactivated.
242		self.set_password(user_id, None).await?;
243		drop(admin_lock);
244
245		// Revoke any SSO authorizations, outside the lock since each is a request
246		// to the identity provider.
247		self.services
248			.oauth
249			.revoke_user_tokens(user_id)
250			.await;
251
252		// TODO: Unhook 3PID
253		Ok(())
254	}
255
256	/// Check if a user has an account on this homeserver.
257	#[inline]
258	pub async fn exists(&self, user_id: &UserId) -> bool {
259		self.db.userid_password.get(user_id).await.is_ok()
260	}
261
262	/// Check if account is deactivated
263	pub async fn is_deactivated(&self, user_id: &UserId) -> Result<bool> {
264		self.db
265			.userid_password
266			.get(user_id)
267			.map_ok(|val| val.is_empty())
268			.map_err(|_| err!(Request(NotFound("User does not exist."))))
269			.await
270	}
271
272	/// Check if account is active, infallible
273	pub async fn is_active(&self, user_id: &UserId) -> bool {
274		!self.is_deactivated(user_id).await.unwrap_or(true)
275	}
276
277	/// Check if account is active, infallible
278	pub async fn is_active_local(&self, user_id: &UserId) -> bool {
279		self.services.globals.user_is_local(user_id) && self.is_active(user_id).await
280	}
281
282	/// Gate an LDAP-authenticated login into an existing local account.
283	///
284	/// Only a deactivated account is rejected. A password-origin account
285	/// passes, since a successful bind establishes the caller's identity
286	/// however their account was created, and a localpart with no local
287	/// account passes on its way to registration.
288	pub async fn check_ldap_login(&self, user_id: &UserId) -> Result {
289		self.deactivated_check(user_id).await
290	}
291
292	/// Reject a deactivated account with 403 `M_USER_DEACTIVATED`.
293	///
294	/// An account that does not exist is not deactivated, as in Synapse. One
295	/// created without a password, as an appservice's users often are, reads
296	/// as deactivated, since both store an empty password.
297	pub async fn deactivated_check(&self, user_id: &UserId) -> Result {
298		self.db
299			.userid_password
300			.get(user_id)
301			.map_ok(|password| password.is_empty())
302			.await
303			.optional()?
304			.unwrap_or_default()
305			.is_false()
306			.into_option()
307			.ok_or_else(|| err!(Request(UserDeactivated("This user has been deactivated."))))
308	}
309
310	/// MSC3823: account is suspended (read-mostly mode, sessions retained).
311	pub async fn is_suspended(&self, user_id: &UserId) -> bool {
312		self.db
313			.userid_suspended
314			.get(user_id)
315			.await
316			.is_ok()
317	}
318
319	/// MSC3939: reject a request from a locked account.
320	///
321	/// The rejection maps to 401 `M_USER_LOCKED` with `soft_logout: true`, so
322	/// the client retains its session and polls for the unlock. The login and
323	/// refresh routes and the request middleware share this gate.
324	pub async fn locked_check(&self, user_id: &UserId) -> Result {
325		self.is_locked(user_id)
326			.await
327			.is_false()
328			.ok_or_else(|| err!(Request(UserLocked("This account has been locked."))))
329	}
330
331	/// MSC3939: account is locked (401 + soft_logout, sessions retained).
332	///
333	/// The row's presence is the entire state, and locking never invalidates
334	/// access tokens, so clearing the row restores every session intact.
335	/// Both `locked_check` and the admin lock surfaces read it.
336	pub async fn is_locked(&self, user_id: &UserId) -> bool {
337		self.db.userid_locked.get(user_id).await.is_ok()
338	}
339
340	/// MSC4025: the user's events serve as pruned copies to recipients not
341	/// joined at the event. Presence-only for the serving gate.
342	pub async fn is_erased(&self, user_id: &UserId) -> bool {
343		self.db.userid_erased.get(user_id).await.is_ok()
344	}
345
346	/// MSC4025: the global count recorded at erasure, for admin surfacing;
347	/// the serving gate never reads it.
348	pub async fn erasure_count(&self, user_id: &UserId) -> Option<PduCount> {
349		self.db
350			.userid_erased
351			.get(user_id)
352			.await
353			.deserialized()
354			.map(PduCount::from_unsigned)
355			.ok()
356	}
357
358	/// MSC3823: forensic record for the active suspension, if any.
359	pub async fn get_suspension(&self, user_id: &UserId) -> Option<Moderation> {
360		self.db
361			.userid_suspended
362			.get(user_id)
363			.await
364			.deserialized::<Json<_>>()
365			.map(|Json(m)| m)
366			.ok()
367	}
368
369	/// MSC3939: forensic record for the active lock, if any.
370	pub async fn get_lock(&self, user_id: &UserId) -> Option<Moderation> {
371		self.db
372			.userid_locked
373			.get(user_id)
374			.await
375			.deserialized::<Json<_>>()
376			.map(|Json(m)| m)
377			.ok()
378	}
379
380	pub fn set_suspended(&self, user_id: &UserId, by: &UserId) {
381		let entry = Moderation {
382			when: MilliSecondsSinceUnixEpoch::now(),
383			by: by.to_owned(),
384		};
385
386		self.db
387			.userid_suspended
388			.raw_put(user_id, Json(entry));
389	}
390
391	pub fn clear_suspended(&self, user_id: &UserId) { self.db.userid_suspended.remove(user_id); }
392
393	/// MSC4025: mark the user erased, recording the current global count.
394	pub fn set_erased(&self, user_id: &UserId) {
395		let count = self.services.globals.current_count();
396
397		self.db.userid_erased.raw_put(user_id, count);
398	}
399
400	/// MSC4025: erasure is reversible; clearing the marker restores the
401	/// unredacted view.
402	pub fn clear_erased(&self, user_id: &UserId) { self.db.userid_erased.remove(user_id); }
403
404	pub fn set_locked(&self, user_id: &UserId, by: &UserId) {
405		let entry = Moderation {
406			when: MilliSecondsSinceUnixEpoch::now(),
407			by: by.to_owned(),
408		};
409
410		self.db
411			.userid_locked
412			.raw_put(user_id, Json(entry));
413	}
414
415	pub fn clear_locked(&self, user_id: &UserId) { self.db.userid_locked.remove(user_id); }
416
417	/// Returns the number of users registered on this server.
418	#[inline]
419	pub async fn count(&self) -> usize { self.db.userid_password.count().await }
420
421	/// Returns whether the user table contains no accounts.
422	///
423	/// The lookup stops after the first account and propagates storage errors.
424	#[tracing::instrument(level = "trace", skip_all)]
425	pub async fn is_empty(&self) -> Result<bool> {
426		self.db
427			.userid_password
428			.keys()
429			.take(1)
430			.ready_fold(Ok(true), |_, user: Result<&UserId>| user.map(|_| false))
431			.await
432	}
433
434	/// Returns an iterator over all users on this homeserver.
435	pub fn stream(&self) -> impl Stream<Item = &UserId> + Send {
436		self.db.userid_password.keys().ignore_err()
437	}
438
439	/// Returns a list of local users as list of usernames.
440	///
441	/// A user account is considered `local` if the length of it's password is
442	/// greater then zero.
443	pub fn list_local_users(&self) -> impl Stream<Item = &UserId> + Send + '_ {
444		self.db
445			.userid_password
446			.stream()
447			.ignore_err()
448			.ready_filter_map(|(u, p): (&UserId, &[u8])| (!p.is_empty()).then_some(u))
449	}
450
451	/// Returns the origin of the user (password/LDAP/...).
452	pub async fn origin(&self, user_id: &UserId) -> Result<String> {
453		self.db
454			.userid_origin
455			.get(user_id)
456			.await
457			.deserialized()
458	}
459
460	/// Returns whether the user has a password. Disabled accounts and
461	/// registrations setting a sentinel password will return false here.
462	pub async fn has_password(&self, user_id: &UserId) -> Result<bool> {
463		self.password_hash(user_id)
464			.map_ok(|value| is_password_hash(&value))
465			.await
466	}
467
468	/// Returns the password hash for the given user.
469	pub async fn password_hash(&self, user_id: &UserId) -> Result<String> {
470		self.db
471			.userid_password
472			.get(user_id)
473			.await
474			.deserialized()
475	}
476
477	/// Hashes and stores the user's password.
478	///
479	/// A real password is hashed at the configured Argon2id cost and marks the
480	/// account as password-origin. `None` stores the disabled marker and the
481	/// sentinel is stored verbatim; neither is hashed. Changing the password of
482	/// an account whose origin is neither password nor SSO is rejected.
483	pub async fn set_password(&self, user_id: &UserId, password: Option<&str>) -> Result {
484		// Cannot change the password of a LDAP user. There are two special cases :
485		// - a `None` password can be used to deactivate a LDAP user
486		// - a "*" password is used as the default password of an active LDAP user
487		//
488		// The above now applies to all non-password origin users by default unless an
489		// exception is made for that origin in the condition below. Note that users
490		// with no origin are also password-origin users.
491		let allowed_origins = ["password", "sso"];
492		if password.is_some() && password != Some(PASSWORD_SENTINEL) {
493			let origin = self.origin(user_id).await;
494			let origin = origin.as_deref().unwrap_or("password");
495
496			if !allowed_origins.iter().any(is_equal_to!(&origin)) {
497				return Err!(Request(InvalidParam(
498					"Cannot change password of an {origin:?} user."
499				)));
500			}
501		}
502
503		match password {
504			| None => {
505				self.db
506					.userid_password
507					.insert(user_id, PASSWORD_DISABLED);
508			},
509			| Some(PASSWORD_SENTINEL) => {
510				self.db
511					.userid_password
512					.insert(user_id, PASSWORD_SENTINEL);
513			},
514			| Some(password) => {
515				let cost = self.services.config.password_hash_cost();
516				let hash = hash_password(password, cost).map_err(|e| {
517					err!(Request(InvalidParam("Password does not meet the requirements: {e}")))
518				})?;
519
520				self.db.userid_password.insert(user_id, hash);
521				self.db.userid_origin.insert(user_id, "password");
522			},
523		}
524
525		Ok(())
526	}
527
528	/// Creates a new sync filter. Returns the filter id.
529	#[must_use]
530	pub fn create_filter(&self, user_id: &UserId, filter: &FilterDefinition) -> String {
531		let filter_id = utils::random_string(4);
532
533		let key = (user_id, &filter_id);
534		self.db.userfilterid_filter.put(key, Json(filter));
535
536		filter_id
537	}
538
539	pub async fn get_filter(
540		&self,
541		user_id: &UserId,
542		filter_id: &str,
543	) -> Result<FilterDefinition> {
544		let key = (user_id, filter_id);
545		self.db
546			.userfilterid_filter
547			.qry(&key)
548			.await
549			.deserialized()
550	}
551
552	/// Creates an OpenID token, which can be used to prove that a user has
553	/// access to an account (primarily for integrations)
554	pub fn create_openid_token(&self, user_id: &UserId, token: &str) -> Result<u64> {
555		use std::num::Saturating as Sat;
556
557		let expires_in = self.services.server.config.openid_token_ttl;
558		let expires_at = Sat(utils::millis_since_unix_epoch()) + Sat(expires_in) * Sat(1000);
559
560		let mut value = expires_at.0.to_be_bytes().to_vec();
561		value.extend_from_slice(user_id.as_bytes());
562
563		self.db
564			.openidtoken_expiresatuserid
565			.insert(token.as_bytes(), value.as_slice());
566
567		Ok(expires_in)
568	}
569
570	/// Find out which user an OpenID access token belongs to.
571	pub async fn find_from_openid_token(&self, token: &str) -> Result<OwnedUserId> {
572		let Ok(value) = self
573			.db
574			.openidtoken_expiresatuserid
575			.get(token)
576			.await
577		else {
578			return Err!(Request(Unauthorized("OpenID token is unrecognised")));
579		};
580
581		let (expires_at_bytes, user_bytes) = value.split_at(0_u64.to_be_bytes().len());
582		let expires_at =
583			u64::from_be_bytes(expires_at_bytes.try_into().map_err(|e| {
584				err!(Database("expires_at in openid_userid is invalid u64. {e}"))
585			})?);
586
587		if expires_at < utils::millis_since_unix_epoch() {
588			debug_warn!("OpenID token is expired, removing");
589			self.db
590				.openidtoken_expiresatuserid
591				.remove(token.as_bytes());
592
593			return Err!(Request(Unauthorized("OpenID token is expired")));
594		}
595
596		let user_string = utils::string_from_bytes(user_bytes)
597			.map_err(|e| err!(Database("User ID in openid_userid is invalid unicode. {e}")))?;
598
599		OwnedUserId::try_from(user_string)
600			.map_err(|e| err!(Database("User ID in openid_userid is invalid. {e}")))
601	}
602
603	/// Creates a short-lived login token, which can be used to log in using the
604	/// `m.login.token` mechanism.
605	#[must_use]
606	pub fn create_login_token(&self, user_id: &UserId, token: &str) -> u64 {
607		use std::num::Saturating as Sat;
608
609		let expires_in = self.services.server.config.login_token_ttl;
610		let expires_at = Sat(utils::millis_since_unix_epoch()) + Sat(expires_in);
611
612		let value = (expires_at.0, user_id);
613		self.db
614			.logintoken_expiresatuserid
615			.raw_put(token, value);
616
617		expires_in
618	}
619
620	/// Verify a login token is valid and return its owner without consuming it.
621	/// Unlike `find_from_login_token`, the token remains in the database
622	/// after this call and can still be consumed later.
623	pub async fn peek_login_token(&self, token: &str) -> Result<OwnedUserId> {
624		let Ok(value) = self
625			.db
626			.logintoken_expiresatuserid
627			.get(token)
628			.await
629		else {
630			return Err!(Request(Forbidden("Login token is unrecognised")));
631		};
632		let (expires_at, user_id): (u64, OwnedUserId) = value.deserialized()?;
633
634		if expires_at < utils::millis_since_unix_epoch() {
635			trace!(?user_id, ?token, "Removing expired login token");
636			self.db.logintoken_expiresatuserid.remove(token);
637			return Err!(Request(Forbidden("Login token is expired")));
638		}
639
640		Ok(user_id)
641	}
642
643	/// Find out which user a login token belongs to.
644	/// Removes the token to prevent double-use attacks.
645	pub async fn find_from_login_token(&self, token: &str) -> Result<OwnedUserId> {
646		let Ok(value) = self
647			.db
648			.logintoken_expiresatuserid
649			.get(token)
650			.await
651		else {
652			return Err!(Request(Forbidden("Login token is unrecognised")));
653		};
654		let (expires_at, user_id): (u64, OwnedUserId) = value.deserialized()?;
655
656		if expires_at < utils::millis_since_unix_epoch() {
657			trace!(?user_id, ?token, "Removing expired login token");
658
659			self.db.logintoken_expiresatuserid.remove(token);
660
661			return Err!(Request(Forbidden("Login token is expired")));
662		}
663
664		self.db.logintoken_expiresatuserid.remove(token);
665
666		Ok(user_id)
667	}
668
669	#[cfg(not(feature = "ldap"))]
670	#[expect(clippy::unused_async)]
671	pub async fn search_ldap(&self, _user_id: &UserId) -> Result<Vec<(String, bool)>> {
672		Err!(FeatureDisabled("ldap"))
673	}
674
675	#[cfg(not(feature = "ldap"))]
676	#[expect(clippy::unused_async)]
677	pub async fn auth_ldap(&self, _user_dn: &str, _password: &str) -> Result {
678		Err!(FeatureDisabled("ldap"))
679	}
680
681	#[cfg(not(feature = "ldap"))]
682	#[must_use]
683	pub fn ldap_bind_dn(&self, _localpart: &str) -> Option<String> { None }
684}
685
686/// Whether a stored password value is a real hash rather than a marker.
687///
688/// The disabled marker and the sentinel hold no password to check against.
689#[must_use]
690pub fn is_password_hash(value: &str) -> bool {
691	value != PASSWORD_DISABLED && value != PASSWORD_SENTINEL
692}