use std::sync::Arc;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use lettre::message::Mailbox;
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
use serde_json::Value;
use sha2::{Digest, Sha256};
use surrealdb::types::SurrealValue;
use webauthn_rs::prelude::*;
use crate::database::Db;
use crate::{Error, Result};
const MAX_ATTEMPTS: i64 = 5;
const MAX_SENDS: i64 = 5;
#[derive(Debug, Clone, SurrealValue)]
pub struct Session {
pub user_id: i64,
pub customer: bool,
}
#[derive(Debug, Clone)]
pub struct Challenge {
pub id: String,
pub options: Value,
}
#[derive(Clone)]
pub struct Auth {
db: Db,
app_name: String,
mailer: Arc<Mailer>,
webauthn: Arc<Webauthn>,
}
impl std::fmt::Debug for Auth {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Auth")
.field("app_name", &self.app_name)
.finish()
}
}
enum Mailer {
Smtp {
transport: AsyncSmtpTransport<Tokio1Executor>,
from: Mailbox,
},
Log,
}
impl Auth {
pub fn from_env(db: Db, app_name: &str) -> Result<Self> {
let var = |key: &str| std::env::var(key).ok().filter(|v| !v.trim().is_empty());
let setup = |err: String| Error::AuthSetup(err);
let mailer = match var("SMTP_URL") {
Some(url) => Mailer::Smtp {
transport: AsyncSmtpTransport::<Tokio1Executor>::from_url(&url)
.map_err(|err| setup(format!("SMTP_URL: {err}")))?
.build(),
from: var("MAIL_FROM")
.ok_or_else(|| setup("MAIL_FROM is not set".into()))?
.parse()
.map_err(|err| setup(format!("MAIL_FROM: {err}")))?,
},
None => Mailer::Log,
};
let rp_id = var("PASSKEY_RP_ID").unwrap_or_else(|| "localhost".into());
let origin = var("PASSKEY_ORIGIN").unwrap_or_else(|| "http://localhost:8000".into());
let origin = Url::parse(&origin).map_err(|err| setup(format!("PASSKEY_ORIGIN: {err}")))?;
let webauthn = WebauthnBuilder::new(&rp_id, &origin)
.and_then(|builder| {
builder
.rp_name(app_name)
.allow_subdomains(true)
.allow_any_port(true)
.build()
})
.map_err(|err| setup(format!("PASSKEY_RP_ID / PASSKEY_ORIGIN: {err}")))?;
Ok(Self {
db,
app_name: app_name.to_owned(),
mailer: Arc::new(mailer),
webauthn: Arc::new(webauthn),
})
}
pub async fn send_code(&self, email: &str) -> Result<()> {
#[derive(SurrealValue)]
struct Sent {
sent: i64,
in_window: bool,
}
let sent: Option<Sent> = self
.db
.query(
"SELECT sent, window_end > time::now() AS in_window
FROM ONLY type::record('login_code', $email)",
)
.bind(("email", email.to_owned()))
.await?
.take(0)?;
if sent.is_some_and(|s| s.in_window && s.sent >= MAX_SENDS) {
return Err(Error::Auth(
"Too many codes were sent to this address; try again in 15 minutes.".into(),
));
}
let code = format!("{:06}", rand::random_range(0..1_000_000));
self.db
.query(
"UPSERT type::record('login_code', $email) SET
code_hash = $hash, attempts = 0, expires_at = time::now() + 10m,
sent = IF window_end > time::now() { sent + 1 } ELSE { 1 },
window_end = IF window_end > time::now() { window_end } ELSE { time::now() + 15m }",
)
.bind(("email", email.to_owned()))
.bind(("hash", digest(&format!("{email}:{code}"))))
.await?
.check()?;
self.mail(email, &code).await
}
pub async fn verify_code(&self, email: &str, code: &str) -> Result<bool> {
#[derive(SurrealValue)]
struct Pending {
code_hash: String,
attempts: i64,
live: bool,
}
let pending: Option<Pending> = self
.db
.query(
"SELECT code_hash, attempts, expires_at > time::now() AS live
FROM ONLY type::record('login_code', $email)",
)
.bind(("email", email.to_owned()))
.await?
.take(0)?;
let Some(pending) = pending.filter(|p| p.live && p.attempts < MAX_ATTEMPTS) else {
return Ok(false);
};
let correct = pending.code_hash == digest(&format!("{email}:{}", code.trim()));
let update = if correct {
"DELETE type::record('login_code', $email)"
} else {
"UPDATE type::record('login_code', $email) SET attempts += 1"
};
self.db
.query(update)
.bind(("email", email.to_owned()))
.await?
.check()?;
Ok(correct)
}
pub async fn create_session(&self, user_id: i64, customer: bool) -> Result<String> {
let token = random_token();
self.db
.query(
"CREATE type::record('session', $hash)
SET user_id = $user_id, customer = $customer, expires_at = time::now() + 30d",
)
.bind(("hash", digest(&token)))
.bind(("user_id", user_id))
.bind(("customer", customer))
.await?
.check()?;
Ok(token)
}
pub async fn session(&self, token: &str) -> Result<Option<Session>> {
let session: Option<Session> = self
.db
.query(
"UPDATE type::record('session', $hash)
SET expires_at = IF expires_at < time::now() + 15d { time::now() + 30d } ELSE { expires_at }
WHERE expires_at > time::now()
RETURN user_id, customer",
)
.bind(("hash", digest(token)))
.await?
.take(0)?;
Ok(session)
}
pub async fn end_session(&self, token: &str) -> Result<()> {
self.db
.query("DELETE type::record('session', $hash)")
.bind(("hash", digest(token)))
.await?
.check()?;
Ok(())
}
pub async fn passkey_register_start(&self, user_id: i64, email: &str) -> Result<Challenge> {
let existing: Vec<CredentialID> = self
.passkeys(user_id)
.await?
.iter()
.map(|passkey| passkey.cred_id().clone())
.collect();
let (options, state) = self
.webauthn
.start_passkey_registration(user_uuid(user_id), email, email, Some(existing))
.map_err(passkey_error)?;
let mut challenge = self.challenge(Some(user_id), &state, &options).await?;
challenge.options["authenticatorSelection"]["residentKey"] = "required".into();
challenge.options["authenticatorSelection"]["requireResidentKey"] = true.into();
Ok(challenge)
}
pub async fn passkey_register_finish(
&self,
user_id: i64,
id: &str,
credential: Value,
) -> Result<()> {
let (owner, state) = self.take_challenge(id).await?;
if owner != Some(user_id) {
return Err(expired());
}
let credential = serde_json::from_value(credential).map_err(passkey_error)?;
let passkey = self
.webauthn
.finish_passkey_registration(&credential, &serde_json::from_str(&state)?)
.map_err(passkey_error)?;
self.db
.query("CREATE passkey SET user_id = $user_id, cred_id = $cred_id, data = $data")
.bind(("user_id", user_id))
.bind(("cred_id", URL_SAFE_NO_PAD.encode(passkey.cred_id())))
.bind(("data", serde_json::to_string(&passkey)?))
.await?
.check()?;
Ok(())
}
pub async fn passkey_login_start(&self) -> Result<Challenge> {
let (options, state) = self
.webauthn
.start_discoverable_authentication()
.map_err(passkey_error)?;
self.challenge(None, &state, &options).await
}
pub async fn passkey_login_finish(&self, id: &str, credential: Value) -> Result<i64> {
let (_, state) = self.take_challenge(id).await?;
let credential: PublicKeyCredential =
serde_json::from_value(credential).map_err(passkey_error)?;
let (uuid, _) = self
.webauthn
.identify_discoverable_authentication(&credential)
.map_err(passkey_error)?;
let user_id = uuid.as_u64_pair().1 as i64;
let mut passkeys = self.passkeys(user_id).await?;
let keys: Vec<DiscoverableKey> = passkeys.iter().map(DiscoverableKey::from).collect();
let result = self
.webauthn
.finish_discoverable_authentication(&credential, serde_json::from_str(&state)?, &keys)
.map_err(passkey_error)?;
for passkey in &mut passkeys {
if passkey.update_credential(&result) == Some(true) {
self.db
.query("UPDATE passkey SET data = $data WHERE cred_id = $cred_id")
.bind(("cred_id", URL_SAFE_NO_PAD.encode(passkey.cred_id())))
.bind(("data", serde_json::to_string(&passkey)?))
.await?
.check()?;
}
}
Ok(user_id)
}
async fn passkeys(&self, user_id: i64) -> Result<Vec<Passkey>> {
let data: Vec<String> = self
.db
.query("SELECT VALUE data FROM passkey WHERE user_id = $user_id")
.bind(("user_id", user_id))
.await?
.take(0)?;
Ok(data
.iter()
.filter_map(|data| serde_json::from_str(data).ok())
.collect())
}
async fn challenge(
&self,
user_id: Option<i64>,
state: &impl serde::Serialize,
options: &impl serde::Serialize,
) -> Result<Challenge> {
let id = random_token();
self.db
.query(
"CREATE type::record('auth_challenge', $id)
SET user_id = $user_id, state = $state, expires_at = time::now() + 5m",
)
.bind(("id", id.clone()))
.bind(("user_id", user_id))
.bind(("state", serde_json::to_string(state)?))
.await?
.check()?;
let mut options = serde_json::to_value(options)?;
Ok(Challenge {
id,
options: options["publicKey"].take(),
})
}
async fn take_challenge(&self, id: &str) -> Result<(Option<i64>, String)> {
#[derive(SurrealValue)]
struct Pending {
user_id: Option<i64>,
state: String,
}
let mut response = self
.db
.query(
"LET $c = SELECT user_id, state FROM ONLY type::record('auth_challenge', $id)
WHERE expires_at > time::now();
DELETE type::record('auth_challenge', $id);
RETURN $c;",
)
.bind(("id", id.to_owned()))
.await?;
let pending: Option<Pending> = response.take(2)?;
let pending = pending.ok_or_else(expired)?;
Ok((pending.user_id, pending.state))
}
async fn mail(&self, email: &str, code: &str) -> Result<()> {
let subject = format!("{code} is your {} sign-in code", self.app_name);
let body = format!(
"Your sign-in code for {} is {code}.\n\nIt expires in 10 minutes. \
If you did not ask for it, you can ignore this email.\n",
self.app_name
);
match &*self.mailer {
Mailer::Smtp { transport, from } => {
let to: Mailbox = email
.parse()
.map_err(|_| Error::Auth("That email address is not valid.".into()))?;
let message = Message::builder()
.from(from.clone())
.to(to)
.subject(subject)
.body(body)
.map_err(|err| Error::AuthSetup(err.to_string()))?;
transport.send(message).await.map_err(|err| {
tracing::error!("sending a sign-in code failed: {err}");
Error::Auth("The code could not be sent; please try again.".into())
})?;
Ok(())
}
Mailer::Log if cfg!(debug_assertions) => {
tracing::info!("sign-in code for {email}: {code} (SMTP_URL is not set)");
if let Ok(dir) = std::env::var("MAIL_DIR") {
std::fs::write(std::path::Path::new(&dir).join(email), code)
.map_err(|err| Error::AuthSetup(format!("MAIL_DIR: {err}")))?;
}
Ok(())
}
Mailer::Log => Err(Error::AuthSetup(
"SMTP_URL is not set, so sign-in codes cannot be sent".into(),
)),
}
}
}
pub fn normalize_email(email: &str) -> Option<String> {
let email = email.trim().to_lowercase();
let (local, domain) = email.split_once('@')?;
let valid = !local.is_empty()
&& domain.contains('.')
&& email.len() <= 254
&& email.parse::<Mailbox>().is_ok();
valid.then_some(email)
}
impl From<serde_json::Error> for Error {
fn from(err: serde_json::Error) -> Self {
Error::AuthSetup(err.to_string())
}
}
fn random_token() -> String {
URL_SAFE_NO_PAD.encode(rand::random::<[u8; 32]>())
}
fn digest(value: &str) -> String {
format!("{:x}", Sha256::digest(value.as_bytes()))
}
fn user_uuid(user_id: i64) -> Uuid {
Uuid::from_u64_pair(0, user_id as u64)
}
fn expired() -> Error {
Error::Auth("This sign-in expired; please try again.".into())
}
fn passkey_error(err: impl std::fmt::Display) -> Error {
tracing::debug!("passkey: {err}");
Error::Auth("The passkey was not accepted.".into())
}