style: cargo fmt --all
This commit is contained in:
@@ -351,27 +351,23 @@ impl CalDavAdapter {
|
||||
username
|
||||
))))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?;
|
||||
xml_writer
|
||||
.write_event(Event::End(BytesEnd::new("D:current-user-principal")))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:current-user-principal")))?;
|
||||
|
||||
// calendar-home-set
|
||||
xml_writer
|
||||
.write_event(Event::Start(BytesStart::new("C:calendar-home-set")))?;
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("C:calendar-home-set")))?;
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&format!(
|
||||
"/caldav/{}/",
|
||||
username
|
||||
))))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?;
|
||||
xml_writer
|
||||
.write_event(Event::End(BytesEnd::new("C:calendar-home-set")))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("C:calendar-home-set")))?;
|
||||
}
|
||||
PropFindType::PropName => {
|
||||
xml_writer.write_event(Event::Empty(BytesStart::new("D:resourcetype")))?;
|
||||
xml_writer
|
||||
.write_event(Event::Empty(BytesStart::new("D:current-user-principal")))?;
|
||||
xml_writer
|
||||
.write_event(Event::Empty(BytesStart::new("C:calendar-home-set")))?;
|
||||
xml_writer.write_event(Event::Empty(BytesStart::new("C:calendar-home-set")))?;
|
||||
}
|
||||
PropFindType::Prop(props) => {
|
||||
Self::write_root_requested_props(xml_writer, username, props)?;
|
||||
@@ -404,9 +400,8 @@ impl CalDavAdapter {
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:resourcetype")))?;
|
||||
}
|
||||
("DAV:", "current-user-principal") => {
|
||||
xml_writer.write_event(Event::Start(BytesStart::new(
|
||||
"D:current-user-principal",
|
||||
)))?;
|
||||
xml_writer
|
||||
.write_event(Event::Start(BytesStart::new("D:current-user-principal")))?;
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&format!(
|
||||
"/caldav/principals/{}/",
|
||||
@@ -417,16 +412,14 @@ impl CalDavAdapter {
|
||||
.write_event(Event::End(BytesEnd::new("D:current-user-principal")))?;
|
||||
}
|
||||
("urn:ietf:params:xml:ns:caldav", "calendar-home-set") => {
|
||||
xml_writer
|
||||
.write_event(Event::Start(BytesStart::new("C:calendar-home-set")))?;
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("C:calendar-home-set")))?;
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&format!(
|
||||
"/caldav/{}/",
|
||||
username
|
||||
))))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?;
|
||||
xml_writer
|
||||
.write_event(Event::End(BytesEnd::new("C:calendar-home-set")))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("C:calendar-home-set")))?;
|
||||
}
|
||||
("DAV:", "displayname") => {
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:displayname")))?;
|
||||
@@ -452,10 +445,7 @@ impl CalDavAdapter {
|
||||
}
|
||||
|
||||
/// Write standard properties for a principal resource.
|
||||
fn write_principal_props<W: Write>(
|
||||
xml_writer: &mut Writer<W>,
|
||||
username: &str,
|
||||
) -> Result<()> {
|
||||
fn write_principal_props<W: Write>(xml_writer: &mut Writer<W>, username: &str) -> Result<()> {
|
||||
// resourcetype — principal
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:resourcetype")))?;
|
||||
xml_writer.write_event(Event::Empty(BytesStart::new("D:collection")))?;
|
||||
@@ -510,9 +500,8 @@ impl CalDavAdapter {
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:displayname")))?;
|
||||
}
|
||||
("DAV:", "current-user-principal") => {
|
||||
xml_writer.write_event(Event::Start(BytesStart::new(
|
||||
"D:current-user-principal",
|
||||
)))?;
|
||||
xml_writer
|
||||
.write_event(Event::Start(BytesStart::new("D:current-user-principal")))?;
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&format!(
|
||||
"/caldav/principals/{}/",
|
||||
@@ -523,16 +512,14 @@ impl CalDavAdapter {
|
||||
.write_event(Event::End(BytesEnd::new("D:current-user-principal")))?;
|
||||
}
|
||||
("urn:ietf:params:xml:ns:caldav", "calendar-home-set") => {
|
||||
xml_writer
|
||||
.write_event(Event::Start(BytesStart::new("C:calendar-home-set")))?;
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("C:calendar-home-set")))?;
|
||||
xml_writer.write_event(Event::Start(BytesStart::new("D:href")))?;
|
||||
xml_writer.write_event(Event::Text(BytesText::new(&format!(
|
||||
"/caldav/{}/",
|
||||
username
|
||||
))))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?;
|
||||
xml_writer
|
||||
.write_event(Event::End(BytesEnd::new("C:calendar-home-set")))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("C:calendar-home-set")))?;
|
||||
}
|
||||
("urn:ietf:params:xml:ns:caldav", "calendar-user-address-set") => {
|
||||
xml_writer.write_event(Event::Start(BytesStart::new(
|
||||
@@ -544,9 +531,8 @@ impl CalDavAdapter {
|
||||
username
|
||||
))))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new("D:href")))?;
|
||||
xml_writer.write_event(Event::End(BytesEnd::new(
|
||||
"C:calendar-user-address-set",
|
||||
)))?;
|
||||
xml_writer
|
||||
.write_event(Event::End(BytesEnd::new("C:calendar-user-address-set")))?;
|
||||
}
|
||||
_ => {
|
||||
let prop_name = if prop.namespace == "http://calendarserver.org/ns/" {
|
||||
|
||||
@@ -356,7 +356,11 @@ mod tests {
|
||||
</D:propfind>"#;
|
||||
|
||||
let result = WebDavAdapter::parse_propfind(Cursor::new(xml));
|
||||
assert!(result.is_ok(), "Failed to parse PROPFIND: {:?}", result.err());
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to parse PROPFIND: {:?}",
|
||||
result.err()
|
||||
);
|
||||
|
||||
let request = result.unwrap();
|
||||
match request.prop_find_type {
|
||||
@@ -431,7 +435,11 @@ mod tests {
|
||||
"/caldav/",
|
||||
"testuser",
|
||||
);
|
||||
assert!(result.is_ok(), "Failed to generate root propfind: {:?}", result.err());
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to generate root propfind: {:?}",
|
||||
result.err()
|
||||
);
|
||||
|
||||
let xml_str = String::from_utf8(output).expect("Invalid UTF-8");
|
||||
|
||||
@@ -511,11 +519,8 @@ mod tests {
|
||||
};
|
||||
|
||||
let mut output = Vec::new();
|
||||
let result = CalDavAdapter::generate_principal_propfind_response(
|
||||
&mut output,
|
||||
&request,
|
||||
"testuser",
|
||||
);
|
||||
let result =
|
||||
CalDavAdapter::generate_principal_propfind_response(&mut output, &request, "testuser");
|
||||
assert!(result.is_ok(), "Failed: {:?}", result.err());
|
||||
|
||||
let xml_str = String::from_utf8(output).expect("Invalid UTF-8");
|
||||
@@ -576,10 +581,7 @@ mod tests {
|
||||
);
|
||||
|
||||
// displayname should be populated
|
||||
assert!(
|
||||
xml_str.contains("Personal"),
|
||||
"Should contain calendar name"
|
||||
);
|
||||
assert!(xml_str.contains("Personal"), "Should contain calendar name");
|
||||
|
||||
// supported-calendar-component-set should have VEVENT
|
||||
assert!(
|
||||
|
||||
@@ -1,339 +1,334 @@
|
||||
//! App Password application service.
|
||||
//!
|
||||
//! Orchestrates creation, verification, listing, and revocation of
|
||||
//! application-specific passwords for DAV clients.
|
||||
|
||||
use crate::application::dtos::app_password_dto::*;
|
||||
use crate::application::ports::auth_ports::{
|
||||
AppPasswordStoragePort, PasswordHasherPort, UserStoragePort,
|
||||
};
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::entities::app_password::AppPassword;
|
||||
use chrono::{Duration, Utc};
|
||||
use moka::future::Cache;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration as StdDuration;
|
||||
|
||||
/// App password token length (32 random alphanumeric chars after prefix).
|
||||
const TOKEN_LENGTH: usize = 32;
|
||||
/// Prefix for all app password tokens (makes them easily identifiable).
|
||||
const TOKEN_PREFIX: &str = "oxicloud-";
|
||||
|
||||
/// TTL for cached Basic Auth verification results.
|
||||
/// Balances performance (avoids repeated Argon2id + DB queries) with security
|
||||
/// (limits the window during which a revoked app password remains usable).
|
||||
const BASIC_AUTH_CACHE_TTL_SECS: u64 = 30;
|
||||
|
||||
/// Maximum number of cached Basic Auth verifications.
|
||||
/// Each entry is ~160 bytes (32-byte key + 4 small strings), so 10 000
|
||||
/// entries ≈ 1.6 MB — negligible compared to other in-memory caches.
|
||||
const BASIC_AUTH_CACHE_MAX_ENTRIES: u64 = 10_000;
|
||||
|
||||
/// Cached identity returned after a successful Basic Auth verification.
|
||||
#[derive(Clone)]
|
||||
struct CachedBasicAuthResult {
|
||||
user_id: String,
|
||||
username: String,
|
||||
email: String,
|
||||
role: String,
|
||||
}
|
||||
|
||||
pub struct AppPasswordService {
|
||||
repo: Arc<dyn AppPasswordStoragePort>,
|
||||
hasher: Arc<dyn PasswordHasherPort>,
|
||||
user_repo: Arc<dyn UserStoragePort>,
|
||||
base_url: String,
|
||||
|
||||
/// In-memory cache of successful Basic Auth verifications.
|
||||
///
|
||||
/// **Key**: `blake3(username + ":" + password)` — the plain-text password
|
||||
/// is never stored; only a cryptographic hash is kept as lookup key.
|
||||
///
|
||||
/// **Value**: the authenticated identity (user_id, username, email, role).
|
||||
///
|
||||
/// **Eviction**: TTL-based (30 s) + capacity-based (10 000 entries).
|
||||
/// Failed verifications are *never* cached, so brute-force attackers
|
||||
/// always pay the full Argon2id cost.
|
||||
auth_cache: Cache<[u8; 32], CachedBasicAuthResult>,
|
||||
}
|
||||
|
||||
impl AppPasswordService {
|
||||
pub fn new(
|
||||
repo: Arc<dyn AppPasswordStoragePort>,
|
||||
hasher: Arc<dyn PasswordHasherPort>,
|
||||
user_repo: Arc<dyn UserStoragePort>,
|
||||
base_url: String,
|
||||
) -> Self {
|
||||
let auth_cache = Cache::builder()
|
||||
.max_capacity(BASIC_AUTH_CACHE_MAX_ENTRIES)
|
||||
.time_to_live(StdDuration::from_secs(BASIC_AUTH_CACHE_TTL_SECS))
|
||||
.build();
|
||||
|
||||
tracing::info!(
|
||||
"AppPasswordService Basic Auth cache initialized: TTL={}s, max={} entries",
|
||||
BASIC_AUTH_CACHE_TTL_SECS,
|
||||
BASIC_AUTH_CACHE_MAX_ENTRIES,
|
||||
);
|
||||
|
||||
Self {
|
||||
repo,
|
||||
hasher,
|
||||
user_repo,
|
||||
base_url,
|
||||
auth_cache,
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a random app password token using cryptographic RNG.
|
||||
fn generate_token() -> String {
|
||||
use rand_core::{OsRng, RngCore};
|
||||
|
||||
let charset: &[u8] = b"abcdefghijklmnopqrstuvwxyz\
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ\
|
||||
0123456789";
|
||||
let mut rng_bytes = [0u8; TOKEN_LENGTH];
|
||||
OsRng.fill_bytes(&mut rng_bytes);
|
||||
|
||||
let random_part: String = rng_bytes
|
||||
.iter()
|
||||
.map(|&b| {
|
||||
let idx = (b as usize) % charset.len();
|
||||
charset[idx] as char
|
||||
})
|
||||
.collect();
|
||||
format!("{}{}", TOKEN_PREFIX, random_part)
|
||||
}
|
||||
|
||||
/// Create a new app password for the given user.
|
||||
///
|
||||
/// Returns the response DTO that includes the plain-text password (shown only once).
|
||||
pub async fn create(
|
||||
&self,
|
||||
user_id: &str,
|
||||
request: CreateAppPasswordRequestDto,
|
||||
) -> Result<AppPasswordCreatedResponseDto, DomainError> {
|
||||
// Validate label
|
||||
let label = request.label.trim().to_string();
|
||||
if label.is_empty() || label.len() > 255 {
|
||||
return Err(DomainError::validation_error(
|
||||
"Label must be 1-255 characters",
|
||||
));
|
||||
}
|
||||
|
||||
// Fetch user for the username (needed for Basic Auth instructions)
|
||||
let user = self.user_repo.get_user_by_id(user_id).await?;
|
||||
let username = user.username().to_string();
|
||||
|
||||
// Generate the plain-text token
|
||||
let plain_token = Self::generate_token();
|
||||
let prefix = plain_token[..TOKEN_PREFIX.len() + 8].to_string();
|
||||
|
||||
// Hash the token for storage
|
||||
let password_hash = self.hasher.hash_password(&plain_token).await?;
|
||||
|
||||
// Calculate expiration
|
||||
let expires_at = request.expires_in_days.map(|days| {
|
||||
Utc::now() + Duration::days(days as i64)
|
||||
});
|
||||
|
||||
// Create entity
|
||||
let app_password = AppPassword::new(
|
||||
user_id.to_string(),
|
||||
label.clone(),
|
||||
password_hash,
|
||||
prefix.clone(),
|
||||
request.scopes.clone(),
|
||||
expires_at,
|
||||
);
|
||||
|
||||
let saved = self.repo.create(app_password).await?;
|
||||
|
||||
let expires_str = saved
|
||||
.expires_at
|
||||
.map(|dt| dt.to_rfc3339());
|
||||
|
||||
let curl_example = format!(
|
||||
"curl -u '{}:{}' -X PROPFIND {}/webdav/",
|
||||
username, plain_token, self.base_url
|
||||
);
|
||||
|
||||
Ok(AppPasswordCreatedResponseDto {
|
||||
id: saved.id,
|
||||
label,
|
||||
password: plain_token,
|
||||
username: username.clone(),
|
||||
scopes: request.scopes,
|
||||
expires_at: expires_str,
|
||||
instructions: AppPasswordInstructions {
|
||||
davx5: format!(
|
||||
"In DAVx⁵, add account with base URL: {}/webdav/\n\
|
||||
Username: {}\n\
|
||||
Password: (the token shown above)",
|
||||
self.base_url, username
|
||||
),
|
||||
thunderbird: format!(
|
||||
"In Thunderbird CalDAV/CardDAV:\n\
|
||||
URL: {}/caldav/ or {}/carddav/\n\
|
||||
Username: {}\n\
|
||||
Password: (the token shown above)",
|
||||
self.base_url, self.base_url, username
|
||||
),
|
||||
rclone: format!(
|
||||
"rclone config:\n\
|
||||
type = webdav\n\
|
||||
url = {}/webdav/\n\
|
||||
vendor = other\n\
|
||||
user = {}\n\
|
||||
pass = (the token shown above, use 'rclone obscure' to encode)",
|
||||
self.base_url, username
|
||||
),
|
||||
curl_example,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// List all app passwords for a user (excludes plain-text passwords).
|
||||
pub async fn list(&self, user_id: &str) -> Result<AppPasswordListResponseDto, DomainError> {
|
||||
let passwords = self.repo.list_by_user(user_id).await?;
|
||||
let total = passwords.len();
|
||||
|
||||
let app_passwords = passwords
|
||||
.into_iter()
|
||||
.map(|ap| {
|
||||
let is_active = ap.active && !ap.is_expired();
|
||||
AppPasswordSummaryDto {
|
||||
id: ap.id,
|
||||
label: ap.label,
|
||||
prefix: format!("{}...", ap.prefix),
|
||||
scopes: ap.scopes,
|
||||
created_at: ap.created_at.to_rfc3339(),
|
||||
last_used_at: ap.last_used_at.map(|dt| dt.to_rfc3339()),
|
||||
expires_at: ap.expires_at.map(|dt| dt.to_rfc3339()),
|
||||
active: is_active,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(AppPasswordListResponseDto {
|
||||
app_passwords,
|
||||
total,
|
||||
})
|
||||
}
|
||||
|
||||
/// Revoke (soft-delete) an app password. Verifies ownership.
|
||||
///
|
||||
/// Also invalidates **all** cached Basic Auth entries for the owning user
|
||||
/// so that the revocation takes effect immediately (instead of waiting
|
||||
/// up to `BASIC_AUTH_CACHE_TTL_SECS`).
|
||||
pub async fn revoke(&self, user_id: &str, id: &str) -> Result<AppPasswordRevokeResponseDto, DomainError> {
|
||||
let ap = self.repo.get_by_id(id).await?;
|
||||
if ap.user_id != user_id {
|
||||
return Err(DomainError::unauthorized(
|
||||
"You can only revoke your own app passwords",
|
||||
));
|
||||
}
|
||||
self.repo.revoke(id).await?;
|
||||
|
||||
// Invalidate all cached auth entries for this user so the
|
||||
// revocation is effective immediately.
|
||||
let uid = user_id.to_string();
|
||||
self.auth_cache
|
||||
.invalidate_entries_if(move |_key, val| val.user_id == uid)
|
||||
.ok();
|
||||
|
||||
tracing::debug!("Revoked app password {} — auth cache entries for user {} invalidated", id, user_id);
|
||||
|
||||
Ok(AppPasswordRevokeResponseDto {
|
||||
status: "revoked".to_string(),
|
||||
id: id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Verify username + app password for HTTP Basic Auth.
|
||||
///
|
||||
/// Returns `(user_id, username, email, role)` on success.
|
||||
///
|
||||
/// ## Performance
|
||||
///
|
||||
/// Successful verifications are cached for `BASIC_AUTH_CACHE_TTL_SECS`
|
||||
/// (default 30 s) keyed by `blake3(username:password)`. This avoids
|
||||
/// the expensive Argon2id computation **and** the three PostgreSQL
|
||||
/// round-trips on every repeated DAV request from the same client.
|
||||
///
|
||||
/// Failed verifications are **never** cached, preserving the full
|
||||
/// Argon2id cost as a brute-force deterrent.
|
||||
pub async fn verify_basic_auth(
|
||||
&self,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<(String, String, String, String), DomainError> {
|
||||
// ── 1. Compute cache key = blake3("username:password") ────────
|
||||
// The plain-text password is never stored; only the 32-byte
|
||||
// cryptographic digest is used as lookup key.
|
||||
let cache_key: [u8; 32] = blake3::hash(
|
||||
format!("{}:{}", username, password).as_bytes(),
|
||||
)
|
||||
.into();
|
||||
|
||||
// ── 2. Cache hit → return immediately ────────────────────────
|
||||
if let Some(cached) = self.auth_cache.get(&cache_key).await {
|
||||
return Ok((
|
||||
cached.user_id,
|
||||
cached.username,
|
||||
cached.email,
|
||||
cached.role,
|
||||
));
|
||||
}
|
||||
|
||||
// ── 3. Cache miss → full verification ────────────────────────
|
||||
// Look up user by username
|
||||
let user = self
|
||||
.user_repo
|
||||
.get_user_by_username(username)
|
||||
.await
|
||||
.map_err(|_| DomainError::unauthorized("Invalid username or app password"))?;
|
||||
|
||||
// Get all active app passwords for this user
|
||||
let app_passwords = self
|
||||
.repo
|
||||
.get_active_by_user_id(user.id())
|
||||
.await?;
|
||||
|
||||
if app_passwords.is_empty() {
|
||||
return Err(DomainError::unauthorized(
|
||||
"Invalid username or app password",
|
||||
));
|
||||
}
|
||||
|
||||
// Try each app password hash (Argon2id — CPU-intensive)
|
||||
for ap in &app_passwords {
|
||||
if let Ok(true) = self.hasher.verify_password(password, &ap.password_hash).await {
|
||||
// Update last_used_at (fire-and-forget; don't fail auth on touch error)
|
||||
let _ = self.repo.touch_last_used(&ap.id).await;
|
||||
|
||||
let result = CachedBasicAuthResult {
|
||||
user_id: user.id().to_string(),
|
||||
username: user.username().to_string(),
|
||||
email: user.email().to_string(),
|
||||
role: user.role().to_string(),
|
||||
};
|
||||
|
||||
// ── 4. Cache the successful result ────────────────────
|
||||
self.auth_cache.insert(cache_key, result.clone()).await;
|
||||
|
||||
return Ok((
|
||||
result.user_id,
|
||||
result.username,
|
||||
result.email,
|
||||
result.role,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Failed verifications are intentionally NOT cached so that
|
||||
// brute-force attackers always pay the full Argon2id cost.
|
||||
Err(DomainError::unauthorized(
|
||||
"Invalid username or app password",
|
||||
))
|
||||
}
|
||||
}
|
||||
//! App Password application service.
|
||||
//!
|
||||
//! Orchestrates creation, verification, listing, and revocation of
|
||||
//! application-specific passwords for DAV clients.
|
||||
|
||||
use crate::application::dtos::app_password_dto::*;
|
||||
use crate::application::ports::auth_ports::{
|
||||
AppPasswordStoragePort, PasswordHasherPort, UserStoragePort,
|
||||
};
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::entities::app_password::AppPassword;
|
||||
use chrono::{Duration, Utc};
|
||||
use moka::future::Cache;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration as StdDuration;
|
||||
|
||||
/// App password token length (32 random alphanumeric chars after prefix).
|
||||
const TOKEN_LENGTH: usize = 32;
|
||||
/// Prefix for all app password tokens (makes them easily identifiable).
|
||||
const TOKEN_PREFIX: &str = "oxicloud-";
|
||||
|
||||
/// TTL for cached Basic Auth verification results.
|
||||
/// Balances performance (avoids repeated Argon2id + DB queries) with security
|
||||
/// (limits the window during which a revoked app password remains usable).
|
||||
const BASIC_AUTH_CACHE_TTL_SECS: u64 = 30;
|
||||
|
||||
/// Maximum number of cached Basic Auth verifications.
|
||||
/// Each entry is ~160 bytes (32-byte key + 4 small strings), so 10 000
|
||||
/// entries ≈ 1.6 MB — negligible compared to other in-memory caches.
|
||||
const BASIC_AUTH_CACHE_MAX_ENTRIES: u64 = 10_000;
|
||||
|
||||
/// Cached identity returned after a successful Basic Auth verification.
|
||||
#[derive(Clone)]
|
||||
struct CachedBasicAuthResult {
|
||||
user_id: String,
|
||||
username: String,
|
||||
email: String,
|
||||
role: String,
|
||||
}
|
||||
|
||||
pub struct AppPasswordService {
|
||||
repo: Arc<dyn AppPasswordStoragePort>,
|
||||
hasher: Arc<dyn PasswordHasherPort>,
|
||||
user_repo: Arc<dyn UserStoragePort>,
|
||||
base_url: String,
|
||||
|
||||
/// In-memory cache of successful Basic Auth verifications.
|
||||
///
|
||||
/// **Key**: `blake3(username + ":" + password)` — the plain-text password
|
||||
/// is never stored; only a cryptographic hash is kept as lookup key.
|
||||
///
|
||||
/// **Value**: the authenticated identity (user_id, username, email, role).
|
||||
///
|
||||
/// **Eviction**: TTL-based (30 s) + capacity-based (10 000 entries).
|
||||
/// Failed verifications are *never* cached, so brute-force attackers
|
||||
/// always pay the full Argon2id cost.
|
||||
auth_cache: Cache<[u8; 32], CachedBasicAuthResult>,
|
||||
}
|
||||
|
||||
impl AppPasswordService {
|
||||
pub fn new(
|
||||
repo: Arc<dyn AppPasswordStoragePort>,
|
||||
hasher: Arc<dyn PasswordHasherPort>,
|
||||
user_repo: Arc<dyn UserStoragePort>,
|
||||
base_url: String,
|
||||
) -> Self {
|
||||
let auth_cache = Cache::builder()
|
||||
.max_capacity(BASIC_AUTH_CACHE_MAX_ENTRIES)
|
||||
.time_to_live(StdDuration::from_secs(BASIC_AUTH_CACHE_TTL_SECS))
|
||||
.build();
|
||||
|
||||
tracing::info!(
|
||||
"AppPasswordService Basic Auth cache initialized: TTL={}s, max={} entries",
|
||||
BASIC_AUTH_CACHE_TTL_SECS,
|
||||
BASIC_AUTH_CACHE_MAX_ENTRIES,
|
||||
);
|
||||
|
||||
Self {
|
||||
repo,
|
||||
hasher,
|
||||
user_repo,
|
||||
base_url,
|
||||
auth_cache,
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a random app password token using cryptographic RNG.
|
||||
fn generate_token() -> String {
|
||||
use rand_core::{OsRng, RngCore};
|
||||
|
||||
let charset: &[u8] = b"abcdefghijklmnopqrstuvwxyz\
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ\
|
||||
0123456789";
|
||||
let mut rng_bytes = [0u8; TOKEN_LENGTH];
|
||||
OsRng.fill_bytes(&mut rng_bytes);
|
||||
|
||||
let random_part: String = rng_bytes
|
||||
.iter()
|
||||
.map(|&b| {
|
||||
let idx = (b as usize) % charset.len();
|
||||
charset[idx] as char
|
||||
})
|
||||
.collect();
|
||||
format!("{}{}", TOKEN_PREFIX, random_part)
|
||||
}
|
||||
|
||||
/// Create a new app password for the given user.
|
||||
///
|
||||
/// Returns the response DTO that includes the plain-text password (shown only once).
|
||||
pub async fn create(
|
||||
&self,
|
||||
user_id: &str,
|
||||
request: CreateAppPasswordRequestDto,
|
||||
) -> Result<AppPasswordCreatedResponseDto, DomainError> {
|
||||
// Validate label
|
||||
let label = request.label.trim().to_string();
|
||||
if label.is_empty() || label.len() > 255 {
|
||||
return Err(DomainError::validation_error(
|
||||
"Label must be 1-255 characters",
|
||||
));
|
||||
}
|
||||
|
||||
// Fetch user for the username (needed for Basic Auth instructions)
|
||||
let user = self.user_repo.get_user_by_id(user_id).await?;
|
||||
let username = user.username().to_string();
|
||||
|
||||
// Generate the plain-text token
|
||||
let plain_token = Self::generate_token();
|
||||
let prefix = plain_token[..TOKEN_PREFIX.len() + 8].to_string();
|
||||
|
||||
// Hash the token for storage
|
||||
let password_hash = self.hasher.hash_password(&plain_token).await?;
|
||||
|
||||
// Calculate expiration
|
||||
let expires_at = request
|
||||
.expires_in_days
|
||||
.map(|days| Utc::now() + Duration::days(days as i64));
|
||||
|
||||
// Create entity
|
||||
let app_password = AppPassword::new(
|
||||
user_id.to_string(),
|
||||
label.clone(),
|
||||
password_hash,
|
||||
prefix.clone(),
|
||||
request.scopes.clone(),
|
||||
expires_at,
|
||||
);
|
||||
|
||||
let saved = self.repo.create(app_password).await?;
|
||||
|
||||
let expires_str = saved.expires_at.map(|dt| dt.to_rfc3339());
|
||||
|
||||
let curl_example = format!(
|
||||
"curl -u '{}:{}' -X PROPFIND {}/webdav/",
|
||||
username, plain_token, self.base_url
|
||||
);
|
||||
|
||||
Ok(AppPasswordCreatedResponseDto {
|
||||
id: saved.id,
|
||||
label,
|
||||
password: plain_token,
|
||||
username: username.clone(),
|
||||
scopes: request.scopes,
|
||||
expires_at: expires_str,
|
||||
instructions: AppPasswordInstructions {
|
||||
davx5: format!(
|
||||
"In DAVx⁵, add account with base URL: {}/webdav/\n\
|
||||
Username: {}\n\
|
||||
Password: (the token shown above)",
|
||||
self.base_url, username
|
||||
),
|
||||
thunderbird: format!(
|
||||
"In Thunderbird CalDAV/CardDAV:\n\
|
||||
URL: {}/caldav/ or {}/carddav/\n\
|
||||
Username: {}\n\
|
||||
Password: (the token shown above)",
|
||||
self.base_url, self.base_url, username
|
||||
),
|
||||
rclone: format!(
|
||||
"rclone config:\n\
|
||||
type = webdav\n\
|
||||
url = {}/webdav/\n\
|
||||
vendor = other\n\
|
||||
user = {}\n\
|
||||
pass = (the token shown above, use 'rclone obscure' to encode)",
|
||||
self.base_url, username
|
||||
),
|
||||
curl_example,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// List all app passwords for a user (excludes plain-text passwords).
|
||||
pub async fn list(&self, user_id: &str) -> Result<AppPasswordListResponseDto, DomainError> {
|
||||
let passwords = self.repo.list_by_user(user_id).await?;
|
||||
let total = passwords.len();
|
||||
|
||||
let app_passwords = passwords
|
||||
.into_iter()
|
||||
.map(|ap| {
|
||||
let is_active = ap.active && !ap.is_expired();
|
||||
AppPasswordSummaryDto {
|
||||
id: ap.id,
|
||||
label: ap.label,
|
||||
prefix: format!("{}...", ap.prefix),
|
||||
scopes: ap.scopes,
|
||||
created_at: ap.created_at.to_rfc3339(),
|
||||
last_used_at: ap.last_used_at.map(|dt| dt.to_rfc3339()),
|
||||
expires_at: ap.expires_at.map(|dt| dt.to_rfc3339()),
|
||||
active: is_active,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(AppPasswordListResponseDto {
|
||||
app_passwords,
|
||||
total,
|
||||
})
|
||||
}
|
||||
|
||||
/// Revoke (soft-delete) an app password. Verifies ownership.
|
||||
///
|
||||
/// Also invalidates **all** cached Basic Auth entries for the owning user
|
||||
/// so that the revocation takes effect immediately (instead of waiting
|
||||
/// up to `BASIC_AUTH_CACHE_TTL_SECS`).
|
||||
pub async fn revoke(
|
||||
&self,
|
||||
user_id: &str,
|
||||
id: &str,
|
||||
) -> Result<AppPasswordRevokeResponseDto, DomainError> {
|
||||
let ap = self.repo.get_by_id(id).await?;
|
||||
if ap.user_id != user_id {
|
||||
return Err(DomainError::unauthorized(
|
||||
"You can only revoke your own app passwords",
|
||||
));
|
||||
}
|
||||
self.repo.revoke(id).await?;
|
||||
|
||||
// Invalidate all cached auth entries for this user so the
|
||||
// revocation is effective immediately.
|
||||
let uid = user_id.to_string();
|
||||
self.auth_cache
|
||||
.invalidate_entries_if(move |_key, val| val.user_id == uid)
|
||||
.ok();
|
||||
|
||||
tracing::debug!(
|
||||
"Revoked app password {} — auth cache entries for user {} invalidated",
|
||||
id,
|
||||
user_id
|
||||
);
|
||||
|
||||
Ok(AppPasswordRevokeResponseDto {
|
||||
status: "revoked".to_string(),
|
||||
id: id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Verify username + app password for HTTP Basic Auth.
|
||||
///
|
||||
/// Returns `(user_id, username, email, role)` on success.
|
||||
///
|
||||
/// ## Performance
|
||||
///
|
||||
/// Successful verifications are cached for `BASIC_AUTH_CACHE_TTL_SECS`
|
||||
/// (default 30 s) keyed by `blake3(username:password)`. This avoids
|
||||
/// the expensive Argon2id computation **and** the three PostgreSQL
|
||||
/// round-trips on every repeated DAV request from the same client.
|
||||
///
|
||||
/// Failed verifications are **never** cached, preserving the full
|
||||
/// Argon2id cost as a brute-force deterrent.
|
||||
pub async fn verify_basic_auth(
|
||||
&self,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<(String, String, String, String), DomainError> {
|
||||
// ── 1. Compute cache key = blake3("username:password") ────────
|
||||
// The plain-text password is never stored; only the 32-byte
|
||||
// cryptographic digest is used as lookup key.
|
||||
let cache_key: [u8; 32] =
|
||||
blake3::hash(format!("{}:{}", username, password).as_bytes()).into();
|
||||
|
||||
// ── 2. Cache hit → return immediately ────────────────────────
|
||||
if let Some(cached) = self.auth_cache.get(&cache_key).await {
|
||||
return Ok((cached.user_id, cached.username, cached.email, cached.role));
|
||||
}
|
||||
|
||||
// ── 3. Cache miss → full verification ────────────────────────
|
||||
// Look up user by username
|
||||
let user = self
|
||||
.user_repo
|
||||
.get_user_by_username(username)
|
||||
.await
|
||||
.map_err(|_| DomainError::unauthorized("Invalid username or app password"))?;
|
||||
|
||||
// Get all active app passwords for this user
|
||||
let app_passwords = self.repo.get_active_by_user_id(user.id()).await?;
|
||||
|
||||
if app_passwords.is_empty() {
|
||||
return Err(DomainError::unauthorized(
|
||||
"Invalid username or app password",
|
||||
));
|
||||
}
|
||||
|
||||
// Try each app password hash (Argon2id — CPU-intensive)
|
||||
for ap in &app_passwords {
|
||||
if let Ok(true) = self
|
||||
.hasher
|
||||
.verify_password(password, &ap.password_hash)
|
||||
.await
|
||||
{
|
||||
// Update last_used_at (fire-and-forget; don't fail auth on touch error)
|
||||
let _ = self.repo.touch_last_used(&ap.id).await;
|
||||
|
||||
let result = CachedBasicAuthResult {
|
||||
user_id: user.id().to_string(),
|
||||
username: user.username().to_string(),
|
||||
email: user.email().to_string(),
|
||||
role: user.role().to_string(),
|
||||
};
|
||||
|
||||
// ── 4. Cache the successful result ────────────────────
|
||||
self.auth_cache.insert(cache_key, result.clone()).await;
|
||||
|
||||
return Ok((result.user_id, result.username, result.email, result.role));
|
||||
}
|
||||
}
|
||||
|
||||
// Failed verifications are intentionally NOT cached so that
|
||||
// brute-force attackers always pay the full Argon2id cost.
|
||||
Err(DomainError::unauthorized(
|
||||
"Invalid username or app password",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +138,9 @@ impl BatchOperationService {
|
||||
let target_folder = target_folder.clone();
|
||||
|
||||
async move {
|
||||
let copy_result = mgmt.copy_file(&file_id, target_folder.map(|s| s.to_string())).await;
|
||||
let copy_result = mgmt
|
||||
.copy_file(&file_id, target_folder.map(|s| s.to_string()))
|
||||
.await;
|
||||
(file_id, copy_result)
|
||||
}
|
||||
}))
|
||||
@@ -201,7 +203,9 @@ impl BatchOperationService {
|
||||
let target_folder = target_folder.clone();
|
||||
|
||||
async move {
|
||||
let move_result = mgmt.move_file(&file_id, target_folder.map(|s| s.to_string())).await;
|
||||
let move_result = mgmt
|
||||
.move_file(&file_id, target_folder.map(|s| s.to_string()))
|
||||
.await;
|
||||
(file_id, move_result)
|
||||
}
|
||||
}))
|
||||
@@ -578,7 +582,9 @@ impl BatchOperationService {
|
||||
let caller = caller.clone();
|
||||
|
||||
async move {
|
||||
let dto = MoveFolderDto { parent_id: target.map(|s| s.to_string()) };
|
||||
let dto = MoveFolderDto {
|
||||
parent_id: target.map(|s| s.to_string()),
|
||||
};
|
||||
let move_result = folder_service.move_folder(&folder_id, dto, &caller).await;
|
||||
(folder_id, move_result)
|
||||
}
|
||||
|
||||
@@ -1,437 +1,435 @@
|
||||
//! OAuth 2.0 Device Authorization Grant service (RFC 8628).
|
||||
//!
|
||||
//! Orchestrates the full device flow:
|
||||
//! 1. `initiate` — generates device_code + user_code, stores in DB
|
||||
//! 2. `verify_user_code` — looks up pending code for the verification page
|
||||
//! 3. `approve` — user approves, tokens are generated and stored
|
||||
//! 4. `deny` — user denies the request
|
||||
//! 5. `poll` — client polls by device_code; returns tokens or status error
|
||||
//! 6. `cleanup_expired` — background job to purge stale entries
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::dtos::device_auth_dto::*;
|
||||
use crate::application::ports::auth_ports::{DeviceCodeStoragePort, TokenServicePort, UserStoragePort};
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
use crate::domain::entities::device_code::{DeviceCode, DeviceCodeStatus};
|
||||
use crate::domain::entities::session::Session;
|
||||
use crate::application::ports::auth_ports::SessionStoragePort;
|
||||
|
||||
/// Default device code lifetime: 15 minutes (RFC 8628 recommends 5-30 min).
|
||||
const DEVICE_CODE_LIFETIME_SECS: i64 = 900;
|
||||
|
||||
/// Default polling interval in seconds (RFC 8628 §3.2 recommends 5s).
|
||||
const DEFAULT_POLL_INTERVAL: i32 = 5;
|
||||
|
||||
/// Length of the device_code (hex-encoded, 64 chars = 32 bytes).
|
||||
const DEVICE_CODE_BYTES: usize = 32;
|
||||
|
||||
/// User code format: 4 uppercase letters + hyphen + 4 digits → "ABCD-1234"
|
||||
/// Short enough to type, long enough to avoid collisions with 26^4 * 10^4 = ~4.5 billion combos.
|
||||
const USER_CODE_LETTER_LEN: usize = 4;
|
||||
const USER_CODE_DIGIT_LEN: usize = 4;
|
||||
|
||||
pub struct DeviceAuthService {
|
||||
device_code_storage: Arc<dyn DeviceCodeStoragePort>,
|
||||
token_service: Arc<dyn TokenServicePort>,
|
||||
user_storage: Arc<dyn UserStoragePort>,
|
||||
session_storage: Arc<dyn SessionStoragePort>,
|
||||
/// Base URL of the server (e.g. "https://cloud.example.com")
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl DeviceAuthService {
|
||||
pub fn new(
|
||||
device_code_storage: Arc<dyn DeviceCodeStoragePort>,
|
||||
token_service: Arc<dyn TokenServicePort>,
|
||||
user_storage: Arc<dyn UserStoragePort>,
|
||||
session_storage: Arc<dyn SessionStoragePort>,
|
||||
base_url: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
device_code_storage,
|
||||
token_service,
|
||||
user_storage,
|
||||
session_storage,
|
||||
base_url,
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 1. Initiate — called by the DAV client
|
||||
// ========================================================================
|
||||
|
||||
/// Start a new device authorization flow.
|
||||
///
|
||||
/// Returns the response that the client displays to the user.
|
||||
pub async fn initiate(
|
||||
&self,
|
||||
req: DeviceAuthorizeRequestDto,
|
||||
) -> Result<DeviceAuthorizeResponseDto, DomainError> {
|
||||
let device_code_token = generate_device_code();
|
||||
let user_code = generate_user_code();
|
||||
|
||||
let verification_uri = format!("{}/device", self.base_url.trim_end_matches('/'));
|
||||
let verification_uri_complete = format!("{}?code={}", verification_uri, user_code);
|
||||
|
||||
let dc = DeviceCode::new(
|
||||
device_code_token.clone(),
|
||||
user_code.clone(),
|
||||
req.client_name,
|
||||
req.scope,
|
||||
verification_uri.clone(),
|
||||
Some(verification_uri_complete.clone()),
|
||||
DEVICE_CODE_LIFETIME_SECS,
|
||||
DEFAULT_POLL_INTERVAL,
|
||||
);
|
||||
|
||||
let dc = self.device_code_storage.create_device_code(dc).await?;
|
||||
|
||||
tracing::info!(
|
||||
"Device auth flow initiated: user_code={}, expires_in={}s",
|
||||
user_code,
|
||||
DEVICE_CODE_LIFETIME_SECS
|
||||
);
|
||||
|
||||
Ok(DeviceAuthorizeResponseDto {
|
||||
device_code: device_code_token,
|
||||
user_code,
|
||||
verification_uri,
|
||||
verification_uri_complete: Some(verification_uri_complete),
|
||||
expires_in: dc.seconds_remaining(),
|
||||
interval: DEFAULT_POLL_INTERVAL,
|
||||
})
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 2. Verify — user opens the verification page, looks up pending code
|
||||
// ========================================================================
|
||||
|
||||
/// Look up a pending device code by user_code for the verification page.
|
||||
pub async fn verify_user_code(
|
||||
&self,
|
||||
user_code: &str,
|
||||
) -> Result<DeviceVerifyInfoDto, DomainError> {
|
||||
let normalized = user_code.trim().to_uppercase().replace(' ', "");
|
||||
|
||||
match self
|
||||
.device_code_storage
|
||||
.get_pending_by_user_code(&normalized)
|
||||
.await
|
||||
{
|
||||
Ok(dc) => {
|
||||
if dc.is_expired() {
|
||||
return Ok(DeviceVerifyInfoDto {
|
||||
client_name: dc.client_name().to_string(),
|
||||
scopes: dc.scopes().to_string(),
|
||||
valid: false,
|
||||
});
|
||||
}
|
||||
Ok(DeviceVerifyInfoDto {
|
||||
client_name: dc.client_name().to_string(),
|
||||
scopes: dc.scopes().to_string(),
|
||||
valid: true,
|
||||
})
|
||||
}
|
||||
Err(_) => Ok(DeviceVerifyInfoDto {
|
||||
client_name: String::new(),
|
||||
scopes: String::new(),
|
||||
valid: false,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 3. Approve — authenticated user approves the device code
|
||||
// ========================================================================
|
||||
|
||||
/// Approve a device code, generating tokens for the polling client.
|
||||
///
|
||||
/// * `user_code` — the code from the verification page
|
||||
/// * `user_id` — the authenticated user's ID (from session/JWT)
|
||||
pub async fn approve(
|
||||
&self,
|
||||
user_code: &str,
|
||||
user_id: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
let normalized = user_code.trim().to_uppercase().replace(' ', "");
|
||||
|
||||
let mut dc = self
|
||||
.device_code_storage
|
||||
.get_pending_by_user_code(&normalized)
|
||||
.await?;
|
||||
|
||||
if dc.is_expired() {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"DeviceCode",
|
||||
"Device code has expired. Please start a new authorization flow.",
|
||||
));
|
||||
}
|
||||
|
||||
// Fetch user to generate tokens
|
||||
let user = self.user_storage.get_user_by_id(user_id).await?;
|
||||
|
||||
// Generate internal JWT access token + refresh token
|
||||
let access_token = self.token_service.generate_access_token(&user)?;
|
||||
let refresh_token = self.token_service.generate_refresh_token();
|
||||
|
||||
// Persist refresh token as a session
|
||||
let session = Session::new(
|
||||
user_id.to_string(),
|
||||
refresh_token.clone(),
|
||||
None, // ip_address
|
||||
Some(format!("device:{}", dc.client_name())), // user_agent
|
||||
self.token_service.refresh_token_expiry_days(),
|
||||
);
|
||||
self.session_storage.create_session(session).await?;
|
||||
|
||||
// Store tokens on the device code entity
|
||||
dc.authorize(user_id.to_string(), access_token, refresh_token);
|
||||
self.device_code_storage.update_device_code(dc).await?;
|
||||
|
||||
tracing::info!(
|
||||
"Device code approved by user {} (user_code={})",
|
||||
user_id,
|
||||
normalized
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 4. Deny — authenticated user denies the device code
|
||||
// ========================================================================
|
||||
|
||||
pub async fn deny(&self, user_code: &str) -> Result<(), DomainError> {
|
||||
let normalized = user_code.trim().to_uppercase().replace(' ', "");
|
||||
|
||||
let mut dc = self
|
||||
.device_code_storage
|
||||
.get_pending_by_user_code(&normalized)
|
||||
.await?;
|
||||
|
||||
dc.deny();
|
||||
self.device_code_storage.update_device_code(dc).await?;
|
||||
|
||||
tracing::info!("Device code denied (user_code={})", normalized);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 5. Poll — client polls by device_code for tokens
|
||||
// ========================================================================
|
||||
|
||||
/// Client polls for tokens. Returns:
|
||||
/// - `Ok(DeviceTokenSuccessDto)` if authorized
|
||||
/// - `Err` with specific RFC 8628 error codes for pending/slow_down/expired/denied
|
||||
pub async fn poll(
|
||||
&self,
|
||||
device_code: &str,
|
||||
) -> Result<DeviceTokenSuccessDto, DevicePollError> {
|
||||
let mut dc = self
|
||||
.device_code_storage
|
||||
.get_by_device_code(device_code)
|
||||
.await
|
||||
.map_err(|_| DevicePollError::InvalidDeviceCode)?;
|
||||
|
||||
// Check expiry first
|
||||
if dc.is_expired() && dc.status() == DeviceCodeStatus::Pending {
|
||||
let mut expired_dc = dc.clone();
|
||||
expired_dc.mark_expired();
|
||||
let _ = self.device_code_storage.update_device_code(expired_dc).await;
|
||||
return Err(DevicePollError::ExpiredToken);
|
||||
}
|
||||
|
||||
match dc.status() {
|
||||
DeviceCodeStatus::Pending => {
|
||||
// Check for slow_down (polling too fast)
|
||||
if dc.is_polling_too_fast() {
|
||||
return Err(DevicePollError::SlowDown);
|
||||
}
|
||||
// Record this poll
|
||||
dc.record_poll();
|
||||
let _ = self.device_code_storage.update_device_code(dc).await;
|
||||
Err(DevicePollError::AuthorizationPending)
|
||||
}
|
||||
DeviceCodeStatus::Authorized => {
|
||||
let access_token = dc.access_token().unwrap_or_default().to_string();
|
||||
let refresh_token = dc.refresh_token().unwrap_or_default().to_string();
|
||||
let scope = dc.scopes().to_string();
|
||||
|
||||
Ok(DeviceTokenSuccessDto {
|
||||
access_token,
|
||||
token_type: "Bearer".to_string(),
|
||||
refresh_token,
|
||||
expires_in: self.token_service.refresh_token_expiry_secs(),
|
||||
scope,
|
||||
})
|
||||
}
|
||||
DeviceCodeStatus::Denied => Err(DevicePollError::AccessDenied),
|
||||
DeviceCodeStatus::Expired => Err(DevicePollError::ExpiredToken),
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 6. Cleanup — purge expired entries
|
||||
// ========================================================================
|
||||
|
||||
pub async fn cleanup_expired(&self) -> Result<u64, DomainError> {
|
||||
let deleted = self.device_code_storage.delete_expired().await?;
|
||||
if deleted > 0 {
|
||||
tracing::info!("Device code cleanup: {} expired entries removed", deleted);
|
||||
}
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 7. List — user's authorized devices (for UI)
|
||||
// ========================================================================
|
||||
|
||||
pub async fn list_user_devices(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<DeviceInfoDto>, DomainError> {
|
||||
let codes = self.device_code_storage.list_by_user(user_id).await?;
|
||||
Ok(codes
|
||||
.into_iter()
|
||||
.map(|dc| DeviceInfoDto {
|
||||
id: dc.id().to_string(),
|
||||
client_name: dc.client_name().to_string(),
|
||||
scopes: dc.scopes().to_string(),
|
||||
status: dc.status().as_str().to_string(),
|
||||
created_at: dc.created_at().to_rfc3339(),
|
||||
authorized_at: dc.authorized_at().map(|t| t.to_rfc3339()),
|
||||
expires_at: dc.expires_at().to_rfc3339(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 8. Revoke — user revokes a device authorization
|
||||
// ========================================================================
|
||||
|
||||
pub async fn revoke_device(&self, device_id: &str, user_id: &str) -> Result<(), DomainError> {
|
||||
// Verify ownership before deleting
|
||||
let devices = self.device_code_storage.list_by_user(user_id).await?;
|
||||
let found = devices.iter().any(|d| d.id() == device_id);
|
||||
if !found {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"DeviceCode",
|
||||
"Device authorization not found or not owned by you",
|
||||
));
|
||||
}
|
||||
self.device_code_storage.delete_by_id(device_id).await
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Poll error (typed for RFC 8628 error responses)
|
||||
// ============================================================================
|
||||
|
||||
/// Typed errors for the device token polling endpoint (RFC 8628 §3.5).
|
||||
#[derive(Debug)]
|
||||
pub enum DevicePollError {
|
||||
/// The authorization request is still pending (user hasn't acted yet).
|
||||
AuthorizationPending,
|
||||
/// The client is polling too fast; increase the interval.
|
||||
SlowDown,
|
||||
/// The user denied the authorization request.
|
||||
AccessDenied,
|
||||
/// The device_code has expired.
|
||||
ExpiredToken,
|
||||
/// The device_code is not recognized.
|
||||
InvalidDeviceCode,
|
||||
}
|
||||
|
||||
impl DevicePollError {
|
||||
/// RFC 8628 error string for the JSON response.
|
||||
pub fn error_code(&self) -> &'static str {
|
||||
match self {
|
||||
Self::AuthorizationPending => "authorization_pending",
|
||||
Self::SlowDown => "slow_down",
|
||||
Self::AccessDenied => "access_denied",
|
||||
Self::ExpiredToken => "expired_token",
|
||||
Self::InvalidDeviceCode => "invalid_grant",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn description(&self) -> &'static str {
|
||||
match self {
|
||||
Self::AuthorizationPending => {
|
||||
"The authorization request is still pending. Continue polling."
|
||||
}
|
||||
Self::SlowDown => "You are polling too frequently. Please slow down.",
|
||||
Self::AccessDenied => "The user denied the authorization request.",
|
||||
Self::ExpiredToken => {
|
||||
"The device_code has expired. Please start a new authorization flow."
|
||||
}
|
||||
Self::InvalidDeviceCode => "The device_code is not recognized.",
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP status code per RFC 8628 §3.5:
|
||||
/// - authorization_pending and slow_down: 400
|
||||
/// - access_denied: 403
|
||||
/// - expired_token: 400
|
||||
pub fn http_status(&self) -> u16 {
|
||||
match self {
|
||||
Self::AuthorizationPending | Self::SlowDown | Self::ExpiredToken => 400,
|
||||
Self::AccessDenied => 403,
|
||||
Self::InvalidDeviceCode => 400,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Additional DTOs (used by service, not in the handler module)
|
||||
// ============================================================================
|
||||
|
||||
/// DTO for listing authorized devices in the user's profile.
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct DeviceInfoDto {
|
||||
pub id: String,
|
||||
pub client_name: String,
|
||||
pub scopes: String,
|
||||
pub status: String,
|
||||
pub created_at: String,
|
||||
pub authorized_at: Option<String>,
|
||||
pub expires_at: String,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
/// Generate a cryptographically random device_code (hex-encoded).
|
||||
fn generate_device_code() -> String {
|
||||
use rand_core::{OsRng, RngCore};
|
||||
let mut bytes = [0u8; DEVICE_CODE_BYTES];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
/// Generate a human-readable user_code in the format "ABCD-1234".
|
||||
fn generate_user_code() -> String {
|
||||
use rand_core::{OsRng, RngCore};
|
||||
let mut rng_bytes = [0u8; 8];
|
||||
OsRng.fill_bytes(&mut rng_bytes);
|
||||
|
||||
let letters: String = (0..USER_CODE_LETTER_LEN)
|
||||
.map(|i| {
|
||||
let b = rng_bytes[i] % 26;
|
||||
(b'A' + b) as char
|
||||
})
|
||||
.collect();
|
||||
|
||||
let digits: String = (0..USER_CODE_DIGIT_LEN)
|
||||
.map(|i| {
|
||||
let b = rng_bytes[USER_CODE_LETTER_LEN + i] % 10;
|
||||
(b'0' + b) as char
|
||||
})
|
||||
.collect();
|
||||
|
||||
format!("{}-{}", letters, digits)
|
||||
}
|
||||
//! OAuth 2.0 Device Authorization Grant service (RFC 8628).
|
||||
//!
|
||||
//! Orchestrates the full device flow:
|
||||
//! 1. `initiate` — generates device_code + user_code, stores in DB
|
||||
//! 2. `verify_user_code` — looks up pending code for the verification page
|
||||
//! 3. `approve` — user approves, tokens are generated and stored
|
||||
//! 4. `deny` — user denies the request
|
||||
//! 5. `poll` — client polls by device_code; returns tokens or status error
|
||||
//! 6. `cleanup_expired` — background job to purge stale entries
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::dtos::device_auth_dto::*;
|
||||
use crate::application::ports::auth_ports::SessionStoragePort;
|
||||
use crate::application::ports::auth_ports::{
|
||||
DeviceCodeStoragePort, TokenServicePort, UserStoragePort,
|
||||
};
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
use crate::domain::entities::device_code::{DeviceCode, DeviceCodeStatus};
|
||||
use crate::domain::entities::session::Session;
|
||||
|
||||
/// Default device code lifetime: 15 minutes (RFC 8628 recommends 5-30 min).
|
||||
const DEVICE_CODE_LIFETIME_SECS: i64 = 900;
|
||||
|
||||
/// Default polling interval in seconds (RFC 8628 §3.2 recommends 5s).
|
||||
const DEFAULT_POLL_INTERVAL: i32 = 5;
|
||||
|
||||
/// Length of the device_code (hex-encoded, 64 chars = 32 bytes).
|
||||
const DEVICE_CODE_BYTES: usize = 32;
|
||||
|
||||
/// User code format: 4 uppercase letters + hyphen + 4 digits → "ABCD-1234"
|
||||
/// Short enough to type, long enough to avoid collisions with 26^4 * 10^4 = ~4.5 billion combos.
|
||||
const USER_CODE_LETTER_LEN: usize = 4;
|
||||
const USER_CODE_DIGIT_LEN: usize = 4;
|
||||
|
||||
pub struct DeviceAuthService {
|
||||
device_code_storage: Arc<dyn DeviceCodeStoragePort>,
|
||||
token_service: Arc<dyn TokenServicePort>,
|
||||
user_storage: Arc<dyn UserStoragePort>,
|
||||
session_storage: Arc<dyn SessionStoragePort>,
|
||||
/// Base URL of the server (e.g. "https://cloud.example.com")
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl DeviceAuthService {
|
||||
pub fn new(
|
||||
device_code_storage: Arc<dyn DeviceCodeStoragePort>,
|
||||
token_service: Arc<dyn TokenServicePort>,
|
||||
user_storage: Arc<dyn UserStoragePort>,
|
||||
session_storage: Arc<dyn SessionStoragePort>,
|
||||
base_url: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
device_code_storage,
|
||||
token_service,
|
||||
user_storage,
|
||||
session_storage,
|
||||
base_url,
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 1. Initiate — called by the DAV client
|
||||
// ========================================================================
|
||||
|
||||
/// Start a new device authorization flow.
|
||||
///
|
||||
/// Returns the response that the client displays to the user.
|
||||
pub async fn initiate(
|
||||
&self,
|
||||
req: DeviceAuthorizeRequestDto,
|
||||
) -> Result<DeviceAuthorizeResponseDto, DomainError> {
|
||||
let device_code_token = generate_device_code();
|
||||
let user_code = generate_user_code();
|
||||
|
||||
let verification_uri = format!("{}/device", self.base_url.trim_end_matches('/'));
|
||||
let verification_uri_complete = format!("{}?code={}", verification_uri, user_code);
|
||||
|
||||
let dc = DeviceCode::new(
|
||||
device_code_token.clone(),
|
||||
user_code.clone(),
|
||||
req.client_name,
|
||||
req.scope,
|
||||
verification_uri.clone(),
|
||||
Some(verification_uri_complete.clone()),
|
||||
DEVICE_CODE_LIFETIME_SECS,
|
||||
DEFAULT_POLL_INTERVAL,
|
||||
);
|
||||
|
||||
let dc = self.device_code_storage.create_device_code(dc).await?;
|
||||
|
||||
tracing::info!(
|
||||
"Device auth flow initiated: user_code={}, expires_in={}s",
|
||||
user_code,
|
||||
DEVICE_CODE_LIFETIME_SECS
|
||||
);
|
||||
|
||||
Ok(DeviceAuthorizeResponseDto {
|
||||
device_code: device_code_token,
|
||||
user_code,
|
||||
verification_uri,
|
||||
verification_uri_complete: Some(verification_uri_complete),
|
||||
expires_in: dc.seconds_remaining(),
|
||||
interval: DEFAULT_POLL_INTERVAL,
|
||||
})
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 2. Verify — user opens the verification page, looks up pending code
|
||||
// ========================================================================
|
||||
|
||||
/// Look up a pending device code by user_code for the verification page.
|
||||
pub async fn verify_user_code(
|
||||
&self,
|
||||
user_code: &str,
|
||||
) -> Result<DeviceVerifyInfoDto, DomainError> {
|
||||
let normalized = user_code.trim().to_uppercase().replace(' ', "");
|
||||
|
||||
match self
|
||||
.device_code_storage
|
||||
.get_pending_by_user_code(&normalized)
|
||||
.await
|
||||
{
|
||||
Ok(dc) => {
|
||||
if dc.is_expired() {
|
||||
return Ok(DeviceVerifyInfoDto {
|
||||
client_name: dc.client_name().to_string(),
|
||||
scopes: dc.scopes().to_string(),
|
||||
valid: false,
|
||||
});
|
||||
}
|
||||
Ok(DeviceVerifyInfoDto {
|
||||
client_name: dc.client_name().to_string(),
|
||||
scopes: dc.scopes().to_string(),
|
||||
valid: true,
|
||||
})
|
||||
}
|
||||
Err(_) => Ok(DeviceVerifyInfoDto {
|
||||
client_name: String::new(),
|
||||
scopes: String::new(),
|
||||
valid: false,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 3. Approve — authenticated user approves the device code
|
||||
// ========================================================================
|
||||
|
||||
/// Approve a device code, generating tokens for the polling client.
|
||||
///
|
||||
/// * `user_code` — the code from the verification page
|
||||
/// * `user_id` — the authenticated user's ID (from session/JWT)
|
||||
pub async fn approve(&self, user_code: &str, user_id: &str) -> Result<(), DomainError> {
|
||||
let normalized = user_code.trim().to_uppercase().replace(' ', "");
|
||||
|
||||
let mut dc = self
|
||||
.device_code_storage
|
||||
.get_pending_by_user_code(&normalized)
|
||||
.await?;
|
||||
|
||||
if dc.is_expired() {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"DeviceCode",
|
||||
"Device code has expired. Please start a new authorization flow.",
|
||||
));
|
||||
}
|
||||
|
||||
// Fetch user to generate tokens
|
||||
let user = self.user_storage.get_user_by_id(user_id).await?;
|
||||
|
||||
// Generate internal JWT access token + refresh token
|
||||
let access_token = self.token_service.generate_access_token(&user)?;
|
||||
let refresh_token = self.token_service.generate_refresh_token();
|
||||
|
||||
// Persist refresh token as a session
|
||||
let session = Session::new(
|
||||
user_id.to_string(),
|
||||
refresh_token.clone(),
|
||||
None, // ip_address
|
||||
Some(format!("device:{}", dc.client_name())), // user_agent
|
||||
self.token_service.refresh_token_expiry_days(),
|
||||
);
|
||||
self.session_storage.create_session(session).await?;
|
||||
|
||||
// Store tokens on the device code entity
|
||||
dc.authorize(user_id.to_string(), access_token, refresh_token);
|
||||
self.device_code_storage.update_device_code(dc).await?;
|
||||
|
||||
tracing::info!(
|
||||
"Device code approved by user {} (user_code={})",
|
||||
user_id,
|
||||
normalized
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 4. Deny — authenticated user denies the device code
|
||||
// ========================================================================
|
||||
|
||||
pub async fn deny(&self, user_code: &str) -> Result<(), DomainError> {
|
||||
let normalized = user_code.trim().to_uppercase().replace(' ', "");
|
||||
|
||||
let mut dc = self
|
||||
.device_code_storage
|
||||
.get_pending_by_user_code(&normalized)
|
||||
.await?;
|
||||
|
||||
dc.deny();
|
||||
self.device_code_storage.update_device_code(dc).await?;
|
||||
|
||||
tracing::info!("Device code denied (user_code={})", normalized);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 5. Poll — client polls by device_code for tokens
|
||||
// ========================================================================
|
||||
|
||||
/// Client polls for tokens. Returns:
|
||||
/// - `Ok(DeviceTokenSuccessDto)` if authorized
|
||||
/// - `Err` with specific RFC 8628 error codes for pending/slow_down/expired/denied
|
||||
pub async fn poll(&self, device_code: &str) -> Result<DeviceTokenSuccessDto, DevicePollError> {
|
||||
let mut dc = self
|
||||
.device_code_storage
|
||||
.get_by_device_code(device_code)
|
||||
.await
|
||||
.map_err(|_| DevicePollError::InvalidDeviceCode)?;
|
||||
|
||||
// Check expiry first
|
||||
if dc.is_expired() && dc.status() == DeviceCodeStatus::Pending {
|
||||
let mut expired_dc = dc.clone();
|
||||
expired_dc.mark_expired();
|
||||
let _ = self
|
||||
.device_code_storage
|
||||
.update_device_code(expired_dc)
|
||||
.await;
|
||||
return Err(DevicePollError::ExpiredToken);
|
||||
}
|
||||
|
||||
match dc.status() {
|
||||
DeviceCodeStatus::Pending => {
|
||||
// Check for slow_down (polling too fast)
|
||||
if dc.is_polling_too_fast() {
|
||||
return Err(DevicePollError::SlowDown);
|
||||
}
|
||||
// Record this poll
|
||||
dc.record_poll();
|
||||
let _ = self.device_code_storage.update_device_code(dc).await;
|
||||
Err(DevicePollError::AuthorizationPending)
|
||||
}
|
||||
DeviceCodeStatus::Authorized => {
|
||||
let access_token = dc.access_token().unwrap_or_default().to_string();
|
||||
let refresh_token = dc.refresh_token().unwrap_or_default().to_string();
|
||||
let scope = dc.scopes().to_string();
|
||||
|
||||
Ok(DeviceTokenSuccessDto {
|
||||
access_token,
|
||||
token_type: "Bearer".to_string(),
|
||||
refresh_token,
|
||||
expires_in: self.token_service.refresh_token_expiry_secs(),
|
||||
scope,
|
||||
})
|
||||
}
|
||||
DeviceCodeStatus::Denied => Err(DevicePollError::AccessDenied),
|
||||
DeviceCodeStatus::Expired => Err(DevicePollError::ExpiredToken),
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 6. Cleanup — purge expired entries
|
||||
// ========================================================================
|
||||
|
||||
pub async fn cleanup_expired(&self) -> Result<u64, DomainError> {
|
||||
let deleted = self.device_code_storage.delete_expired().await?;
|
||||
if deleted > 0 {
|
||||
tracing::info!("Device code cleanup: {} expired entries removed", deleted);
|
||||
}
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 7. List — user's authorized devices (for UI)
|
||||
// ========================================================================
|
||||
|
||||
pub async fn list_user_devices(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<DeviceInfoDto>, DomainError> {
|
||||
let codes = self.device_code_storage.list_by_user(user_id).await?;
|
||||
Ok(codes
|
||||
.into_iter()
|
||||
.map(|dc| DeviceInfoDto {
|
||||
id: dc.id().to_string(),
|
||||
client_name: dc.client_name().to_string(),
|
||||
scopes: dc.scopes().to_string(),
|
||||
status: dc.status().as_str().to_string(),
|
||||
created_at: dc.created_at().to_rfc3339(),
|
||||
authorized_at: dc.authorized_at().map(|t| t.to_rfc3339()),
|
||||
expires_at: dc.expires_at().to_rfc3339(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 8. Revoke — user revokes a device authorization
|
||||
// ========================================================================
|
||||
|
||||
pub async fn revoke_device(&self, device_id: &str, user_id: &str) -> Result<(), DomainError> {
|
||||
// Verify ownership before deleting
|
||||
let devices = self.device_code_storage.list_by_user(user_id).await?;
|
||||
let found = devices.iter().any(|d| d.id() == device_id);
|
||||
if !found {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"DeviceCode",
|
||||
"Device authorization not found or not owned by you",
|
||||
));
|
||||
}
|
||||
self.device_code_storage.delete_by_id(device_id).await
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Poll error (typed for RFC 8628 error responses)
|
||||
// ============================================================================
|
||||
|
||||
/// Typed errors for the device token polling endpoint (RFC 8628 §3.5).
|
||||
#[derive(Debug)]
|
||||
pub enum DevicePollError {
|
||||
/// The authorization request is still pending (user hasn't acted yet).
|
||||
AuthorizationPending,
|
||||
/// The client is polling too fast; increase the interval.
|
||||
SlowDown,
|
||||
/// The user denied the authorization request.
|
||||
AccessDenied,
|
||||
/// The device_code has expired.
|
||||
ExpiredToken,
|
||||
/// The device_code is not recognized.
|
||||
InvalidDeviceCode,
|
||||
}
|
||||
|
||||
impl DevicePollError {
|
||||
/// RFC 8628 error string for the JSON response.
|
||||
pub fn error_code(&self) -> &'static str {
|
||||
match self {
|
||||
Self::AuthorizationPending => "authorization_pending",
|
||||
Self::SlowDown => "slow_down",
|
||||
Self::AccessDenied => "access_denied",
|
||||
Self::ExpiredToken => "expired_token",
|
||||
Self::InvalidDeviceCode => "invalid_grant",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn description(&self) -> &'static str {
|
||||
match self {
|
||||
Self::AuthorizationPending => {
|
||||
"The authorization request is still pending. Continue polling."
|
||||
}
|
||||
Self::SlowDown => "You are polling too frequently. Please slow down.",
|
||||
Self::AccessDenied => "The user denied the authorization request.",
|
||||
Self::ExpiredToken => {
|
||||
"The device_code has expired. Please start a new authorization flow."
|
||||
}
|
||||
Self::InvalidDeviceCode => "The device_code is not recognized.",
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP status code per RFC 8628 §3.5:
|
||||
/// - authorization_pending and slow_down: 400
|
||||
/// - access_denied: 403
|
||||
/// - expired_token: 400
|
||||
pub fn http_status(&self) -> u16 {
|
||||
match self {
|
||||
Self::AuthorizationPending | Self::SlowDown | Self::ExpiredToken => 400,
|
||||
Self::AccessDenied => 403,
|
||||
Self::InvalidDeviceCode => 400,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Additional DTOs (used by service, not in the handler module)
|
||||
// ============================================================================
|
||||
|
||||
/// DTO for listing authorized devices in the user's profile.
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct DeviceInfoDto {
|
||||
pub id: String,
|
||||
pub client_name: String,
|
||||
pub scopes: String,
|
||||
pub status: String,
|
||||
pub created_at: String,
|
||||
pub authorized_at: Option<String>,
|
||||
pub expires_at: String,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
/// Generate a cryptographically random device_code (hex-encoded).
|
||||
fn generate_device_code() -> String {
|
||||
use rand_core::{OsRng, RngCore};
|
||||
let mut bytes = [0u8; DEVICE_CODE_BYTES];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
/// Generate a human-readable user_code in the format "ABCD-1234".
|
||||
fn generate_user_code() -> String {
|
||||
use rand_core::{OsRng, RngCore};
|
||||
let mut rng_bytes = [0u8; 8];
|
||||
OsRng.fill_bytes(&mut rng_bytes);
|
||||
|
||||
let letters: String = (0..USER_CODE_LETTER_LEN)
|
||||
.map(|i| {
|
||||
let b = rng_bytes[i] % 26;
|
||||
(b'A' + b) as char
|
||||
})
|
||||
.collect();
|
||||
|
||||
let digits: String = (0..USER_CODE_DIGIT_LEN)
|
||||
.map(|i| {
|
||||
let b = rng_bytes[USER_CODE_LETTER_LEN + i] % 10;
|
||||
(b'0' + b) as char
|
||||
})
|
||||
.collect();
|
||||
|
||||
format!("{}-{}", letters, digits)
|
||||
}
|
||||
|
||||
@@ -398,12 +398,16 @@ impl FolderUseCase for FolderService {
|
||||
}
|
||||
|
||||
// Rename folder — UPDATE RETURNING gives us the updated row directly
|
||||
let folder = self.folder_storage.rename_folder(id, dto.name).await.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FolderStorage",
|
||||
format!("Failed to rename folder with ID: {}: {}", id, e),
|
||||
)
|
||||
})?;
|
||||
let folder = self
|
||||
.folder_storage
|
||||
.rename_folder(id, dto.name)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FolderStorage",
|
||||
format!("Failed to rename folder with ID: {}: {}", id, e),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(FolderDto::from(folder))
|
||||
}
|
||||
@@ -455,12 +459,16 @@ impl FolderUseCase for FolderService {
|
||||
|
||||
// Move folder — UPDATE RETURNING gives us the updated row directly
|
||||
let parent_ref = dto.parent_id.as_deref();
|
||||
let folder = self.folder_storage.move_folder(id, parent_ref).await.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FolderStorage",
|
||||
format!("Failed to move folder with ID: {}: {}", id, e),
|
||||
)
|
||||
})?;
|
||||
let folder = self
|
||||
.folder_storage
|
||||
.move_folder(id, parent_ref)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"FolderStorage",
|
||||
format!("Failed to move folder with ID: {}: {}", id, e),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(FolderDto::from(folder))
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ pub mod app_password_service;
|
||||
pub mod auth_application_service;
|
||||
pub mod batch_operations;
|
||||
pub mod calendar_service;
|
||||
pub mod device_auth_service;
|
||||
pub mod contact_service;
|
||||
pub mod device_auth_service;
|
||||
pub mod favorites_service;
|
||||
pub mod file_management_service;
|
||||
pub mod file_retrieval_service;
|
||||
|
||||
Reference in New Issue
Block a user