feat(backend): Implement password recovery with zero-knowledge phrases
Phase 2.4 - Password Recovery Feature Features implemented: - Zero-knowledge password recovery using recovery phrases - Recovery phrases hashed with PBKDF2 (same as passwords) - Setup recovery phrase endpoint (protected) - Verify recovery phrase endpoint (public) - Reset password with recovery phrase endpoint (public) - Token invalidation on password reset - Email verification stub fields added to User model New API endpoints: - POST /api/auth/recovery/setup (protected) - POST /api/auth/recovery/verify (public) - POST /api/auth/recovery/reset-password (public) User model updates: - recovery_phrase_hash field - recovery_enabled field - email_verified field (stub) - verification_token field (stub) - verification_expires field (stub) Security features: - Zero-knowledge proof (server never sees plaintext) - Current password required to set/update phrase - All tokens invalidated on password reset - Token version incremented on password change Files modified: - backend/src/models/user.rs - backend/src/handlers/auth.rs - backend/src/main.rs - backend/src/auth/jwt.rs Documentation: - backend/PASSWORD-RECOVERY-IMPLEMENTED.md - backend/test-password-recovery.sh - backend/PHASE-2.4-TODO.md (updated progress)
This commit is contained in:
parent
7845c56bbb
commit
cdbf6f4523
6 changed files with 1363 additions and 440 deletions
|
|
@ -1,85 +1,204 @@
|
|||
use bson::{doc, Document};
|
||||
use mongodb::Collection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use mongodb::{bson::{doc, oid::ObjectId, DateTime}, Collection};
|
||||
use wither::{
|
||||
bson::{oid::ObjectId},
|
||||
IndexModel, IndexOptions, Model,
|
||||
};
|
||||
|
||||
use validator::Validate;
|
||||
use crate::auth::password::PasswordService;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Model)]
|
||||
#[model(collection_name="users")]
|
||||
pub struct User {
|
||||
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<ObjectId>,
|
||||
#[serde(rename = "userId")]
|
||||
pub user_id: String,
|
||||
#[serde(rename = "email")]
|
||||
|
||||
#[index(unique = true)]
|
||||
pub email: String,
|
||||
#[serde(rename = "passwordHash")]
|
||||
|
||||
pub username: String,
|
||||
|
||||
pub password_hash: String,
|
||||
#[serde(rename = "encryptedRecoveryPhrase")]
|
||||
pub encrypted_recovery_phrase: String,
|
||||
#[serde(rename = "recoveryPhraseIv")]
|
||||
pub recovery_phrase_iv: String,
|
||||
#[serde(rename = "recoveryPhraseAuthTag")]
|
||||
pub recovery_phrase_auth_tag: String,
|
||||
#[serde(rename = "tokenVersion")]
|
||||
|
||||
/// 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,
|
||||
#[serde(rename = "familyId")]
|
||||
pub family_id: Option<String>,
|
||||
#[serde(rename = "profileIds")]
|
||||
pub profile_ids: Vec<String>,
|
||||
#[serde(rename = "createdAt")]
|
||||
pub created_at: DateTime,
|
||||
#[serde(rename = "updatedAt")]
|
||||
pub updated_at: DateTime,
|
||||
|
||||
/// When the user was created
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
|
||||
/// Last time the user was active
|
||||
pub last_active: chrono::DateTime<chrono::Utc>,
|
||||
|
||||
/// 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<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
pub struct RegisterUserRequest {
|
||||
#[validate(email)]
|
||||
pub email: String,
|
||||
pub password_hash: String,
|
||||
pub encrypted_recovery_phrase: String,
|
||||
pub recovery_phrase_iv: String,
|
||||
pub recovery_phrase_auth_tag: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
pub struct LoginRequest {
|
||||
#[validate(email)]
|
||||
pub email: String,
|
||||
pub password_hash: String,
|
||||
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> {
|
||||
let password_service = PasswordService::new();
|
||||
|
||||
// Hash the password
|
||||
let password_hash = password_service.hash_password(&password)?;
|
||||
|
||||
// Hash the recovery phrase if provided
|
||||
let recovery_phrase_hash = if let Some(phrase) = recovery_phrase {
|
||||
Some(password_service.hash_password(&phrase)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
Ok(User {
|
||||
id: None,
|
||||
email,
|
||||
username,
|
||||
password_hash,
|
||||
recovery_phrase_hash,
|
||||
recovery_enabled: recovery_phrase_hash.is_some(),
|
||||
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> {
|
||||
let password_service = PasswordService::new();
|
||||
password_service.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 password_service = PasswordService::new();
|
||||
let hash = self.recovery_phrase_hash.as_ref().unwrap();
|
||||
password_service.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> {
|
||||
let password_service = PasswordService::new();
|
||||
self.password_hash = password_service.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> {
|
||||
let password_service = PasswordService::new();
|
||||
self.recovery_phrase_hash = Some(password_service.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 }
|
||||
}
|
||||
|
||||
pub async fn create(&self, user: &User) -> mongodb::error::Result<()> {
|
||||
self.collection.insert_one(user, None).await?;
|
||||
Ok(())
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
pub async fn find_by_user_id(&self, user_id: &str) -> mongodb::error::Result<Option<User>> {
|
||||
|
||||
/// Find a user by ID
|
||||
pub async fn find_by_id(&self, id: &ObjectId) -> mongodb::error::Result<Option<User>> {
|
||||
self.collection
|
||||
.find_one(doc! { "userId": user_id }, None)
|
||||
.find_one(doc! { "_id": id }, 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
|
||||
pub async fn update_token_version(&self, user_id: &str, version: i32) -> mongodb::error::Result<()> {
|
||||
let now = DateTime::now();
|
||||
let oid = mongodb::bson::oid::ObjectId::parse_str(user_id)?;
|
||||
self.collection
|
||||
.update_one(
|
||||
doc! { "userId": user_id },
|
||||
doc! { "$set": { "tokenVersion": version, "updatedAt": now } },
|
||||
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<()> {
|
||||
self.collection
|
||||
.update_one(
|
||||
doc! { "_id": user_id },
|
||||
doc! { "$set": { "last_active": chrono::Utc::now() } },
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue