Refresh-token rotation bug (found via Solaria smoke test): * RefreshClaims now carries a unique random jti (UUID v4). Without it, two refresh tokens issued in the same second (e.g. on rotation) were byte-identical — breaking rotation, colliding on the tokenHash unique index, and making a stolen old token indistinguishable from the new one. Every refresh token is now unique regardless of issue time. Added a unit test asserting consecutive tokens differ. Code cleanup (review items): * #15: removed unused deps tower_governor, slog, thiserror (zero refs in src/). * #30: deleted leftover src/main.rs.restore (a stale git-error-message backup). * #28: removed the 9 single-line-comment stub files under src/db/ (appointment, family, health_data, lab_result, medication, profile, permission, share, user) and their mod declarations; nothing referenced them, real logic lives in mongodb_impl.rs/init.rs. * #29: stripped 61 broad module-level #![allow(...)] suppressions across src/. Fixed the surfaced warnings instead: 4 unused 'claims' extractor bindings -> _claims; removed dead OpenFDAService.client/base_url fields + the never-called query_drug_events method + unused HashMap/reqwest imports; applied clippy's mechanical fixes (Ok(?) -> ?, Copy ObjectId clone, redundant closures, useless conversions); rewrote 'if let Ok(_) = x' -> 'if x.is_ok()'. Result: cargo build + clippy --all-targets are warning-free with no blanket allows. Verified: fmt clean, build clean, clippy 0 warnings, 19 unit tests pass (was 18; +1 jti-uniqueness test).
223 lines
6.6 KiB
Rust
223 lines
6.6 KiB
Rust
use mongodb::bson::{doc, oid::ObjectId};
|
|
use mongodb::Collection;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::auth::password::verify_password;
|
|
use mongodb::bson::DateTime;
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct User {
|
|
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
|
pub id: Option<ObjectId>,
|
|
|
|
pub email: String,
|
|
|
|
pub username: String,
|
|
|
|
pub password_hash: String,
|
|
|
|
/// Password recovery phrase hash (zero-knowledge)
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub recovery_phrase_hash: Option<String>,
|
|
|
|
/// Whether password recovery is enabled for this user
|
|
pub recovery_enabled: bool,
|
|
|
|
/// Token version for invalidating all tokens on password change
|
|
pub token_version: i32,
|
|
|
|
/// When the user was created
|
|
pub created_at: DateTime,
|
|
|
|
/// Last time the user was active
|
|
pub last_active: DateTime,
|
|
|
|
/// Email verification status
|
|
pub email_verified: bool,
|
|
|
|
/// Email verification token (stub for now, will be used in Phase 2.4)
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub verification_token: Option<String>,
|
|
|
|
/// When the verification token expires
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub verification_expires: Option<DateTime>,
|
|
}
|
|
|
|
impl User {
|
|
/// Create a new user with password and optional recovery phrase
|
|
pub fn new(
|
|
email: String,
|
|
username: String,
|
|
password: String,
|
|
recovery_phrase: Option<String>,
|
|
) -> Result<Self, anyhow::Error> {
|
|
// Import PasswordService
|
|
use crate::auth::password::PasswordService;
|
|
|
|
// Hash the password
|
|
let password_hash = PasswordService::hash_password(&password)?;
|
|
|
|
// Hash the recovery phrase if provided
|
|
let recovery_phrase_hash = if let Some(phrase) = recovery_phrase {
|
|
Some(PasswordService::hash_password(&phrase)?)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let now = DateTime::now();
|
|
let recovery_enabled = recovery_phrase_hash.is_some();
|
|
|
|
Ok(User {
|
|
id: None,
|
|
email,
|
|
username,
|
|
password_hash,
|
|
recovery_phrase_hash,
|
|
recovery_enabled,
|
|
token_version: 0,
|
|
created_at: now,
|
|
last_active: now,
|
|
email_verified: false,
|
|
verification_token: None,
|
|
verification_expires: None,
|
|
})
|
|
}
|
|
|
|
/// Verify a password against the stored hash
|
|
pub fn verify_password(&self, password: &str) -> Result<bool, anyhow::Error> {
|
|
verify_password(password, &self.password_hash)
|
|
}
|
|
|
|
/// Verify a recovery phrase against the stored hash
|
|
pub fn verify_recovery_phrase(&self, phrase: &str) -> Result<bool, anyhow::Error> {
|
|
if !self.recovery_enabled || self.recovery_phrase_hash.is_none() {
|
|
return Ok(false);
|
|
}
|
|
|
|
let hash = self.recovery_phrase_hash.as_ref().unwrap();
|
|
verify_password(phrase, hash)
|
|
}
|
|
|
|
/// Update the password hash (increments token_version to invalidate all tokens)
|
|
pub fn update_password(&mut self, new_password: String) -> Result<(), anyhow::Error> {
|
|
use crate::auth::password::PasswordService;
|
|
|
|
self.password_hash = PasswordService::hash_password(&new_password)?;
|
|
self.token_version += 1;
|
|
Ok(())
|
|
}
|
|
|
|
/// Set or update the recovery phrase
|
|
pub fn set_recovery_phrase(&mut self, phrase: String) -> Result<(), anyhow::Error> {
|
|
use crate::auth::password::PasswordService;
|
|
|
|
self.recovery_phrase_hash = Some(PasswordService::hash_password(&phrase)?);
|
|
self.recovery_enabled = true;
|
|
Ok(())
|
|
}
|
|
|
|
/// Remove the recovery phrase (disable password recovery)
|
|
pub fn remove_recovery_phrase(&mut self) {
|
|
self.recovery_phrase_hash = None;
|
|
self.recovery_enabled = false;
|
|
}
|
|
|
|
/// Increment token version (invalidates all existing tokens)
|
|
pub fn increment_token_version(&mut self) {
|
|
self.token_version += 1;
|
|
}
|
|
}
|
|
|
|
/// Repository for User operations
|
|
#[derive(Clone)]
|
|
pub struct UserRepository {
|
|
collection: Collection<User>,
|
|
}
|
|
|
|
impl UserRepository {
|
|
/// Create a new UserRepository
|
|
pub fn new(collection: Collection<User>) -> Self {
|
|
Self { collection }
|
|
}
|
|
|
|
/// Create a new user
|
|
pub async fn create(&self, user: &User) -> mongodb::error::Result<Option<ObjectId>> {
|
|
let result = self.collection.insert_one(user, None).await?;
|
|
Ok(Some(result.inserted_id.as_object_id().unwrap()))
|
|
}
|
|
|
|
/// Find a user by email
|
|
pub async fn find_by_email(&self, email: &str) -> mongodb::error::Result<Option<User>> {
|
|
self.collection
|
|
.find_one(doc! { "email": email }, None)
|
|
.await
|
|
}
|
|
|
|
/// Find a user by ID
|
|
pub async fn find_by_id(&self, id: &ObjectId) -> mongodb::error::Result<Option<User>> {
|
|
self.collection.find_one(doc! { "_id": id }, None).await
|
|
}
|
|
|
|
/// Find a user by verification token
|
|
pub async fn find_by_verification_token(
|
|
&self,
|
|
token: &str,
|
|
) -> mongodb::error::Result<Option<User>> {
|
|
self.collection
|
|
.find_one(doc! { "verification_token": token }, None)
|
|
.await
|
|
}
|
|
|
|
/// Update a user
|
|
pub async fn update(&self, user: &User) -> mongodb::error::Result<()> {
|
|
self.collection
|
|
.replace_one(doc! { "_id": &user.id }, user, None)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Update the token version - silently fails if ObjectId is invalid
|
|
pub async fn update_token_version(
|
|
&self,
|
|
user_id: &str,
|
|
version: i32,
|
|
) -> mongodb::error::Result<()> {
|
|
let oid = match ObjectId::parse_str(user_id) {
|
|
Ok(id) => id,
|
|
Err(_) => return Ok(()), // Silently fail if invalid ObjectId
|
|
};
|
|
|
|
self.collection
|
|
.update_one(
|
|
doc! { "_id": oid },
|
|
doc! { "$set": { "token_version": version } },
|
|
None,
|
|
)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Delete a user
|
|
pub async fn delete(&self, user_id: &ObjectId) -> mongodb::error::Result<()> {
|
|
self.collection
|
|
.delete_one(doc! { "_id": user_id }, None)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Update last active timestamp
|
|
pub async fn update_last_active(&self, user_id: &ObjectId) -> mongodb::error::Result<()> {
|
|
use mongodb::bson::DateTime;
|
|
|
|
let now = DateTime::now();
|
|
self.collection
|
|
.update_one(
|
|
doc! { "_id": user_id },
|
|
doc! { "$set": { "last_active": now } },
|
|
None,
|
|
)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
}
|