- Restructured Cargo.toml with Bundle Pattern for easy feature selection
- Added feature bundles: tasks → automation + drive + monitoring
- Applied conditional compilation guards throughout codebase:
* AppState fields (drive, cache, task_engine, task_scheduler)
* main.rs initialization (S3, Redis, Tasks)
* SessionManager Redis usage
* bootstrap S3/Drive operations
* compiler task scheduling
* shared module Task/NewTask exports
- Eliminated all botserver compilation warnings
- Minimal build now compiles successfully
- Accepted core dependencies: automation (Rhai), drive (S3), cache (Redis)
- Created DEPENDENCY_FIX_PLAN.md with complete documentation
Minimal feature set: chat + automation + drive + cache
Verified: cargo check -p botserver --no-default-features --features minimal ✅
1550 lines
50 KiB
Rust
1550 lines
50 KiB
Rust
use argon2::PasswordVerifier;
|
|
use axum::{
|
|
extract::{Path, State},
|
|
http::StatusCode,
|
|
response::IntoResponse,
|
|
routing::{delete, get, post},
|
|
Json, Router,
|
|
};
|
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
|
use chrono::{DateTime, Duration, Utc};
|
|
use diesel::prelude::*;
|
|
use diesel::sql_types::{BigInt, Bytea, Nullable, Text, Timestamptz, Uuid as DieselUuid};
|
|
use log::{error, info, warn};
|
|
use ring::rand::{SecureRandom, SystemRandom};
|
|
use serde::{Deserialize, Serialize};
|
|
use sha2::{Digest, Sha256};
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
use tokio::sync::RwLock;
|
|
use uuid::Uuid;
|
|
|
|
use crate::shared::state::AppState;
|
|
use crate::shared::utils::DbPool;
|
|
|
|
const CHALLENGE_TIMEOUT_SECONDS: i64 = 300;
|
|
const PASSKEY_NAME_MAX_LENGTH: usize = 64;
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct FallbackAttemptTracker {
|
|
attempts: u32,
|
|
locked_until: Option<DateTime<Utc>>,
|
|
}
|
|
|
|
pub struct PasskeyCredential {
|
|
pub id: String,
|
|
pub user_id: Uuid,
|
|
pub credential_id: Vec<u8>,
|
|
pub public_key: Vec<u8>,
|
|
pub counter: u32,
|
|
pub name: String,
|
|
pub created_at: DateTime<Utc>,
|
|
pub last_used_at: Option<DateTime<Utc>>,
|
|
pub aaguid: Option<Vec<u8>>,
|
|
pub transports: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PasskeyChallenge {
|
|
pub challenge: Vec<u8>,
|
|
pub user_id: Option<Uuid>,
|
|
pub created_at: DateTime<Utc>,
|
|
pub operation: ChallengeOperation,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ChallengeOperation {
|
|
Registration,
|
|
Authentication,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct RegistrationOptionsRequest {
|
|
pub user_id: Uuid,
|
|
pub username: String,
|
|
pub display_name: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct RegistrationOptions {
|
|
pub challenge: String,
|
|
pub rp: RelyingParty,
|
|
pub user: UserEntity,
|
|
pub pub_key_cred_params: Vec<PubKeyCredParam>,
|
|
pub timeout: u32,
|
|
pub attestation: String,
|
|
pub authenticator_selection: AuthenticatorSelection,
|
|
pub exclude_credentials: Vec<CredentialDescriptor>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct RelyingParty {
|
|
pub id: String,
|
|
pub name: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct UserEntity {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub display_name: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PubKeyCredParam {
|
|
#[serde(rename = "type")]
|
|
pub cred_type: String,
|
|
pub alg: i32,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AuthenticatorSelection {
|
|
pub authenticator_attachment: Option<String>,
|
|
pub resident_key: String,
|
|
pub require_resident_key: bool,
|
|
pub user_verification: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct CredentialDescriptor {
|
|
#[serde(rename = "type")]
|
|
pub cred_type: String,
|
|
pub id: String,
|
|
pub transports: Option<Vec<String>>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct RegistrationResponse {
|
|
pub id: String,
|
|
pub raw_id: String,
|
|
pub response: AuthenticatorAttestationResponse,
|
|
#[serde(rename = "type")]
|
|
pub cred_type: String,
|
|
pub client_extension_results: Option<HashMap<String, serde_json::Value>>,
|
|
pub authenticator_attachment: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AuthenticatorAttestationResponse {
|
|
pub client_data_json: String,
|
|
pub attestation_object: String,
|
|
pub transports: Option<Vec<String>>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AuthenticationOptionsRequest {
|
|
pub username: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AuthenticationOptions {
|
|
pub challenge: String,
|
|
pub timeout: u32,
|
|
pub rp_id: String,
|
|
pub allow_credentials: Vec<CredentialDescriptor>,
|
|
pub user_verification: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AuthenticationResponse {
|
|
pub id: String,
|
|
pub raw_id: String,
|
|
pub response: AuthenticatorAssertionResponse,
|
|
#[serde(rename = "type")]
|
|
pub cred_type: String,
|
|
pub client_extension_results: Option<HashMap<String, serde_json::Value>>,
|
|
pub authenticator_attachment: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AuthenticatorAssertionResponse {
|
|
pub client_data_json: String,
|
|
pub authenticator_data: String,
|
|
pub signature: String,
|
|
pub user_handle: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PasskeyInfo {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub created_at: DateTime<Utc>,
|
|
pub last_used_at: Option<DateTime<Utc>>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct RenamePasskeyRequest {
|
|
pub name: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct VerificationResult {
|
|
pub success: bool,
|
|
pub user_id: Option<Uuid>,
|
|
pub credential_id: Option<String>,
|
|
pub error: Option<String>,
|
|
pub used_fallback: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct RegistrationResult {
|
|
pub success: bool,
|
|
pub credential_id: Option<String>,
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PasswordFallbackRequest {
|
|
pub username: String,
|
|
pub password: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PasswordFallbackResponse {
|
|
pub success: bool,
|
|
pub user_id: Option<Uuid>,
|
|
pub token: Option<String>,
|
|
pub error: Option<String>,
|
|
pub passkey_available: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct FallbackConfig {
|
|
pub enabled: bool,
|
|
pub require_additional_verification: bool,
|
|
pub max_fallback_attempts: u32,
|
|
pub lockout_duration_seconds: u64,
|
|
pub prompt_passkey_setup: bool,
|
|
}
|
|
|
|
impl Default for FallbackConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: true,
|
|
require_additional_verification: false,
|
|
max_fallback_attempts: 5,
|
|
lockout_duration_seconds: 900, // 15 minutes
|
|
prompt_passkey_setup: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(QueryableByName)]
|
|
struct PasskeyRow {
|
|
#[diesel(sql_type = Text)]
|
|
id: String,
|
|
#[diesel(sql_type = DieselUuid)]
|
|
user_id: Uuid,
|
|
#[diesel(sql_type = Bytea)]
|
|
credential_id: Vec<u8>,
|
|
#[diesel(sql_type = Bytea)]
|
|
public_key: Vec<u8>,
|
|
#[diesel(sql_type = BigInt)]
|
|
counter: i64,
|
|
#[diesel(sql_type = Text)]
|
|
name: String,
|
|
#[diesel(sql_type = Timestamptz)]
|
|
created_at: DateTime<Utc>,
|
|
#[diesel(sql_type = Nullable<Timestamptz>)]
|
|
last_used_at: Option<DateTime<Utc>>,
|
|
#[diesel(sql_type = Nullable<Bytea>)]
|
|
aaguid: Option<Vec<u8>>,
|
|
#[diesel(sql_type = Nullable<Text>)]
|
|
transports: Option<String>,
|
|
}
|
|
|
|
pub struct PasskeyService {
|
|
pool: Arc<diesel::r2d2::Pool<diesel::r2d2::ConnectionManager<diesel::PgConnection>>>,
|
|
rp_id: String,
|
|
rp_name: String,
|
|
rp_origin: String,
|
|
challenges: Arc<RwLock<HashMap<String, PasskeyChallenge>>>,
|
|
rng: SystemRandom,
|
|
fallback_config: FallbackConfig,
|
|
fallback_attempts: Arc<RwLock<HashMap<String, FallbackAttemptTracker>>>,
|
|
}
|
|
|
|
impl PasskeyService {
|
|
pub fn new(
|
|
pool: DbPool,
|
|
rp_id: String,
|
|
rp_name: String,
|
|
rp_origin: String,
|
|
) -> Self {
|
|
Self {
|
|
pool: Arc::new(pool),
|
|
rp_id,
|
|
rp_name,
|
|
rp_origin,
|
|
challenges: Arc::new(RwLock::new(HashMap::new())),
|
|
rng: SystemRandom::new(),
|
|
fallback_config: FallbackConfig::default(),
|
|
fallback_attempts: Arc::new(RwLock::new(HashMap::new())),
|
|
}
|
|
}
|
|
|
|
pub fn with_fallback_config(
|
|
pool: DbPool,
|
|
rp_id: String,
|
|
rp_name: String,
|
|
rp_origin: String,
|
|
fallback_config: FallbackConfig,
|
|
) -> Self {
|
|
Self {
|
|
pool: Arc::new(pool),
|
|
rp_id,
|
|
rp_name,
|
|
rp_origin,
|
|
challenges: Arc::new(RwLock::new(HashMap::new())),
|
|
rng: SystemRandom::new(),
|
|
fallback_config,
|
|
fallback_attempts: Arc::new(RwLock::new(HashMap::new())),
|
|
}
|
|
}
|
|
|
|
pub fn user_has_passkeys(&self, username: &str) -> Result<bool, PasskeyError> {
|
|
let passkeys = self.get_passkeys_by_username(username)?;
|
|
Ok(!passkeys.is_empty())
|
|
}
|
|
|
|
pub async fn authenticate_with_password_fallback(
|
|
&self,
|
|
request: &PasswordFallbackRequest,
|
|
) -> Result<PasswordFallbackResponse, PasskeyError> {
|
|
if !self.fallback_config.enabled {
|
|
return Ok(PasswordFallbackResponse {
|
|
success: false,
|
|
user_id: None,
|
|
token: None,
|
|
error: Some("Password fallback is disabled".to_string()),
|
|
passkey_available: false,
|
|
});
|
|
}
|
|
|
|
// Check if user is locked out
|
|
if self.is_user_locked_out(&request.username).await {
|
|
return Ok(PasswordFallbackResponse {
|
|
success: false,
|
|
user_id: None,
|
|
token: None,
|
|
error: Some("Account temporarily locked due to too many failed attempts".to_string()),
|
|
passkey_available: false,
|
|
});
|
|
}
|
|
|
|
// Verify password against database
|
|
let verification_result = self.verify_password(&request.username, &request.password).await;
|
|
|
|
match verification_result {
|
|
Ok(user_id) => {
|
|
// Clear failed attempts on successful login
|
|
self.clear_fallback_attempts(&request.username).await;
|
|
|
|
// Check if user has passkeys available
|
|
let passkey_available = self.user_has_passkeys(&request.username).unwrap_or(false);
|
|
|
|
// Generate session token
|
|
let token = self.generate_session_token(&user_id);
|
|
|
|
Ok(PasswordFallbackResponse {
|
|
success: true,
|
|
user_id: Some(user_id),
|
|
token: Some(token),
|
|
error: None,
|
|
passkey_available,
|
|
})
|
|
}
|
|
Err(e) => {
|
|
// Track failed attempt
|
|
self.track_fallback_attempt(&request.username).await;
|
|
|
|
Ok(PasswordFallbackResponse {
|
|
success: false,
|
|
user_id: None,
|
|
token: None,
|
|
error: Some(e.to_string()),
|
|
passkey_available: false,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn is_user_locked_out(&self, username: &str) -> bool {
|
|
let attempts = self.fallback_attempts.read().await;
|
|
if let Some(tracker) = attempts.get(username) {
|
|
if let Some(locked_until) = tracker.locked_until {
|
|
return Utc::now() < locked_until;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
async fn track_fallback_attempt(&self, username: &str) {
|
|
let mut attempts = self.fallback_attempts.write().await;
|
|
let now = Utc::now();
|
|
|
|
let tracker = attempts.entry(username.to_string()).or_insert(FallbackAttemptTracker {
|
|
attempts: 0,
|
|
locked_until: None,
|
|
});
|
|
|
|
tracker.attempts += 1;
|
|
|
|
// Check if we should lock out the user
|
|
if tracker.attempts >= self.fallback_config.max_fallback_attempts {
|
|
tracker.locked_until = Some(
|
|
now + chrono::Duration::seconds(self.fallback_config.lockout_duration_seconds as i64)
|
|
);
|
|
}
|
|
}
|
|
|
|
async fn clear_fallback_attempts(&self, username: &str) {
|
|
let mut attempts = self.fallback_attempts.write().await;
|
|
attempts.remove(username);
|
|
}
|
|
|
|
async fn verify_password(&self, username: &str, password: &str) -> Result<Uuid, PasskeyError> {
|
|
let mut conn = self.pool.get().map_err(|_| PasskeyError::DatabaseError)?;
|
|
|
|
#[derive(QueryableByName)]
|
|
struct UserPasswordRow {
|
|
#[diesel(sql_type = DieselUuid)]
|
|
id: Uuid,
|
|
#[diesel(sql_type = Nullable<Text>)]
|
|
password_hash: Option<String>,
|
|
}
|
|
|
|
let result: Option<UserPasswordRow> = diesel::sql_query(
|
|
"SELECT id, password_hash FROM users WHERE username = $1 OR email = $1"
|
|
)
|
|
.bind::<Text, _>(username)
|
|
.get_result::<UserPasswordRow>(&mut conn)
|
|
.optional()
|
|
.map_err(|_| PasskeyError::DatabaseError)?;
|
|
|
|
match result {
|
|
Some(row) => {
|
|
if let Some(hash) = row.password_hash {
|
|
let parsed_hash = argon2::PasswordHash::new(&hash)
|
|
.map_err(|_| PasskeyError::InvalidCredentialId)?;
|
|
|
|
if argon2::Argon2::default()
|
|
.verify_password(password.as_bytes(), &parsed_hash)
|
|
.is_ok()
|
|
{
|
|
return Ok(row.id);
|
|
}
|
|
}
|
|
Err(PasskeyError::InvalidCredentialId)
|
|
}
|
|
None => Err(PasskeyError::InvalidCredentialId),
|
|
}
|
|
}
|
|
|
|
fn generate_session_token(&self, user_id: &Uuid) -> String {
|
|
let random_bytes: [u8; 32] = rand::random();
|
|
let token = base64::Engine::encode(
|
|
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
|
|
random_bytes
|
|
);
|
|
format!("{}:{}", user_id, token)
|
|
}
|
|
|
|
pub fn should_offer_password_fallback(&self, username: &str) -> Result<bool, PasskeyError> {
|
|
if !self.fallback_config.enabled {
|
|
return Ok(false);
|
|
}
|
|
|
|
let has_passkeys = self.user_has_passkeys(username)?;
|
|
Ok(!has_passkeys || self.fallback_config.enabled)
|
|
}
|
|
|
|
pub fn get_fallback_config(&self) -> &FallbackConfig {
|
|
&self.fallback_config
|
|
}
|
|
|
|
pub fn set_fallback_config(&mut self, config: FallbackConfig) {
|
|
self.fallback_config = config;
|
|
}
|
|
|
|
pub async fn generate_registration_options(
|
|
&self,
|
|
request: RegistrationOptionsRequest,
|
|
) -> Result<RegistrationOptions, PasskeyError> {
|
|
let challenge = self.generate_challenge()?;
|
|
let challenge_b64 = URL_SAFE_NO_PAD.encode(&challenge);
|
|
|
|
let passkey_challenge = PasskeyChallenge {
|
|
challenge: challenge.clone(),
|
|
user_id: Some(request.user_id),
|
|
created_at: Utc::now(),
|
|
operation: ChallengeOperation::Registration,
|
|
};
|
|
|
|
{
|
|
let mut challenges = self.challenges.write().await;
|
|
challenges.insert(challenge_b64.clone(), passkey_challenge);
|
|
}
|
|
|
|
let existing_credentials = self.get_user_passkeys(request.user_id)?;
|
|
let exclude_credentials: Vec<CredentialDescriptor> = existing_credentials
|
|
.into_iter()
|
|
.map(|pk| CredentialDescriptor {
|
|
cred_type: "public-key".to_string(),
|
|
id: URL_SAFE_NO_PAD.encode(&pk.credential_id),
|
|
transports: Some(pk.transports),
|
|
})
|
|
.collect();
|
|
|
|
let user_id_b64 = URL_SAFE_NO_PAD.encode(request.user_id.as_bytes());
|
|
|
|
Ok(RegistrationOptions {
|
|
challenge: challenge_b64,
|
|
rp: RelyingParty {
|
|
id: self.rp_id.clone(),
|
|
name: self.rp_name.clone(),
|
|
},
|
|
user: UserEntity {
|
|
id: user_id_b64,
|
|
name: request.username,
|
|
display_name: request.display_name,
|
|
},
|
|
pub_key_cred_params: vec![
|
|
PubKeyCredParam {
|
|
cred_type: "public-key".to_string(),
|
|
alg: -7,
|
|
},
|
|
PubKeyCredParam {
|
|
cred_type: "public-key".to_string(),
|
|
alg: -257,
|
|
},
|
|
],
|
|
timeout: 60000,
|
|
attestation: "none".to_string(),
|
|
authenticator_selection: AuthenticatorSelection {
|
|
authenticator_attachment: None,
|
|
resident_key: "preferred".to_string(),
|
|
require_resident_key: false,
|
|
user_verification: "preferred".to_string(),
|
|
},
|
|
exclude_credentials,
|
|
})
|
|
}
|
|
|
|
pub async fn verify_registration(
|
|
&self,
|
|
response: RegistrationResponse,
|
|
passkey_name: Option<String>,
|
|
) -> Result<RegistrationResult, PasskeyError> {
|
|
let client_data_json = URL_SAFE_NO_PAD
|
|
.decode(&response.response.client_data_json)
|
|
.map_err(|_| PasskeyError::InvalidClientData)?;
|
|
|
|
let client_data: ClientData = serde_json::from_slice(&client_data_json)
|
|
.map_err(|_| PasskeyError::InvalidClientData)?;
|
|
|
|
if client_data.r#type != "webauthn.create" {
|
|
return Err(PasskeyError::InvalidCeremonyType);
|
|
}
|
|
|
|
if !self.verify_origin(&client_data.origin) {
|
|
return Err(PasskeyError::InvalidOrigin);
|
|
}
|
|
|
|
let challenge_bytes = URL_SAFE_NO_PAD
|
|
.decode(&client_data.challenge)
|
|
.map_err(|_| PasskeyError::InvalidChallenge)?;
|
|
log::debug!("Decoded challenge bytes, length: {}", challenge_bytes.len());
|
|
|
|
let stored_challenge = self.get_and_remove_challenge(&client_data.challenge).await?;
|
|
|
|
if stored_challenge.operation != ChallengeOperation::Registration {
|
|
return Err(PasskeyError::InvalidCeremonyType);
|
|
}
|
|
|
|
let user_id = stored_challenge.user_id.ok_or(PasskeyError::MissingUserId)?;
|
|
|
|
let attestation_object = URL_SAFE_NO_PAD
|
|
.decode(&response.response.attestation_object)
|
|
.map_err(|_| PasskeyError::InvalidAttestationObject)?;
|
|
|
|
let (auth_data, public_key, aaguid) = self.parse_attestation_object(&attestation_object)?;
|
|
log::debug!("Parsed attestation object, auth_data length: {}", auth_data.len());
|
|
|
|
let credential_id = URL_SAFE_NO_PAD
|
|
.decode(&response.raw_id)
|
|
.map_err(|_| PasskeyError::InvalidCredentialId)?;
|
|
|
|
let name = passkey_name.unwrap_or_else(|| {
|
|
format!("Passkey {}", Utc::now().format("%Y-%m-%d %H:%M"))
|
|
});
|
|
|
|
let sanitized_name: String = name
|
|
.chars()
|
|
.filter(|c| c.is_alphanumeric() || c.is_whitespace() || *c == '-' || *c == '_')
|
|
.take(PASSKEY_NAME_MAX_LENGTH)
|
|
.collect();
|
|
|
|
let transports = response
|
|
.response
|
|
.transports
|
|
.unwrap_or_default()
|
|
.join(",");
|
|
|
|
self.store_passkey(
|
|
user_id,
|
|
&credential_id,
|
|
&public_key,
|
|
0,
|
|
&sanitized_name,
|
|
aaguid.as_deref(),
|
|
&transports,
|
|
)?;
|
|
|
|
info!("Passkey registered for user {}", user_id);
|
|
|
|
Ok(RegistrationResult {
|
|
success: true,
|
|
credential_id: Some(URL_SAFE_NO_PAD.encode(&credential_id)),
|
|
error: None,
|
|
})
|
|
}
|
|
|
|
pub async fn generate_authentication_options(
|
|
&self,
|
|
request: AuthenticationOptionsRequest,
|
|
) -> Result<AuthenticationOptions, PasskeyError> {
|
|
let challenge = self.generate_challenge()?;
|
|
let challenge_b64 = URL_SAFE_NO_PAD.encode(&challenge);
|
|
|
|
let passkey_challenge = PasskeyChallenge {
|
|
challenge: challenge.clone(),
|
|
user_id: None,
|
|
created_at: Utc::now(),
|
|
operation: ChallengeOperation::Authentication,
|
|
};
|
|
|
|
{
|
|
let mut challenges = self.challenges.write().await;
|
|
challenges.insert(challenge_b64.clone(), passkey_challenge);
|
|
}
|
|
|
|
let allow_credentials = if let Some(username) = request.username {
|
|
let credentials = self.get_passkeys_by_username(&username)?;
|
|
credentials
|
|
.into_iter()
|
|
.map(|pk| CredentialDescriptor {
|
|
cred_type: "public-key".to_string(),
|
|
id: URL_SAFE_NO_PAD.encode(&pk.credential_id),
|
|
transports: Some(pk.transports),
|
|
})
|
|
.collect()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
Ok(AuthenticationOptions {
|
|
challenge: challenge_b64,
|
|
timeout: 60000,
|
|
rp_id: self.rp_id.clone(),
|
|
allow_credentials,
|
|
user_verification: "preferred".to_string(),
|
|
})
|
|
}
|
|
|
|
pub async fn verify_authentication(
|
|
&self,
|
|
response: AuthenticationResponse,
|
|
) -> Result<VerificationResult, PasskeyError> {
|
|
let client_data_json = URL_SAFE_NO_PAD
|
|
.decode(&response.response.client_data_json)
|
|
.map_err(|_| PasskeyError::InvalidClientData)?;
|
|
|
|
let client_data: ClientData = serde_json::from_slice(&client_data_json)
|
|
.map_err(|_| PasskeyError::InvalidClientData)?;
|
|
|
|
if client_data.r#type != "webauthn.get" {
|
|
return Err(PasskeyError::InvalidCeremonyType);
|
|
}
|
|
|
|
if !self.verify_origin(&client_data.origin) {
|
|
return Err(PasskeyError::InvalidOrigin);
|
|
}
|
|
|
|
let _stored_challenge = self.get_and_remove_challenge(&client_data.challenge).await?;
|
|
|
|
let credential_id = URL_SAFE_NO_PAD
|
|
.decode(&response.raw_id)
|
|
.map_err(|_| PasskeyError::InvalidCredentialId)?;
|
|
|
|
let passkey = self.get_passkey_by_credential_id(&credential_id)?;
|
|
|
|
let authenticator_data = URL_SAFE_NO_PAD
|
|
.decode(&response.response.authenticator_data)
|
|
.map_err(|_| PasskeyError::InvalidAuthenticatorData)?;
|
|
|
|
let signature = URL_SAFE_NO_PAD
|
|
.decode(&response.response.signature)
|
|
.map_err(|_| PasskeyError::InvalidSignature)?;
|
|
|
|
let rp_id_hash = Sha256::digest(self.rp_id.as_bytes());
|
|
if authenticator_data.len() < 37 || &authenticator_data[..32] != rp_id_hash.as_slice() {
|
|
return Err(PasskeyError::RpIdMismatch);
|
|
}
|
|
|
|
let flags = authenticator_data[32];
|
|
let user_present = (flags & 0x01) != 0;
|
|
if !user_present {
|
|
return Err(PasskeyError::UserNotPresent);
|
|
}
|
|
|
|
let counter_bytes: [u8; 4] = authenticator_data[33..37]
|
|
.try_into()
|
|
.map_err(|_| PasskeyError::InvalidAuthenticatorData)?;
|
|
let counter = u32::from_be_bytes(counter_bytes);
|
|
|
|
if counter > 0 && counter <= passkey.counter {
|
|
warn!(
|
|
"Possible credential cloning detected for user {}",
|
|
passkey.user_id
|
|
);
|
|
return Err(PasskeyError::CounterMismatch);
|
|
}
|
|
|
|
let mut verification_data = Vec::new();
|
|
verification_data.extend_from_slice(&authenticator_data);
|
|
verification_data.extend_from_slice(&Sha256::digest(&client_data_json));
|
|
|
|
let signature_valid = self.verify_signature(
|
|
&passkey.public_key,
|
|
&verification_data,
|
|
&signature,
|
|
)?;
|
|
|
|
if !signature_valid {
|
|
return Err(PasskeyError::SignatureVerificationFailed);
|
|
}
|
|
|
|
self.update_passkey_counter(&credential_id, counter)?;
|
|
|
|
info!("Passkey authentication successful for user {}", passkey.user_id);
|
|
|
|
Ok(VerificationResult {
|
|
success: true,
|
|
user_id: Some(passkey.user_id),
|
|
credential_id: Some(URL_SAFE_NO_PAD.encode(&credential_id)),
|
|
error: None,
|
|
used_fallback: false,
|
|
})
|
|
}
|
|
|
|
pub fn get_user_passkeys(&self, user_id: Uuid) -> Result<Vec<PasskeyCredential>, PasskeyError> {
|
|
let mut conn = self.pool.get().map_err(|e| {
|
|
error!("Failed to get database connection: {e}");
|
|
PasskeyError::DatabaseError
|
|
})?;
|
|
|
|
let rows: Vec<PasskeyRow> = diesel::sql_query(
|
|
"SELECT id, user_id, credential_id, public_key, counter, name, created_at, last_used_at, aaguid, transports FROM passkeys WHERE user_id = $1 ORDER BY created_at DESC"
|
|
)
|
|
.bind::<DieselUuid, _>(user_id)
|
|
.load(&mut conn)
|
|
.map_err(|e| {
|
|
error!("Failed to query passkeys: {e}");
|
|
PasskeyError::DatabaseError
|
|
})?;
|
|
|
|
let credentials = rows
|
|
.into_iter()
|
|
.map(|row| PasskeyCredential {
|
|
id: row.id,
|
|
user_id: row.user_id,
|
|
credential_id: row.credential_id,
|
|
public_key: row.public_key,
|
|
counter: row.counter as u32,
|
|
name: row.name,
|
|
created_at: row.created_at,
|
|
last_used_at: row.last_used_at,
|
|
aaguid: row.aaguid,
|
|
transports: row
|
|
.transports
|
|
.map(|t| t.split(',').map(String::from).collect())
|
|
.unwrap_or_default(),
|
|
})
|
|
.collect();
|
|
|
|
Ok(credentials)
|
|
}
|
|
|
|
pub fn list_passkeys(&self, user_id: Uuid) -> Result<Vec<PasskeyInfo>, PasskeyError> {
|
|
let passkeys = self.get_user_passkeys(user_id)?;
|
|
let info = passkeys
|
|
.into_iter()
|
|
.map(|pk| PasskeyInfo {
|
|
id: pk.id,
|
|
name: pk.name,
|
|
created_at: pk.created_at,
|
|
last_used_at: pk.last_used_at,
|
|
})
|
|
.collect();
|
|
Ok(info)
|
|
}
|
|
|
|
pub fn rename_passkey(
|
|
&self,
|
|
user_id: Uuid,
|
|
passkey_id: &str,
|
|
new_name: &str,
|
|
) -> Result<(), PasskeyError> {
|
|
let sanitized_name: String = new_name
|
|
.chars()
|
|
.filter(|c| c.is_alphanumeric() || c.is_whitespace() || *c == '-' || *c == '_')
|
|
.take(PASSKEY_NAME_MAX_LENGTH)
|
|
.collect();
|
|
|
|
let mut conn = self.pool.get().map_err(|_| PasskeyError::DatabaseError)?;
|
|
|
|
let result = diesel::sql_query(
|
|
"UPDATE passkeys SET name = $1 WHERE id = $2 AND user_id = $3",
|
|
)
|
|
.bind::<Text, _>(&sanitized_name)
|
|
.bind::<Text, _>(passkey_id)
|
|
.bind::<DieselUuid, _>(user_id)
|
|
.execute(&mut conn)
|
|
.map_err(|e| {
|
|
error!("Failed to rename passkey: {e}");
|
|
PasskeyError::DatabaseError
|
|
})?;
|
|
|
|
if result == 0 {
|
|
return Err(PasskeyError::PasskeyNotFound);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn delete_passkey(&self, user_id: Uuid, passkey_id: &str) -> Result<(), PasskeyError> {
|
|
let mut conn = self.pool.get().map_err(|_| PasskeyError::DatabaseError)?;
|
|
|
|
let result = diesel::sql_query(
|
|
"DELETE FROM passkeys WHERE id = $1 AND user_id = $2",
|
|
)
|
|
.bind::<Text, _>(passkey_id)
|
|
.bind::<DieselUuid, _>(user_id)
|
|
.execute(&mut conn)
|
|
.map_err(|e| {
|
|
error!("Failed to delete passkey: {e}");
|
|
PasskeyError::DatabaseError
|
|
})?;
|
|
|
|
if result == 0 {
|
|
return Err(PasskeyError::PasskeyNotFound);
|
|
}
|
|
|
|
info!("Passkey {} deleted for user {}", passkey_id, user_id);
|
|
Ok(())
|
|
}
|
|
|
|
fn generate_challenge(&self) -> Result<Vec<u8>, PasskeyError> {
|
|
let mut challenge = vec![0u8; 32];
|
|
self.rng
|
|
.fill(&mut challenge)
|
|
.map_err(|_| PasskeyError::ChallengeGenerationFailed)?;
|
|
Ok(challenge)
|
|
}
|
|
|
|
async fn get_and_remove_challenge(&self, challenge_b64: &str) -> Result<PasskeyChallenge, PasskeyError> {
|
|
let mut challenges = self.challenges.write().await;
|
|
|
|
let challenge = challenges
|
|
.remove(challenge_b64)
|
|
.ok_or(PasskeyError::ChallengeNotFound)?;
|
|
|
|
let age = Utc::now() - challenge.created_at;
|
|
if age.num_seconds() > CHALLENGE_TIMEOUT_SECONDS {
|
|
return Err(PasskeyError::ChallengeExpired);
|
|
}
|
|
|
|
Ok(challenge)
|
|
}
|
|
|
|
fn verify_origin(&self, origin: &str) -> bool {
|
|
origin == self.rp_origin
|
|
}
|
|
|
|
fn parse_attestation_object(
|
|
&self,
|
|
attestation_object: &[u8],
|
|
) -> Result<(Vec<u8>, Vec<u8>, Option<Vec<u8>>), PasskeyError> {
|
|
let value: ciborium::Value = ciborium::from_reader(attestation_object)
|
|
.map_err(|_| PasskeyError::InvalidAttestationObject)?;
|
|
|
|
let map = value
|
|
.as_map()
|
|
.ok_or(PasskeyError::InvalidAttestationObject)?;
|
|
|
|
let auth_data = map
|
|
.iter()
|
|
.find(|(k, _)| k.as_text() == Some("authData"))
|
|
.and_then(|(_, v)| v.as_bytes())
|
|
.ok_or(PasskeyError::InvalidAttestationObject)?
|
|
.to_vec();
|
|
|
|
if auth_data.len() < 37 {
|
|
return Err(PasskeyError::InvalidAuthenticatorData);
|
|
}
|
|
|
|
let rp_id_hash = Sha256::digest(self.rp_id.as_bytes());
|
|
if &auth_data[..32] != rp_id_hash.as_slice() {
|
|
return Err(PasskeyError::RpIdMismatch);
|
|
}
|
|
|
|
let flags = auth_data[32];
|
|
let has_attested_credential = (flags & 0x40) != 0;
|
|
|
|
if !has_attested_credential {
|
|
return Err(PasskeyError::NoAttestedCredential);
|
|
}
|
|
|
|
let aaguid = auth_data[37..53].to_vec();
|
|
|
|
let cred_id_len = u16::from_be_bytes([auth_data[53], auth_data[54]]) as usize;
|
|
let cred_id_end = 55 + cred_id_len;
|
|
|
|
if auth_data.len() < cred_id_end {
|
|
return Err(PasskeyError::InvalidAuthenticatorData);
|
|
}
|
|
|
|
let public_key_cbor = &auth_data[cred_id_end..];
|
|
let public_key = public_key_cbor.to_vec();
|
|
|
|
Ok((auth_data, public_key, Some(aaguid)))
|
|
}
|
|
|
|
fn verify_signature(
|
|
&self,
|
|
public_key_cbor: &[u8],
|
|
data: &[u8],
|
|
signature: &[u8],
|
|
) -> Result<bool, PasskeyError> {
|
|
let pk_value: ciborium::Value = ciborium::from_reader(public_key_cbor)
|
|
.map_err(|_| PasskeyError::InvalidPublicKey)?;
|
|
|
|
let pk_map = pk_value
|
|
.as_map()
|
|
.ok_or(PasskeyError::InvalidPublicKey)?;
|
|
|
|
let kty = pk_map
|
|
.iter()
|
|
.find(|(k, _)| k.as_integer() == Some(1.into()))
|
|
.and_then(|(_, v)| v.as_integer())
|
|
.ok_or(PasskeyError::InvalidPublicKey)?;
|
|
|
|
let alg = pk_map
|
|
.iter()
|
|
.find(|(k, _)| k.as_integer() == Some(3.into()))
|
|
.and_then(|(_, v)| v.as_integer())
|
|
.ok_or(PasskeyError::InvalidPublicKey)?;
|
|
|
|
match (i128::from(kty), i128::from(alg)) {
|
|
(2, -7) => self.verify_es256_signature(pk_map, data, signature),
|
|
(3, -257) => self.verify_rs256_signature(pk_map, data, signature),
|
|
_ => Err(PasskeyError::UnsupportedAlgorithm),
|
|
}
|
|
}
|
|
|
|
fn verify_es256_signature(
|
|
&self,
|
|
pk_map: &[(ciborium::Value, ciborium::Value)],
|
|
data: &[u8],
|
|
signature: &[u8],
|
|
) -> Result<bool, PasskeyError> {
|
|
let x = pk_map
|
|
.iter()
|
|
.find(|(k, _)| k.as_integer() == Some((-2).into()))
|
|
.and_then(|(_, v)| v.as_bytes())
|
|
.ok_or(PasskeyError::InvalidPublicKey)?;
|
|
|
|
let y = pk_map
|
|
.iter()
|
|
.find(|(k, _)| k.as_integer() == Some((-3).into()))
|
|
.and_then(|(_, v)| v.as_bytes())
|
|
.ok_or(PasskeyError::InvalidPublicKey)?;
|
|
|
|
if x.len() != 32 || y.len() != 32 {
|
|
return Err(PasskeyError::InvalidPublicKey);
|
|
}
|
|
|
|
let mut public_key_bytes = vec![0x04];
|
|
public_key_bytes.extend_from_slice(x);
|
|
public_key_bytes.extend_from_slice(y);
|
|
|
|
let public_key = ring::signature::UnparsedPublicKey::new(
|
|
&ring::signature::ECDSA_P256_SHA256_ASN1,
|
|
&public_key_bytes,
|
|
);
|
|
|
|
match public_key.verify(data, signature) {
|
|
Ok(()) => Ok(true),
|
|
Err(_) => Ok(false),
|
|
}
|
|
}
|
|
|
|
fn verify_rs256_signature(
|
|
&self,
|
|
pk_map: &[(ciborium::Value, ciborium::Value)],
|
|
data: &[u8],
|
|
signature: &[u8],
|
|
) -> Result<bool, PasskeyError> {
|
|
let n = pk_map
|
|
.iter()
|
|
.find(|(k, _)| k.as_integer() == Some((-1).into()))
|
|
.and_then(|(_, v)| v.as_bytes())
|
|
.ok_or(PasskeyError::InvalidPublicKey)?;
|
|
|
|
let e = pk_map
|
|
.iter()
|
|
.find(|(k, _)| k.as_integer() == Some((-2).into()))
|
|
.and_then(|(_, v)| v.as_bytes())
|
|
.ok_or(PasskeyError::InvalidPublicKey)?;
|
|
|
|
let public_key = ring::signature::RsaPublicKeyComponents { n, e };
|
|
|
|
match public_key.verify(
|
|
&ring::signature::RSA_PKCS1_2048_8192_SHA256,
|
|
data,
|
|
signature,
|
|
) {
|
|
Ok(()) => Ok(true),
|
|
Err(_) => Ok(false),
|
|
}
|
|
}
|
|
|
|
fn store_passkey(
|
|
&self,
|
|
user_id: Uuid,
|
|
credential_id: &[u8],
|
|
public_key: &[u8],
|
|
counter: u32,
|
|
name: &str,
|
|
aaguid: Option<&[u8]>,
|
|
transports: &str,
|
|
) -> Result<(), PasskeyError> {
|
|
let mut conn = self.pool.get().map_err(|_| PasskeyError::DatabaseError)?;
|
|
|
|
let id = Uuid::new_v4().to_string();
|
|
|
|
diesel::sql_query(
|
|
r#"
|
|
INSERT INTO passkeys (id, user_id, credential_id, public_key, counter, name, aaguid, transports, created_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())
|
|
"#,
|
|
)
|
|
.bind::<Text, _>(&id)
|
|
.bind::<DieselUuid, _>(user_id)
|
|
.bind::<Bytea, _>(credential_id)
|
|
.bind::<Bytea, _>(public_key)
|
|
.bind::<BigInt, _>(counter as i64)
|
|
.bind::<Text, _>(name)
|
|
.bind::<Nullable<Bytea>, _>(aaguid)
|
|
.bind::<Text, _>(transports)
|
|
.execute(&mut conn)
|
|
.map_err(|e| {
|
|
error!("Failed to store passkey: {e}");
|
|
PasskeyError::DatabaseError
|
|
})?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn get_passkey_by_credential_id(
|
|
&self,
|
|
credential_id: &[u8],
|
|
) -> Result<PasskeyCredential, PasskeyError> {
|
|
let mut conn = self.pool.get().map_err(|_| PasskeyError::DatabaseError)?;
|
|
|
|
let rows: Vec<PasskeyRow> = diesel::sql_query(
|
|
"SELECT id, user_id, credential_id, public_key, counter, name, created_at, last_used_at, aaguid, transports FROM passkeys WHERE credential_id = $1",
|
|
)
|
|
.bind::<Bytea, _>(credential_id)
|
|
.load(&mut conn)
|
|
.map_err(|e| {
|
|
error!("Failed to query passkey: {e}");
|
|
PasskeyError::DatabaseError
|
|
})?;
|
|
|
|
let row = rows.into_iter().next().ok_or(PasskeyError::PasskeyNotFound)?;
|
|
|
|
Ok(PasskeyCredential {
|
|
id: row.id,
|
|
user_id: row.user_id,
|
|
credential_id: row.credential_id,
|
|
public_key: row.public_key,
|
|
counter: row.counter as u32,
|
|
name: row.name,
|
|
created_at: row.created_at,
|
|
last_used_at: row.last_used_at,
|
|
aaguid: row.aaguid,
|
|
transports: row
|
|
.transports
|
|
.map(|t| t.split(',').map(String::from).collect())
|
|
.unwrap_or_default(),
|
|
})
|
|
}
|
|
|
|
fn get_passkeys_by_username(
|
|
&self,
|
|
username: &str,
|
|
) -> Result<Vec<PasskeyCredential>, PasskeyError> {
|
|
let mut conn = self.pool.get().map_err(|_| PasskeyError::DatabaseError)?;
|
|
|
|
let rows: Vec<PasskeyRow> = diesel::sql_query(
|
|
r#"
|
|
SELECT p.id, p.user_id, p.credential_id, p.public_key, p.counter, p.name, p.created_at, p.last_used_at, p.aaguid, p.transports
|
|
FROM passkeys p
|
|
JOIN users u ON u.id = p.user_id
|
|
WHERE u.username = $1 OR u.email = $1
|
|
ORDER BY p.created_at DESC
|
|
"#,
|
|
)
|
|
.bind::<Text, _>(username)
|
|
.load(&mut conn)
|
|
.map_err(|e| {
|
|
error!("Failed to query passkeys by username: {e}");
|
|
PasskeyError::DatabaseError
|
|
})?;
|
|
|
|
let credentials = rows
|
|
.into_iter()
|
|
.map(|row| PasskeyCredential {
|
|
id: row.id,
|
|
user_id: row.user_id,
|
|
credential_id: row.credential_id,
|
|
public_key: row.public_key,
|
|
counter: row.counter as u32,
|
|
name: row.name,
|
|
created_at: row.created_at,
|
|
last_used_at: row.last_used_at,
|
|
aaguid: row.aaguid,
|
|
transports: row
|
|
.transports
|
|
.map(|t| t.split(',').map(String::from).collect())
|
|
.unwrap_or_default(),
|
|
})
|
|
.collect();
|
|
|
|
Ok(credentials)
|
|
}
|
|
|
|
fn update_passkey_counter(
|
|
&self,
|
|
credential_id: &[u8],
|
|
new_counter: u32,
|
|
) -> Result<(), PasskeyError> {
|
|
let mut conn = self.pool.get().map_err(|_| PasskeyError::DatabaseError)?;
|
|
|
|
diesel::sql_query(
|
|
"UPDATE passkeys SET counter = $1, last_used_at = NOW() WHERE credential_id = $2",
|
|
)
|
|
.bind::<BigInt, _>(new_counter as i64)
|
|
.bind::<Bytea, _>(credential_id)
|
|
.execute(&mut conn)
|
|
.map_err(|e| {
|
|
error!("Failed to update passkey counter: {e}");
|
|
PasskeyError::DatabaseError
|
|
})?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn cleanup_expired_challenges(&self) {
|
|
let mut challenges = self.challenges.write().await;
|
|
let cutoff = Utc::now() - Duration::seconds(CHALLENGE_TIMEOUT_SECONDS);
|
|
challenges.retain(|_, c| c.created_at > cutoff);
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct ClientData {
|
|
#[serde(rename = "type")]
|
|
r#type: String,
|
|
challenge: String,
|
|
origin: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum PasskeyError {
|
|
DatabaseError,
|
|
ChallengeGenerationFailed,
|
|
ChallengeStorageError,
|
|
ChallengeNotFound,
|
|
ChallengeExpired,
|
|
InvalidClientData,
|
|
InvalidCeremonyType,
|
|
InvalidOrigin,
|
|
InvalidChallenge,
|
|
InvalidAttestationObject,
|
|
InvalidAuthenticatorData,
|
|
InvalidCredentialId,
|
|
InvalidSignature,
|
|
InvalidPublicKey,
|
|
MissingUserId,
|
|
NoAttestedCredential,
|
|
RpIdMismatch,
|
|
UserNotPresent,
|
|
CounterMismatch,
|
|
SignatureVerificationFailed,
|
|
UnsupportedAlgorithm,
|
|
PasskeyNotFound,
|
|
}
|
|
|
|
impl std::fmt::Display for PasskeyError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::DatabaseError => write!(f, "Database error"),
|
|
Self::ChallengeGenerationFailed => write!(f, "Challenge generation failed"),
|
|
Self::ChallengeStorageError => write!(f, "Challenge storage error"),
|
|
Self::ChallengeNotFound => write!(f, "Challenge not found"),
|
|
Self::ChallengeExpired => write!(f, "Challenge expired"),
|
|
Self::InvalidClientData => write!(f, "Invalid client data"),
|
|
Self::InvalidCeremonyType => write!(f, "Invalid ceremony type"),
|
|
Self::InvalidOrigin => write!(f, "Invalid origin"),
|
|
Self::InvalidChallenge => write!(f, "Invalid challenge"),
|
|
Self::InvalidAttestationObject => write!(f, "Invalid attestation object"),
|
|
Self::InvalidAuthenticatorData => write!(f, "Invalid authenticator data"),
|
|
Self::InvalidCredentialId => write!(f, "Invalid credential ID"),
|
|
Self::InvalidSignature => write!(f, "Invalid signature"),
|
|
Self::InvalidPublicKey => write!(f, "Invalid public key"),
|
|
Self::MissingUserId => write!(f, "Missing user ID"),
|
|
Self::NoAttestedCredential => write!(f, "No attested credential"),
|
|
Self::RpIdMismatch => write!(f, "RP ID mismatch"),
|
|
Self::UserNotPresent => write!(f, "User not present"),
|
|
Self::CounterMismatch => write!(f, "Counter mismatch - possible cloning"),
|
|
Self::SignatureVerificationFailed => write!(f, "Signature verification failed"),
|
|
Self::UnsupportedAlgorithm => write!(f, "Unsupported algorithm"),
|
|
Self::PasskeyNotFound => write!(f, "Passkey not found"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for PasskeyError {}
|
|
|
|
impl IntoResponse for PasskeyError {
|
|
fn into_response(self) -> axum::response::Response {
|
|
let status = match self {
|
|
Self::PasskeyNotFound => StatusCode::NOT_FOUND,
|
|
Self::ChallengeExpired | Self::ChallengeNotFound => StatusCode::GONE,
|
|
Self::InvalidOrigin | Self::RpIdMismatch => StatusCode::FORBIDDEN,
|
|
Self::CounterMismatch | Self::SignatureVerificationFailed => StatusCode::UNAUTHORIZED,
|
|
_ => StatusCode::BAD_REQUEST,
|
|
};
|
|
(status, self.to_string()).into_response()
|
|
}
|
|
}
|
|
|
|
pub fn create_passkey_tables_migration() -> &'static str {
|
|
r#"
|
|
CREATE TABLE IF NOT EXISTS passkeys (
|
|
id TEXT PRIMARY KEY,
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
credential_id BYTEA NOT NULL UNIQUE,
|
|
public_key BYTEA NOT NULL,
|
|
counter BIGINT NOT NULL DEFAULT 0,
|
|
name TEXT NOT NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
last_used_at TIMESTAMPTZ,
|
|
aaguid BYTEA,
|
|
transports TEXT
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_passkeys_user_id ON passkeys(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_passkeys_credential_id ON passkeys(credential_id);
|
|
"#
|
|
}
|
|
|
|
pub fn passkey_routes(_state: Arc<AppState>) -> Router<Arc<AppState>> {
|
|
Router::new()
|
|
.route("/registration/options", post(registration_options_handler))
|
|
.route("/registration/verify", post(registration_verify_handler))
|
|
.route("/authentication/options", post(authentication_options_handler))
|
|
.route("/authentication/verify", post(authentication_verify_handler))
|
|
.route("/list/:user_id", get(list_passkeys_handler))
|
|
.route("/:user_id/:passkey_id", delete(delete_passkey_handler))
|
|
.route("/:user_id/:passkey_id/rename", post(rename_passkey_handler))
|
|
// Password fallback routes
|
|
.route("/fallback/authenticate", post(password_fallback_handler))
|
|
.route("/fallback/check/:username", get(check_fallback_available_handler))
|
|
.route("/fallback/config", get(get_fallback_config_handler))
|
|
}
|
|
|
|
async fn password_fallback_handler(
|
|
State(state): State<Arc<AppState>>,
|
|
Json(request): Json<PasswordFallbackRequest>,
|
|
) -> impl IntoResponse {
|
|
let service = match get_passkey_service(&state) {
|
|
Ok(s) => s,
|
|
Err(e) => return e.into_response(),
|
|
};
|
|
match service.authenticate_with_password_fallback(&request).await {
|
|
Ok(response) => Json(response).into_response(),
|
|
Err(e) => e.into_response(),
|
|
}
|
|
}
|
|
|
|
async fn check_fallback_available_handler(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(username): Path<String>,
|
|
) -> impl IntoResponse {
|
|
let service = match get_passkey_service(&state) {
|
|
Ok(s) => s,
|
|
Err(e) => return e.into_response(),
|
|
};
|
|
|
|
#[derive(Serialize)]
|
|
struct FallbackAvailableResponse {
|
|
available: bool,
|
|
has_passkeys: bool,
|
|
reason: Option<String>,
|
|
}
|
|
|
|
match service.should_offer_password_fallback(&username) {
|
|
Ok(available) => {
|
|
let has_passkeys = service.user_has_passkeys(&username).unwrap_or(false);
|
|
Json(FallbackAvailableResponse {
|
|
available,
|
|
has_passkeys,
|
|
reason: if !available {
|
|
Some("Password fallback is disabled".to_string())
|
|
} else {
|
|
None
|
|
},
|
|
}).into_response()
|
|
}
|
|
Err(e) => e.into_response(),
|
|
}
|
|
}
|
|
|
|
async fn get_fallback_config_handler(
|
|
State(state): State<Arc<AppState>>,
|
|
) -> impl IntoResponse {
|
|
let service = match get_passkey_service(&state) {
|
|
Ok(s) => s,
|
|
Err(e) => return e.into_response(),
|
|
};
|
|
let config = service.get_fallback_config();
|
|
|
|
#[derive(Serialize)]
|
|
struct PublicFallbackConfig {
|
|
enabled: bool,
|
|
prompt_passkey_setup: bool,
|
|
}
|
|
|
|
Json(PublicFallbackConfig {
|
|
enabled: config.enabled,
|
|
prompt_passkey_setup: config.prompt_passkey_setup,
|
|
}).into_response()
|
|
}
|
|
|
|
async fn registration_options_handler(
|
|
State(state): State<Arc<AppState>>,
|
|
Json(request): Json<RegistrationOptionsRequest>,
|
|
) -> Result<Json<RegistrationOptions>, PasskeyError> {
|
|
let service = get_passkey_service(&state)?;
|
|
let options = service.generate_registration_options(request).await?;
|
|
Ok(Json(options))
|
|
}
|
|
|
|
async fn registration_verify_handler(
|
|
State(state): State<Arc<AppState>>,
|
|
Json(request): Json<RegistrationVerifyRequest>,
|
|
) -> Result<Json<RegistrationResult>, PasskeyError> {
|
|
let service = get_passkey_service(&state)?;
|
|
let result = service.verify_registration(request.response, request.name).await?;
|
|
Ok(Json(result))
|
|
}
|
|
|
|
async fn authentication_options_handler(
|
|
State(state): State<Arc<AppState>>,
|
|
Json(request): Json<AuthenticationOptionsRequest>,
|
|
) -> Result<Json<AuthenticationOptions>, PasskeyError> {
|
|
let service = get_passkey_service(&state)?;
|
|
let options = service.generate_authentication_options(request).await?;
|
|
Ok(Json(options))
|
|
}
|
|
|
|
async fn authentication_verify_handler(
|
|
State(state): State<Arc<AppState>>,
|
|
Json(response): Json<AuthenticationResponse>,
|
|
) -> Result<Json<VerificationResult>, PasskeyError> {
|
|
let service = get_passkey_service(&state)?;
|
|
let result = service.verify_authentication(response).await?;
|
|
Ok(Json(result))
|
|
}
|
|
|
|
async fn list_passkeys_handler(
|
|
State(state): State<Arc<AppState>>,
|
|
Path(user_id): Path<Uuid>,
|
|
) -> Result<Json<Vec<PasskeyInfo>>, PasskeyError> {
|
|
let service = get_passkey_service(&state)?;
|
|
let passkeys = service.list_passkeys(user_id)?;
|
|
Ok(Json(passkeys))
|
|
}
|
|
|
|
async fn delete_passkey_handler(
|
|
State(state): State<Arc<AppState>>,
|
|
Path((user_id, passkey_id)): Path<(Uuid, String)>,
|
|
) -> Result<StatusCode, PasskeyError> {
|
|
let service = get_passkey_service(&state)?;
|
|
service.delete_passkey(user_id, &passkey_id)?;
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
async fn rename_passkey_handler(
|
|
State(state): State<Arc<AppState>>,
|
|
Path((user_id, passkey_id)): Path<(Uuid, String)>,
|
|
Json(request): Json<RenamePasskeyRequest>,
|
|
) -> Result<StatusCode, PasskeyError> {
|
|
let service = get_passkey_service(&state)?;
|
|
service.rename_passkey(user_id, &passkey_id, &request.name)?;
|
|
Ok(StatusCode::OK)
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct RegistrationVerifyRequest {
|
|
response: RegistrationResponse,
|
|
name: Option<String>,
|
|
}
|
|
|
|
fn get_passkey_service(state: &AppState) -> Result<PasskeyService, PasskeyError> {
|
|
let pool = state.conn.clone();
|
|
let rp_id = std::env::var("PASSKEY_RP_ID").unwrap_or_else(|_| "localhost".to_string());
|
|
let rp_name = std::env::var("PASSKEY_RP_NAME").unwrap_or_else(|_| "General Bots".to_string());
|
|
let rp_origin = std::env::var("PASSKEY_RP_ORIGIN").unwrap_or_else(|_| "http://localhost:8081".to_string());
|
|
|
|
Ok(PasskeyService::new(pool, rp_id, rp_name, rp_origin))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_fallback_config_default() {
|
|
let config = FallbackConfig::default();
|
|
assert!(config.enabled);
|
|
assert!(!config.require_additional_verification);
|
|
assert_eq!(config.max_fallback_attempts, 5);
|
|
assert_eq!(config.lockout_duration_seconds, 900);
|
|
assert!(config.prompt_passkey_setup);
|
|
}
|
|
|
|
#[test]
|
|
fn test_password_fallback_request_serialization() {
|
|
let request = PasswordFallbackRequest {
|
|
username: "testuser".to_string(),
|
|
password: "testpass".to_string(),
|
|
};
|
|
let json = serde_json::to_string(&request).unwrap();
|
|
assert!(json.contains("testuser"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_password_fallback_response_structure() {
|
|
let response = PasswordFallbackResponse {
|
|
success: true,
|
|
user_id: Some(Uuid::new_v4()),
|
|
token: Some("test-token".to_string()),
|
|
error: None,
|
|
passkey_available: true,
|
|
};
|
|
assert!(response.success);
|
|
assert!(response.user_id.is_some());
|
|
assert!(response.passkey_available);
|
|
}
|
|
|
|
#[test]
|
|
fn test_verification_result_with_fallback() {
|
|
let result = VerificationResult {
|
|
success: true,
|
|
user_id: Some(Uuid::new_v4()),
|
|
credential_id: None,
|
|
error: None,
|
|
used_fallback: true,
|
|
};
|
|
assert!(result.used_fallback);
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
fn test_passkey_error_display() {
|
|
assert_eq!(PasskeyError::DatabaseError.to_string(), "Database error");
|
|
assert_eq!(PasskeyError::ChallengeExpired.to_string(), "Challenge expired");
|
|
assert_eq!(PasskeyError::PasskeyNotFound.to_string(), "Passkey not found");
|
|
}
|
|
|
|
#[test]
|
|
fn test_challenge_operation_serialization() {
|
|
let op = ChallengeOperation::Registration;
|
|
let json = serde_json::to_string(&op).unwrap_or_default();
|
|
assert!(json.contains("registration"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_registration_options_structure() {
|
|
let options = RegistrationOptions {
|
|
challenge: "test_challenge".to_string(),
|
|
rp: RelyingParty {
|
|
id: "example.com".to_string(),
|
|
name: "Example".to_string(),
|
|
},
|
|
user: UserEntity {
|
|
id: "user_id".to_string(),
|
|
name: "user@example.com".to_string(),
|
|
display_name: "User".to_string(),
|
|
},
|
|
pub_key_cred_params: vec![
|
|
PubKeyCredParam {
|
|
cred_type: "public-key".to_string(),
|
|
alg: -7,
|
|
},
|
|
],
|
|
timeout: 60000,
|
|
attestation: "none".to_string(),
|
|
authenticator_selection: AuthenticatorSelection {
|
|
authenticator_attachment: None,
|
|
resident_key: "preferred".to_string(),
|
|
require_resident_key: false,
|
|
user_verification: "preferred".to_string(),
|
|
},
|
|
exclude_credentials: vec![],
|
|
};
|
|
|
|
assert_eq!(options.rp.id, "example.com");
|
|
assert_eq!(options.timeout, 60000);
|
|
}
|
|
|
|
#[test]
|
|
fn test_passkey_info_creation() {
|
|
let info = PasskeyInfo {
|
|
id: "pk_123".to_string(),
|
|
name: "My Passkey".to_string(),
|
|
created_at: Utc::now(),
|
|
last_used_at: None,
|
|
};
|
|
|
|
assert_eq!(info.id, "pk_123");
|
|
assert_eq!(info.name, "My Passkey");
|
|
assert!(info.last_used_at.is_none());
|
|
}
|
|
}
|