341 lines
12 KiB
Rust
341 lines
12 KiB
Rust
use std::collections::HashMap;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use webauthn_rs::{
|
|
Webauthn,
|
|
core::{AuthenticationState, RegistrationState, WebauthnConfig},
|
|
error::WebauthnError as WanError,
|
|
proto::{
|
|
CreationChallengeResponse, Credential, PublicKeyCredential, RegisterPublicKeyCredential,
|
|
RequestChallengeResponse, UserVerificationPolicy,
|
|
},
|
|
};
|
|
|
|
use crate::domain::{DomainError, DomainResult, Passkey as DbPasskey, User};
|
|
use crate::ports::{PasskeyRepository, UserRepository};
|
|
use crate::security::new_secret;
|
|
use crate::sqlite::SqliteDatabase;
|
|
|
|
/// Site-specific WebAuthn configuration, derived from env vars.
|
|
pub struct AppWebauthnConfig {
|
|
rp_id: String,
|
|
rp_name: String,
|
|
origin: url::Url,
|
|
require_resident_key: bool,
|
|
}
|
|
|
|
impl AppWebauthnConfig {
|
|
pub fn new(rp_id: String, rp_name: String, origin: url::Url) -> Self {
|
|
Self {
|
|
rp_id,
|
|
rp_name,
|
|
origin,
|
|
// Resident (discoverable) keys let users sign in without typing an
|
|
// email, because the authenticator can select the credential on its
|
|
// own and return the user handle.
|
|
require_resident_key: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl WebauthnConfig for AppWebauthnConfig {
|
|
fn get_relying_party_name(&self) -> &str {
|
|
&self.rp_name
|
|
}
|
|
fn get_origin(&self) -> &url::Url {
|
|
&self.origin
|
|
}
|
|
fn get_relying_party_id(&self) -> &str {
|
|
&self.rp_id
|
|
}
|
|
fn get_require_resident_key(&self) -> bool {
|
|
self.require_resident_key
|
|
}
|
|
}
|
|
|
|
/// A single-use, in-memory challenge store. Registrations are keyed by user id;
|
|
/// authentications are keyed by a random token so that userless (discoverable)
|
|
/// ceremonies can be correlated back to the finish request.
|
|
#[derive(Default)]
|
|
struct ChallengeStore {
|
|
registrations: HashMap<i64, RegistrationState>,
|
|
authentications: HashMap<String, AuthenticationState>,
|
|
}
|
|
|
|
pub struct WebAuthnService {
|
|
db: SqliteDatabase,
|
|
webauthn: Webauthn<AppWebauthnConfig>,
|
|
users: Arc<dyn UserRepository>,
|
|
passkeys: Arc<dyn PasskeyRepository>,
|
|
challenges: Mutex<ChallengeStore>,
|
|
}
|
|
|
|
impl WebAuthnService {
|
|
pub fn new(
|
|
db: SqliteDatabase,
|
|
config: AppWebauthnConfig,
|
|
users: Arc<dyn UserRepository>,
|
|
passkeys: Arc<dyn PasskeyRepository>,
|
|
) -> Self {
|
|
let webauthn = Webauthn::new(config);
|
|
Self {
|
|
db,
|
|
webauthn,
|
|
users,
|
|
passkeys,
|
|
challenges: Mutex::new(ChallengeStore::default()),
|
|
}
|
|
}
|
|
|
|
/// Start a passkey registration ceremony for an authenticated user.
|
|
pub fn start_registration(&self, user: &User) -> DomainResult<CreationChallengeResponse> {
|
|
// Use the user's opaque, random user handle as the WebAuthn userHandle
|
|
// so that userless (discoverable) sign-in can resolve the owning user
|
|
// from the assertion's userHandle without exposing the numeric id.
|
|
let (challenge, state) = self
|
|
.webauthn
|
|
.generate_challenge_register_options(
|
|
user.user_handle.clone(),
|
|
user.email.clone(),
|
|
user.display_name.clone(),
|
|
None,
|
|
Some(UserVerificationPolicy::Required),
|
|
None,
|
|
)
|
|
.map_err(webauthn_error)?;
|
|
self.challenges
|
|
.lock()
|
|
.map_err(|_| DomainError::Database("challenge lock poisoned".into()))?
|
|
.registrations
|
|
.insert(user.id, state);
|
|
Ok(challenge)
|
|
}
|
|
|
|
/// Finish a passkey registration ceremony and persist the credential.
|
|
pub async fn finish_registration(
|
|
&self,
|
|
user: &User,
|
|
response: RegisterPublicKeyCredential,
|
|
) -> DomainResult<()> {
|
|
let state = self
|
|
.challenges
|
|
.lock()
|
|
.map_err(|_| DomainError::Database("challenge lock poisoned".into()))?
|
|
.registrations
|
|
.remove(&user.id)
|
|
.ok_or(DomainError::NotFound)?;
|
|
|
|
let passkeys = Arc::clone(&self.passkeys);
|
|
let credential_id = response.raw_id.0.clone();
|
|
let user_id = user.id;
|
|
let credential = self
|
|
.webauthn
|
|
.register_credential(&response, &state, |_| Ok(false))
|
|
.map_err(webauthn_error)?;
|
|
|
|
let serialized = serde_json::to_string(&credential.0)
|
|
.map_err(|e| DomainError::Database(e.to_string()))?;
|
|
let credential_id_b64 = base64_url(&credential_id);
|
|
let counter = credential.0.counter as i64;
|
|
|
|
self.db
|
|
.run(move |txn| {
|
|
let passkeys = passkeys.clone();
|
|
Box::pin(async move {
|
|
passkeys
|
|
.create_passkey(txn, user_id, credential_id_b64, serialized, counter)
|
|
.await?;
|
|
Ok(())
|
|
})
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// Start a passkey authentication ceremony for a user identified by email.
|
|
/// Returns the challenge and a token used to correlate the finish request.
|
|
pub async fn start_authentication(
|
|
&self,
|
|
user_id: i64,
|
|
) -> DomainResult<(RequestChallengeResponse, String)> {
|
|
let passkeys = Arc::clone(&self.passkeys);
|
|
let db = self.db.clone();
|
|
let credentials: Vec<Credential> = db
|
|
.run(move |txn| {
|
|
let passkeys = passkeys.clone();
|
|
Box::pin(async move {
|
|
let rows = passkeys.list_for_user(txn, user_id).await?;
|
|
let mut creds = Vec::new();
|
|
for row in rows {
|
|
let cred: Credential = serde_json::from_str(&row.credential)
|
|
.map_err(|e| DomainError::Database(e.to_string()))?;
|
|
creds.push(cred);
|
|
}
|
|
Ok(creds)
|
|
})
|
|
})
|
|
.await?;
|
|
if credentials.is_empty() {
|
|
return Err(DomainError::NotFound);
|
|
}
|
|
let (challenge, state) = self
|
|
.webauthn
|
|
.generate_challenge_authenticate(credentials)
|
|
.map_err(webauthn_error)?;
|
|
let token = hex::encode(new_secret());
|
|
self.challenges
|
|
.lock()
|
|
.map_err(|_| DomainError::Database("challenge lock poisoned".into()))?
|
|
.authentications
|
|
.insert(token.clone(), state);
|
|
Ok((challenge, token))
|
|
}
|
|
|
|
/// Start a userless passkey authentication ceremony. No email is required:
|
|
/// the authenticator selects a discoverable credential and returns a user
|
|
/// handle that we resolve to the owning user on finish.
|
|
pub async fn start_userless_authentication(
|
|
&self,
|
|
) -> DomainResult<(RequestChallengeResponse, String)> {
|
|
let (challenge, mut state) = self
|
|
.webauthn
|
|
.generate_challenge_authenticate_options(vec![], None)
|
|
.map_err(webauthn_error)?;
|
|
// With no allowCredentials the browser will offer any discoverable
|
|
// credential for this RP; the credential set is populated from the
|
|
// user handle once the assertion is received.
|
|
state.set_allowed_credentials(vec![]);
|
|
let token = hex::encode(new_secret());
|
|
self.challenges
|
|
.lock()
|
|
.map_err(|_| DomainError::Database("challenge lock poisoned".into()))?
|
|
.authentications
|
|
.insert(token.clone(), state);
|
|
Ok((challenge, token))
|
|
}
|
|
|
|
/// Finish a passkey authentication ceremony, resolving the owning user from
|
|
/// the credential id (and, for userless ceremonies, the user handle).
|
|
pub async fn finish_authentication(
|
|
&self,
|
|
token: String,
|
|
response: PublicKeyCredential,
|
|
) -> DomainResult<i64> {
|
|
let mut state = self
|
|
.challenges
|
|
.lock()
|
|
.map_err(|_| DomainError::Database("challenge lock poisoned".into()))?
|
|
.authentications
|
|
.remove(&token)
|
|
.ok_or(DomainError::NotFound)?;
|
|
|
|
// For userless ceremonies the assertion carries a user handle that
|
|
// identifies the user; load that user's credentials so the signature
|
|
// can be verified against the correct key.
|
|
if let Some(user_handle) = response.get_user_handle() {
|
|
let handle = user_handle.to_vec();
|
|
let users = Arc::clone(&self.users);
|
|
let db = self.db.clone();
|
|
let user_id = db
|
|
.run(move |txn| {
|
|
let users = users.clone();
|
|
Box::pin(async move {
|
|
let user = users
|
|
.find_user_by_handle(txn, handle)
|
|
.await?
|
|
.ok_or(DomainError::NotFound)?;
|
|
Ok(user.id)
|
|
})
|
|
})
|
|
.await?;
|
|
let passkeys = Arc::clone(&self.passkeys);
|
|
let credentials: Vec<Credential> = db
|
|
.run(move |txn| {
|
|
let passkeys = passkeys.clone();
|
|
Box::pin(async move {
|
|
let rows = passkeys.list_for_user(txn, user_id).await?;
|
|
let mut creds = Vec::new();
|
|
for row in rows {
|
|
let cred: Credential = serde_json::from_str(&row.credential)
|
|
.map_err(|e| DomainError::Database(e.to_string()))?;
|
|
creds.push(cred);
|
|
}
|
|
Ok(creds)
|
|
})
|
|
})
|
|
.await?;
|
|
state.set_allowed_credentials(credentials);
|
|
}
|
|
|
|
let (cred_id, auth_data) = self
|
|
.webauthn
|
|
.authenticate_credential(&response, &state)
|
|
.map_err(|e| {
|
|
tracing::error!(%e, "webauthn authenticate_credential failed");
|
|
webauthn_error(e)
|
|
})?;
|
|
|
|
let passkeys = Arc::clone(&self.passkeys);
|
|
let db = self.db.clone();
|
|
let credential_id_b64 = base64_url(cred_id);
|
|
let user_id = db
|
|
.run(move |txn| {
|
|
let passkeys = passkeys.clone();
|
|
Box::pin(async move {
|
|
let stored = passkeys
|
|
.find_by_credential_id(txn, credential_id_b64)
|
|
.await?
|
|
.ok_or(DomainError::NotFound)?;
|
|
let mut cred: Credential = serde_json::from_str(&stored.credential)
|
|
.map_err(|e| DomainError::Database(e.to_string()))?;
|
|
cred.counter = auth_data.counter;
|
|
let serialized = serde_json::to_string(&cred)
|
|
.map_err(|e| DomainError::Database(e.to_string()))?;
|
|
sqlx::query("UPDATE passkeys SET credential = ?1 WHERE id = ?2")
|
|
.bind(&serialized)
|
|
.bind(stored.id)
|
|
.execute(&mut *txn)
|
|
.await
|
|
.map_err(db_error)?;
|
|
Ok(stored.user_id)
|
|
})
|
|
})
|
|
.await?;
|
|
Ok(user_id)
|
|
}
|
|
|
|
/// List the passkeys registered to a user.
|
|
pub async fn list_passkeys(&self, user_id: i64) -> DomainResult<Vec<DbPasskey>> {
|
|
let passkeys = Arc::clone(&self.passkeys);
|
|
self.db
|
|
.run(move |txn| {
|
|
let passkeys = passkeys.clone();
|
|
Box::pin(async move { passkeys.list_for_user(txn, user_id).await })
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// Delete a passkey owned by a user.
|
|
pub async fn delete_passkey(&self, user_id: i64, passkey_id: i64) -> DomainResult<()> {
|
|
let passkeys = Arc::clone(&self.passkeys);
|
|
self.db
|
|
.run(move |txn| {
|
|
let passkeys = passkeys.clone();
|
|
Box::pin(async move { passkeys.delete_passkey(txn, user_id, passkey_id).await })
|
|
})
|
|
.await
|
|
}
|
|
}
|
|
|
|
fn base64_url(bytes: &[u8]) -> String {
|
|
use base64::Engine;
|
|
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
|
|
}
|
|
|
|
fn webauthn_error(error: WanError) -> DomainError {
|
|
DomainError::Database(error.to_string())
|
|
}
|
|
|
|
fn db_error(error: sqlx::Error) -> DomainError {
|
|
DomainError::Database(error.to_string())
|
|
}
|