1use std::sync::Arc;
25
26use base64::Engine;
27use base64::engine::general_purpose::URL_SAFE_NO_PAD;
28use lettre::message::Mailbox;
29use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
30use serde_json::Value;
31use sha2::{Digest, Sha256};
32use surrealdb::types::SurrealValue;
33use webauthn_rs::prelude::*;
34
35use crate::database::Db;
36use crate::{Error, Result};
37
38const MAX_ATTEMPTS: i64 = 5;
39const MAX_SENDS: i64 = 5;
40
41#[derive(Debug, Clone, SurrealValue)]
43pub struct Session {
44 pub user_id: i64,
45 pub customer: bool,
47}
48
49#[derive(Debug, Clone)]
52pub struct Challenge {
53 pub id: String,
54 pub options: Value,
55}
56
57#[derive(Clone)]
59pub struct Auth {
60 db: Db,
61 app_name: String,
62 mailer: Arc<Mailer>,
63 webauthn: Arc<Webauthn>,
64}
65
66impl std::fmt::Debug for Auth {
67 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68 f.debug_struct("Auth")
69 .field("app_name", &self.app_name)
70 .finish()
71 }
72}
73
74enum Mailer {
75 Smtp {
76 transport: AsyncSmtpTransport<Tokio1Executor>,
77 from: Mailbox,
78 },
79 Log,
80}
81
82impl Auth {
83 pub fn from_env(db: Db, app_name: &str) -> Result<Self> {
86 let var = |key: &str| std::env::var(key).ok().filter(|v| !v.trim().is_empty());
87 let setup = |err: String| Error::AuthSetup(err);
88 let mailer = match var("SMTP_URL") {
89 Some(url) => Mailer::Smtp {
90 transport: AsyncSmtpTransport::<Tokio1Executor>::from_url(&url)
91 .map_err(|err| setup(format!("SMTP_URL: {err}")))?
92 .build(),
93 from: var("MAIL_FROM")
94 .ok_or_else(|| setup("MAIL_FROM is not set".into()))?
95 .parse()
96 .map_err(|err| setup(format!("MAIL_FROM: {err}")))?,
97 },
98 None => Mailer::Log,
99 };
100 let rp_id = var("PASSKEY_RP_ID").unwrap_or_else(|| "localhost".into());
101 let origin = var("PASSKEY_ORIGIN").unwrap_or_else(|| "http://localhost:8000".into());
102 let origin = Url::parse(&origin).map_err(|err| setup(format!("PASSKEY_ORIGIN: {err}")))?;
103 let webauthn = WebauthnBuilder::new(&rp_id, &origin)
104 .and_then(|builder| {
105 builder
106 .rp_name(app_name)
107 .allow_subdomains(true)
108 .allow_any_port(true)
109 .build()
110 })
111 .map_err(|err| setup(format!("PASSKEY_RP_ID / PASSKEY_ORIGIN: {err}")))?;
112 Ok(Self {
113 db,
114 app_name: app_name.to_owned(),
115 mailer: Arc::new(mailer),
116 webauthn: Arc::new(webauthn),
117 })
118 }
119
120 pub async fn send_code(&self, email: &str) -> Result<()> {
123 #[derive(SurrealValue)]
124 struct Sent {
125 sent: i64,
126 in_window: bool,
127 }
128 let sent: Option<Sent> = self
129 .db
130 .query(
131 "SELECT sent, window_end > time::now() AS in_window
132 FROM ONLY type::record('login_code', $email)",
133 )
134 .bind(("email", email.to_owned()))
135 .await?
136 .take(0)?;
137 if sent.is_some_and(|s| s.in_window && s.sent >= MAX_SENDS) {
138 return Err(Error::Auth(
139 "Too many codes were sent to this address; try again in 15 minutes.".into(),
140 ));
141 }
142 let code = format!("{:06}", rand::random_range(0..1_000_000));
143 self.db
144 .query(
145 "UPSERT type::record('login_code', $email) SET
146 code_hash = $hash, attempts = 0, expires_at = time::now() + 10m,
147 sent = IF window_end > time::now() { sent + 1 } ELSE { 1 },
148 window_end = IF window_end > time::now() { window_end } ELSE { time::now() + 15m }",
149 )
150 .bind(("email", email.to_owned()))
151 .bind(("hash", digest(&format!("{email}:{code}"))))
152 .await?
153 .check()?;
154 self.mail(email, &code).await
155 }
156
157 pub async fn verify_code(&self, email: &str, code: &str) -> Result<bool> {
159 #[derive(SurrealValue)]
160 struct Pending {
161 code_hash: String,
162 attempts: i64,
163 live: bool,
164 }
165 let pending: Option<Pending> = self
166 .db
167 .query(
168 "SELECT code_hash, attempts, expires_at > time::now() AS live
169 FROM ONLY type::record('login_code', $email)",
170 )
171 .bind(("email", email.to_owned()))
172 .await?
173 .take(0)?;
174 let Some(pending) = pending.filter(|p| p.live && p.attempts < MAX_ATTEMPTS) else {
175 return Ok(false);
176 };
177 let correct = pending.code_hash == digest(&format!("{email}:{}", code.trim()));
178 let update = if correct {
179 "DELETE type::record('login_code', $email)"
180 } else {
181 "UPDATE type::record('login_code', $email) SET attempts += 1"
182 };
183 self.db
184 .query(update)
185 .bind(("email", email.to_owned()))
186 .await?
187 .check()?;
188 Ok(correct)
189 }
190
191 pub async fn create_session(&self, user_id: i64, customer: bool) -> Result<String> {
193 let token = random_token();
194 self.db
195 .query(
196 "CREATE type::record('session', $hash)
197 SET user_id = $user_id, customer = $customer, expires_at = time::now() + 30d",
198 )
199 .bind(("hash", digest(&token)))
200 .bind(("user_id", user_id))
201 .bind(("customer", customer))
202 .await?
203 .check()?;
204 Ok(token)
205 }
206
207 pub async fn session(&self, token: &str) -> Result<Option<Session>> {
210 let session: Option<Session> = self
211 .db
212 .query(
213 "UPDATE type::record('session', $hash)
214 SET expires_at = IF expires_at < time::now() + 15d { time::now() + 30d } ELSE { expires_at }
215 WHERE expires_at > time::now()
216 RETURN user_id, customer",
217 )
218 .bind(("hash", digest(token)))
219 .await?
220 .take(0)?;
221 Ok(session)
222 }
223
224 pub async fn end_session(&self, token: &str) -> Result<()> {
226 self.db
227 .query("DELETE type::record('session', $hash)")
228 .bind(("hash", digest(token)))
229 .await?
230 .check()?;
231 Ok(())
232 }
233
234 pub async fn passkey_register_start(&self, user_id: i64, email: &str) -> Result<Challenge> {
236 let existing: Vec<CredentialID> = self
237 .passkeys(user_id)
238 .await?
239 .iter()
240 .map(|passkey| passkey.cred_id().clone())
241 .collect();
242 let (options, state) = self
243 .webauthn
244 .start_passkey_registration(user_uuid(user_id), email, email, Some(existing))
245 .map_err(passkey_error)?;
246 let mut challenge = self.challenge(Some(user_id), &state, &options).await?;
247 challenge.options["authenticatorSelection"]["residentKey"] = "required".into();
249 challenge.options["authenticatorSelection"]["requireResidentKey"] = true.into();
250 Ok(challenge)
251 }
252
253 pub async fn passkey_register_finish(
256 &self,
257 user_id: i64,
258 id: &str,
259 credential: Value,
260 ) -> Result<()> {
261 let (owner, state) = self.take_challenge(id).await?;
262 if owner != Some(user_id) {
263 return Err(expired());
264 }
265 let credential = serde_json::from_value(credential).map_err(passkey_error)?;
266 let passkey = self
267 .webauthn
268 .finish_passkey_registration(&credential, &serde_json::from_str(&state)?)
269 .map_err(passkey_error)?;
270 self.db
271 .query("CREATE passkey SET user_id = $user_id, cred_id = $cred_id, data = $data")
272 .bind(("user_id", user_id))
273 .bind(("cred_id", URL_SAFE_NO_PAD.encode(passkey.cred_id())))
274 .bind(("data", serde_json::to_string(&passkey)?))
275 .await?
276 .check()?;
277 Ok(())
278 }
279
280 pub async fn passkey_login_start(&self) -> Result<Challenge> {
282 let (options, state) = self
283 .webauthn
284 .start_discoverable_authentication()
285 .map_err(passkey_error)?;
286 self.challenge(None, &state, &options).await
287 }
288
289 pub async fn passkey_login_finish(&self, id: &str, credential: Value) -> Result<i64> {
291 let (_, state) = self.take_challenge(id).await?;
292 let credential: PublicKeyCredential =
293 serde_json::from_value(credential).map_err(passkey_error)?;
294 let (uuid, _) = self
295 .webauthn
296 .identify_discoverable_authentication(&credential)
297 .map_err(passkey_error)?;
298 let user_id = uuid.as_u64_pair().1 as i64;
299 let mut passkeys = self.passkeys(user_id).await?;
300 let keys: Vec<DiscoverableKey> = passkeys.iter().map(DiscoverableKey::from).collect();
301 let result = self
302 .webauthn
303 .finish_discoverable_authentication(&credential, serde_json::from_str(&state)?, &keys)
304 .map_err(passkey_error)?;
305 for passkey in &mut passkeys {
306 if passkey.update_credential(&result) == Some(true) {
307 self.db
308 .query("UPDATE passkey SET data = $data WHERE cred_id = $cred_id")
309 .bind(("cred_id", URL_SAFE_NO_PAD.encode(passkey.cred_id())))
310 .bind(("data", serde_json::to_string(&passkey)?))
311 .await?
312 .check()?;
313 }
314 }
315 Ok(user_id)
316 }
317
318 async fn passkeys(&self, user_id: i64) -> Result<Vec<Passkey>> {
319 let data: Vec<String> = self
320 .db
321 .query("SELECT VALUE data FROM passkey WHERE user_id = $user_id")
322 .bind(("user_id", user_id))
323 .await?
324 .take(0)?;
325 Ok(data
326 .iter()
327 .filter_map(|data| serde_json::from_str(data).ok())
328 .collect())
329 }
330
331 async fn challenge(
332 &self,
333 user_id: Option<i64>,
334 state: &impl serde::Serialize,
335 options: &impl serde::Serialize,
336 ) -> Result<Challenge> {
337 let id = random_token();
338 self.db
339 .query(
340 "CREATE type::record('auth_challenge', $id)
341 SET user_id = $user_id, state = $state, expires_at = time::now() + 5m",
342 )
343 .bind(("id", id.clone()))
344 .bind(("user_id", user_id))
345 .bind(("state", serde_json::to_string(state)?))
346 .await?
347 .check()?;
348 let mut options = serde_json::to_value(options)?;
349 Ok(Challenge {
350 id,
351 options: options["publicKey"].take(),
352 })
353 }
354
355 async fn take_challenge(&self, id: &str) -> Result<(Option<i64>, String)> {
357 #[derive(SurrealValue)]
358 struct Pending {
359 user_id: Option<i64>,
360 state: String,
361 }
362 let mut response = self
363 .db
364 .query(
365 "LET $c = SELECT user_id, state FROM ONLY type::record('auth_challenge', $id)
366 WHERE expires_at > time::now();
367 DELETE type::record('auth_challenge', $id);
368 RETURN $c;",
369 )
370 .bind(("id", id.to_owned()))
371 .await?;
372 let pending: Option<Pending> = response.take(2)?;
373 let pending = pending.ok_or_else(expired)?;
374 Ok((pending.user_id, pending.state))
375 }
376
377 async fn mail(&self, email: &str, code: &str) -> Result<()> {
378 let subject = format!("{code} is your {} sign-in code", self.app_name);
379 let body = format!(
380 "Your sign-in code for {} is {code}.\n\nIt expires in 10 minutes. \
381 If you did not ask for it, you can ignore this email.\n",
382 self.app_name
383 );
384 match &*self.mailer {
385 Mailer::Smtp { transport, from } => {
386 let to: Mailbox = email
387 .parse()
388 .map_err(|_| Error::Auth("That email address is not valid.".into()))?;
389 let message = Message::builder()
390 .from(from.clone())
391 .to(to)
392 .subject(subject)
393 .body(body)
394 .map_err(|err| Error::AuthSetup(err.to_string()))?;
395 transport.send(message).await.map_err(|err| {
396 tracing::error!("sending a sign-in code failed: {err}");
397 Error::Auth("The code could not be sent; please try again.".into())
398 })?;
399 Ok(())
400 }
401 Mailer::Log if cfg!(debug_assertions) => {
402 tracing::info!("sign-in code for {email}: {code} (SMTP_URL is not set)");
403 if let Ok(dir) = std::env::var("MAIL_DIR") {
404 std::fs::write(std::path::Path::new(&dir).join(email), code)
405 .map_err(|err| Error::AuthSetup(format!("MAIL_DIR: {err}")))?;
406 }
407 Ok(())
408 }
409 Mailer::Log => Err(Error::AuthSetup(
410 "SMTP_URL is not set, so sign-in codes cannot be sent".into(),
411 )),
412 }
413 }
414}
415
416pub fn normalize_email(email: &str) -> Option<String> {
418 let email = email.trim().to_lowercase();
419 let (local, domain) = email.split_once('@')?;
420 let valid = !local.is_empty()
421 && domain.contains('.')
422 && email.len() <= 254
423 && email.parse::<Mailbox>().is_ok();
424 valid.then_some(email)
425}
426
427impl From<serde_json::Error> for Error {
428 fn from(err: serde_json::Error) -> Self {
429 Error::AuthSetup(err.to_string())
430 }
431}
432
433fn random_token() -> String {
434 URL_SAFE_NO_PAD.encode(rand::random::<[u8; 32]>())
435}
436
437fn digest(value: &str) -> String {
438 format!("{:x}", Sha256::digest(value.as_bytes()))
439}
440
441fn user_uuid(user_id: i64) -> Uuid {
442 Uuid::from_u64_pair(0, user_id as u64)
443}
444
445fn expired() -> Error {
446 Error::Auth("This sign-in expired; please try again.".into())
447}
448
449fn passkey_error(err: impl std::fmt::Display) -> Error {
450 tracing::debug!("passkey: {err}");
451 Error::Auth("The passkey was not accepted.".into())
452}