Deux reformatages sans changement de comportement (device.rs, web-server/lib.rs), laissés de côté hors périmètre au fil de plusieurs tickets précédents. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
481 lines
14 KiB
Rust
481 lines
14 KiB
Rust
//! Paired-device session use cases.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use async_trait::async_trait;
|
|
use domain::events::DomainEvent;
|
|
use domain::ports::{Clock, DeviceSessionStore, EventBus, IdGenerator, StoreError};
|
|
use domain::{AuthenticatedDevice, DeviceId, DeviceName, PairedDevice, SessionTokenHash};
|
|
use subtle::ConstantTimeEq;
|
|
|
|
use crate::error::AppError;
|
|
|
|
const LAST_SEEN_TOUCH_THROTTLE_MS: u64 = 5 * 60 * 1000;
|
|
|
|
/// Input for [`PairDevice`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct PairDeviceInput {
|
|
/// Name supplied by the new device.
|
|
pub name: String,
|
|
/// Hash of the freshly issued session token.
|
|
pub session_token_hash: SessionTokenHash,
|
|
}
|
|
|
|
/// Output for [`PairDevice`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct PairDeviceOutput {
|
|
/// Created paired device.
|
|
pub device: PairedDevice,
|
|
}
|
|
|
|
/// Validates a pairing request and persists the new paired device.
|
|
pub struct PairDevice {
|
|
store: Arc<dyn DeviceSessionStore>,
|
|
ids: Arc<dyn IdGenerator>,
|
|
clock: Arc<dyn Clock>,
|
|
}
|
|
|
|
impl PairDevice {
|
|
/// Builds the use case from injected ports.
|
|
#[must_use]
|
|
pub fn new(
|
|
store: Arc<dyn DeviceSessionStore>,
|
|
ids: Arc<dyn IdGenerator>,
|
|
clock: Arc<dyn Clock>,
|
|
) -> Self {
|
|
Self { store, ids, clock }
|
|
}
|
|
|
|
/// Executes device pairing.
|
|
///
|
|
/// # Errors
|
|
/// Returns [`AppError::Invalid`] for an invalid name and [`AppError::Store`] for
|
|
/// persistence failures.
|
|
pub async fn execute(&self, input: PairDeviceInput) -> Result<PairDeviceOutput, AppError> {
|
|
let now = self.clock.now_millis().max(0) as u64;
|
|
let device = PairedDevice {
|
|
device_id: DeviceId::from_uuid(self.ids.new_uuid()),
|
|
name: DeviceName::new(input.name).map_err(|err| AppError::Invalid(err.to_string()))?,
|
|
paired_at_ms: now,
|
|
last_seen_at_ms: now,
|
|
session_token_hash: input.session_token_hash,
|
|
};
|
|
let mut devices = self.store.load_devices().await?;
|
|
devices.push(device.clone());
|
|
self.store.save_devices(&devices).await?;
|
|
Ok(PairDeviceOutput { device })
|
|
}
|
|
}
|
|
|
|
/// Rate-limit key for pairing attempts.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
pub struct RateLimitKey {
|
|
/// Resolved origin identity, typically the peer IP after transport-layer proxy handling.
|
|
pub origin: String,
|
|
/// Logical route being limited.
|
|
pub route: String,
|
|
}
|
|
|
|
/// Pairing-attempt limiter port.
|
|
#[async_trait]
|
|
pub trait PairAttemptLimiter: Send + Sync {
|
|
/// Returns whether a new failed pairing attempt may be processed.
|
|
async fn check(&self, key: RateLimitKey) -> Result<PairAttemptDecision, StoreError>;
|
|
|
|
/// Records one failed pairing attempt for the key and the global bucket.
|
|
async fn record_failure(&self, key: RateLimitKey) -> Result<(), StoreError>;
|
|
}
|
|
|
|
/// Result of a rate-limit check.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum PairAttemptDecision {
|
|
/// Attempt can proceed.
|
|
Allowed,
|
|
/// Attempt is blocked by one of the buckets.
|
|
RateLimited,
|
|
}
|
|
|
|
/// Input for [`AuthenticateSession`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct AuthenticateSessionInput {
|
|
/// Raw token bytes decoded from the session cookie.
|
|
pub token_bytes: Vec<u8>,
|
|
}
|
|
|
|
/// Authenticates a persisted device session.
|
|
pub struct AuthenticateSession {
|
|
store: Arc<dyn DeviceSessionStore>,
|
|
}
|
|
|
|
impl AuthenticateSession {
|
|
/// Builds the use case from injected ports.
|
|
#[must_use]
|
|
pub fn new(store: Arc<dyn DeviceSessionStore>) -> Self {
|
|
Self { store }
|
|
}
|
|
|
|
/// Returns the authenticated device, including its token hash, when valid.
|
|
///
|
|
/// # Errors
|
|
/// Returns [`AppError::Store`] for persistence failures.
|
|
pub async fn execute(
|
|
&self,
|
|
input: AuthenticateSessionInput,
|
|
) -> Result<Option<AuthenticatedDevice>, AppError> {
|
|
Ok(self.store.authenticate_token(&input.token_bytes).await?)
|
|
}
|
|
}
|
|
|
|
/// Input for [`TouchDevice`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct TouchDeviceInput {
|
|
/// Device to mark as seen.
|
|
pub device_id: DeviceId,
|
|
}
|
|
|
|
/// Output for [`TouchDevice`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct TouchDeviceOutput {
|
|
/// Whether the store was rewritten.
|
|
pub updated: bool,
|
|
}
|
|
|
|
/// Device row exposed to presentation layers.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct DeviceView {
|
|
/// Persistent device id.
|
|
pub device_id: DeviceId,
|
|
/// User-facing name.
|
|
pub name: String,
|
|
/// Pairing timestamp as epoch milliseconds.
|
|
pub paired_at_ms: u64,
|
|
/// Last successful authenticated access timestamp as epoch milliseconds.
|
|
pub last_seen_at_ms: u64,
|
|
/// Whether this row is the authenticated current device.
|
|
pub is_current_device: bool,
|
|
}
|
|
|
|
/// Input for [`ListDevices`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ListDevicesInput {
|
|
/// Current device id, when the driving adapter has an authenticated device.
|
|
pub current_device_id: Option<DeviceId>,
|
|
}
|
|
|
|
/// Output for [`ListDevices`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ListDevicesOutput {
|
|
/// Paired devices.
|
|
pub devices: Vec<DeviceView>,
|
|
}
|
|
|
|
/// Lists paired devices without exposing transport-sensitive fields.
|
|
pub struct ListDevices {
|
|
store: Arc<dyn DeviceSessionStore>,
|
|
}
|
|
|
|
impl ListDevices {
|
|
/// Builds the use case from injected ports.
|
|
#[must_use]
|
|
pub fn new(store: Arc<dyn DeviceSessionStore>) -> Self {
|
|
Self { store }
|
|
}
|
|
|
|
/// Executes the listing.
|
|
///
|
|
/// # Errors
|
|
/// Returns [`AppError::Store`] for persistence failures.
|
|
pub async fn execute(&self, input: ListDevicesInput) -> Result<ListDevicesOutput, AppError> {
|
|
let devices = self
|
|
.store
|
|
.load_devices()
|
|
.await?
|
|
.into_iter()
|
|
.map(|device| DeviceView {
|
|
device_id: device.device_id,
|
|
name: device.name.as_str().to_owned(),
|
|
paired_at_ms: device.paired_at_ms,
|
|
last_seen_at_ms: device.last_seen_at_ms,
|
|
is_current_device: Some(device.device_id) == input.current_device_id,
|
|
})
|
|
.collect();
|
|
Ok(ListDevicesOutput { devices })
|
|
}
|
|
}
|
|
|
|
/// Input for [`RenameDevice`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct RenameDeviceInput {
|
|
/// Device to rename.
|
|
pub device_id: DeviceId,
|
|
/// New user-facing name.
|
|
pub name: String,
|
|
}
|
|
|
|
/// Renames a paired device.
|
|
pub struct RenameDevice {
|
|
store: Arc<dyn DeviceSessionStore>,
|
|
}
|
|
|
|
impl RenameDevice {
|
|
/// Builds the use case from injected ports.
|
|
#[must_use]
|
|
pub fn new(store: Arc<dyn DeviceSessionStore>) -> Self {
|
|
Self { store }
|
|
}
|
|
|
|
/// Executes the rename.
|
|
///
|
|
/// # Errors
|
|
/// Returns [`AppError::Invalid`] for an invalid name, [`AppError::NotFound`]
|
|
/// when the device does not exist, and [`AppError::Store`] for persistence failures.
|
|
pub async fn execute(&self, input: RenameDeviceInput) -> Result<(), AppError> {
|
|
let new_name =
|
|
DeviceName::new(input.name).map_err(|err| AppError::Invalid(err.to_string()))?;
|
|
let mut devices = self.store.load_devices().await?;
|
|
let Some(device) = devices
|
|
.iter_mut()
|
|
.find(|device| device.device_id == input.device_id)
|
|
else {
|
|
return Err(AppError::NotFound("device not found".to_owned()));
|
|
};
|
|
device.name = new_name;
|
|
self.store.save_devices(&devices).await?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Input for [`RevokeDevice`].
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct RevokeDeviceInput {
|
|
/// Device to revoke.
|
|
pub device_id: DeviceId,
|
|
}
|
|
|
|
/// Revokes one paired device and publishes a revocation event after persistence.
|
|
pub struct RevokeDevice {
|
|
store: Arc<dyn DeviceSessionStore>,
|
|
events: Arc<dyn EventBus>,
|
|
}
|
|
|
|
impl RevokeDevice {
|
|
/// Builds the use case from injected ports.
|
|
#[must_use]
|
|
pub fn new(store: Arc<dyn DeviceSessionStore>, events: Arc<dyn EventBus>) -> Self {
|
|
Self { store, events }
|
|
}
|
|
|
|
/// Executes the revocation.
|
|
///
|
|
/// # Errors
|
|
/// Returns [`AppError::NotFound`] when the device does not exist and
|
|
/// [`AppError::Store`] for persistence failures.
|
|
pub async fn execute(&self, input: RevokeDeviceInput) -> Result<(), AppError> {
|
|
if !self.store.remove_device(input.device_id).await? {
|
|
return Err(AppError::NotFound("device not found".to_owned()));
|
|
}
|
|
self.events.publish(DomainEvent::DeviceRevoked {
|
|
device_id: input.device_id,
|
|
});
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Revokes every paired device and publishes a global revocation event.
|
|
pub struct RevokeAllDevices {
|
|
store: Arc<dyn DeviceSessionStore>,
|
|
events: Arc<dyn EventBus>,
|
|
}
|
|
|
|
impl RevokeAllDevices {
|
|
/// Builds the use case from injected ports.
|
|
#[must_use]
|
|
pub fn new(store: Arc<dyn DeviceSessionStore>, events: Arc<dyn EventBus>) -> Self {
|
|
Self { store, events }
|
|
}
|
|
|
|
/// Executes global revocation.
|
|
///
|
|
/// # Errors
|
|
/// Returns [`AppError::Store`] for persistence failures.
|
|
pub async fn execute(&self) -> Result<(), AppError> {
|
|
self.store.save_devices(&[]).await?;
|
|
self.events.publish(DomainEvent::AllDevicesRevoked);
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Updates `lastSeenAtMs`, throttled to avoid rewriting the JSON on every request.
|
|
pub struct TouchDevice {
|
|
store: Arc<dyn DeviceSessionStore>,
|
|
clock: Arc<dyn Clock>,
|
|
}
|
|
|
|
impl TouchDevice {
|
|
/// Builds the use case from injected ports.
|
|
#[must_use]
|
|
pub fn new(store: Arc<dyn DeviceSessionStore>, clock: Arc<dyn Clock>) -> Self {
|
|
Self { store, clock }
|
|
}
|
|
|
|
/// Executes the touch.
|
|
///
|
|
/// # Errors
|
|
/// Returns [`AppError::Store`] for persistence failures.
|
|
pub async fn execute(&self, input: TouchDeviceInput) -> Result<TouchDeviceOutput, AppError> {
|
|
let now = self.clock.now_millis().max(0) as u64;
|
|
let mut devices = self.store.load_devices().await?;
|
|
let Some(device) = devices
|
|
.iter_mut()
|
|
.find(|device| device.device_id == input.device_id)
|
|
else {
|
|
return Ok(TouchDeviceOutput { updated: false });
|
|
};
|
|
|
|
if now.saturating_sub(device.last_seen_at_ms) < LAST_SEEN_TOUCH_THROTTLE_MS {
|
|
return Ok(TouchDeviceOutput { updated: false });
|
|
}
|
|
|
|
device.last_seen_at_ms = now;
|
|
self.store.save_devices(&devices).await?;
|
|
Ok(TouchDeviceOutput { updated: true })
|
|
}
|
|
}
|
|
|
|
/// Normalizes user-entered pairing codes server-side.
|
|
#[must_use]
|
|
pub fn normalize_pairing_code(code: &str) -> String {
|
|
code.chars()
|
|
.filter(|ch| !ch.is_whitespace() && *ch != '-')
|
|
.flat_map(char::to_uppercase)
|
|
.collect()
|
|
}
|
|
|
|
/// Compares pairing codes after normalization without early-exit byte comparison.
|
|
#[must_use]
|
|
pub fn pairing_codes_equal(candidate: &str, expected: &str) -> bool {
|
|
let candidate = normalize_pairing_code(candidate);
|
|
let expected = normalize_pairing_code(expected);
|
|
let bytes_equal: bool = candidate.as_bytes().ct_eq(expected.as_bytes()).into();
|
|
bytes_equal && candidate.len() == expected.len()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use async_trait::async_trait;
|
|
use domain::ports::StoreError;
|
|
use std::sync::Mutex;
|
|
use uuid::Uuid;
|
|
|
|
#[derive(Default)]
|
|
struct FakeStore {
|
|
devices: Mutex<Vec<PairedDevice>>,
|
|
saves: Mutex<usize>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl DeviceSessionStore for FakeStore {
|
|
async fn load_devices(&self) -> Result<Vec<PairedDevice>, StoreError> {
|
|
Ok(self.devices.lock().unwrap().clone())
|
|
}
|
|
|
|
async fn save_devices(&self, devices: &[PairedDevice]) -> Result<(), StoreError> {
|
|
*self.devices.lock().unwrap() = devices.to_vec();
|
|
*self.saves.lock().unwrap() += 1;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
struct FixedClock(i64);
|
|
|
|
impl domain::ports::Clock for FixedClock {
|
|
fn now_millis(&self) -> i64 {
|
|
self.0
|
|
}
|
|
}
|
|
|
|
struct FixedIds;
|
|
|
|
impl domain::ports::IdGenerator for FixedIds {
|
|
fn new_uuid(&self) -> Uuid {
|
|
Uuid::from_u128(7)
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn pairing_code_normalization_removes_spaces_hyphens_and_uppercases() {
|
|
assert_eq!(normalize_pairing_code(" ab12-cd34 "), "AB12CD34");
|
|
assert!(pairing_codes_equal(" ab12-cd34 ", "AB12CD34"));
|
|
assert!(!pairing_codes_equal("AB12CD35", "AB12CD34"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pair_device_validates_name_and_persists_device() {
|
|
let store = Arc::new(FakeStore::default());
|
|
let use_case = PairDevice::new(
|
|
Arc::clone(&store) as Arc<dyn DeviceSessionStore>,
|
|
Arc::new(FixedIds),
|
|
Arc::new(FixedClock(1234)),
|
|
);
|
|
|
|
let output = use_case
|
|
.execute(PairDeviceInput {
|
|
name: "Phone".to_owned(),
|
|
session_token_hash: SessionTokenHash::from_token_bytes(&[1; 32]),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(output.device.name.as_str(), "Phone");
|
|
assert_eq!(store.load_devices().await.unwrap().len(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pair_device_rejects_empty_name() {
|
|
let use_case = PairDevice::new(
|
|
Arc::new(FakeStore::default()),
|
|
Arc::new(FixedIds),
|
|
Arc::new(FixedClock(1234)),
|
|
);
|
|
|
|
let err = use_case
|
|
.execute(PairDeviceInput {
|
|
name: " ".to_owned(),
|
|
session_token_hash: SessionTokenHash::from_token_bytes(&[1; 32]),
|
|
})
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert_eq!(err.code(), "INVALID");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn touch_device_is_throttled_to_five_minutes() {
|
|
let device = PairedDevice {
|
|
device_id: DeviceId::from_uuid(Uuid::from_u128(7)),
|
|
name: DeviceName::new("Phone").unwrap(),
|
|
paired_at_ms: 1000,
|
|
last_seen_at_ms: 1000,
|
|
session_token_hash: SessionTokenHash::from_token_bytes(&[1; 32]),
|
|
};
|
|
let store = Arc::new(FakeStore::default());
|
|
store.save_devices(&[device]).await.unwrap();
|
|
*store.saves.lock().unwrap() = 0;
|
|
let use_case = TouchDevice::new(
|
|
Arc::clone(&store) as Arc<dyn DeviceSessionStore>,
|
|
Arc::new(FixedClock((1000 + LAST_SEEN_TOUCH_THROTTLE_MS - 1) as i64)),
|
|
);
|
|
|
|
let output = use_case
|
|
.execute(TouchDeviceInput {
|
|
device_id: DeviceId::from_uuid(Uuid::from_u128(7)),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(!output.updated);
|
|
assert_eq!(*store.saves.lock().unwrap(), 0);
|
|
}
|
|
}
|