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:
goose 2026-02-15 18:12:10 -03:00
parent 7845c56bbb
commit cdbf6f4523
6 changed files with 1363 additions and 440 deletions

View file

@ -1,110 +1,179 @@
use crate::config::JwtConfig;
use crate::auth::claims::{AccessClaims, RefreshClaims};
use anyhow::Result;
use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use uuid::Uuid;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Result, anyhow};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::config::JwtConfig;
// Token claims
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Claims {
pub sub: String,
pub exp: usize,
pub iat: usize,
pub user_id: String,
pub email: String,
pub token_version: i32,
}
impl Claims {
pub fn new(user_id: String, email: String, token_version: i32) -> Self {
let now = Utc::now();
let exp = now + Duration::minutes(15); // Access token expires in 15 minutes
Self {
sub: user_id.clone(),
exp: exp.timestamp() as usize,
iat: now.timestamp() as usize,
user_id,
email,
token_version,
}
}
}
// Refresh token claims (longer expiry)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RefreshClaims {
pub sub: String,
pub exp: usize,
pub iat: usize,
pub user_id: String,
pub token_version: i32,
}
impl RefreshClaims {
pub fn new(user_id: String, token_version: i32) -> Self {
let now = Utc::now();
let exp = now + Duration::days(30); // Refresh token expires in 30 days
Self {
sub: user_id.clone(),
exp: exp.timestamp() as usize,
iat: now.timestamp() as usize,
user_id,
token_version,
}
}
}
/// JWT Service for token generation and validation
#[derive(Clone)]
pub struct JwtService {
config: JwtConfig,
// In-memory storage for refresh tokens (user_id -> set of tokens)
refresh_tokens: Arc<RwLock<HashMap<String, Vec<String>>>>,
encoding_key: EncodingKey,
decoding_key: DecodingKey,
}
impl JwtService {
pub fn new(config: JwtConfig) -> Self {
let encoding_key = EncodingKey::from_base64_secret(&config.secret)
.unwrap_or_else(|_| EncodingKey::from_secret(config.secret.as_bytes()));
let decoding_key = DecodingKey::from_base64_secret(&config.secret)
.unwrap_or_else(|_| DecodingKey::from_secret(config.secret.as_bytes()));
let encoding_key = EncodingKey::from_secret(config.secret.as_ref());
let decoding_key = DecodingKey::from_secret(config.secret.as_ref());
Self {
config,
refresh_tokens: Arc::new(RwLock::new(HashMap::new())),
encoding_key,
decoding_key,
}
}
fn now_secs() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64
/// Generate access and refresh tokens
pub fn generate_tokens(&self, claims: Claims) -> Result<(String, String)> {
// Generate access token
let access_token = encode(&Header::default(), &claims, &self.encoding_key)
.map_err(|e| anyhow::anyhow!("Failed to encode access token: {}", e))?;
// Generate refresh token
let refresh_claims = RefreshClaims::new(claims.user_id.clone(), claims.token_version);
let refresh_token = encode(&Header::default(), &refresh_claims, &self.encoding_key)
.map_err(|e| anyhow::anyhow!("Failed to encode refresh token: {}", e))?;
Ok((access_token, refresh_token))
}
pub fn generate_access_token(
&self,
user_id: &str,
email: &str,
family_id: Option<&str>,
permissions: Vec<String>,
) -> Result<String> {
let now = Self::now_secs();
let expiry_secs = self.config.access_token_expiry_duration().as_secs() as i64;
let expiry = now + expiry_secs;
let jti = Uuid::new_v4().to_string();
let claims = AccessClaims {
sub: user_id.to_string(),
email: email.to_string(),
family_id: family_id.map(|s| s.to_string()),
permissions,
token_type: "access".to_string(),
iat: now,
exp: expiry,
jti,
};
let token = encode(&Header::default(), &claims, &self.encoding_key)
.map_err(|e| anyhow!("Failed to encode access token: {}", e))?;
Ok(token)
}
pub fn generate_refresh_token(&self, user_id: &str) -> Result<String> {
let now = Self::now_secs();
let expiry_secs = self.config.refresh_token_expiry_duration().as_secs() as i64;
let expiry = now + expiry_secs;
let jti = Uuid::new_v4().to_string();
let claims = RefreshClaims {
sub: user_id.to_string(),
token_type: "refresh".to_string(),
iat: now,
exp: expiry,
jti,
};
let token = encode(&Header::default(), &claims, &self.encoding_key)
.map_err(|e| anyhow!("Failed to encode refresh token: {}", e))?;
Ok(token)
}
pub fn verify_access_token(&self, token: &str) -> Result<AccessClaims> {
let token_data = decode::<AccessClaims>(
/// Validate access token
pub fn validate_token(&self, token: &str) -> Result<Claims> {
let token_data = decode::<Claims>(
token,
&self.decoding_key,
&Validation::default()
).map_err(|e| anyhow!("Invalid access token: {}", e))?;
if token_data.claims.token_type != "access" {
return Err(anyhow!("Invalid token type"));
}
)
.map_err(|e| anyhow::anyhow!("Invalid token: {}", e))?;
Ok(token_data.claims)
}
pub fn verify_refresh_token(&self, token: &str) -> Result<RefreshClaims> {
/// Validate refresh token
pub fn validate_refresh_token(&self, token: &str) -> Result<RefreshClaims> {
let token_data = decode::<RefreshClaims>(
token,
&self.decoding_key,
&Validation::default()
).map_err(|e| anyhow!("Invalid refresh token: {}", e))?;
if token_data.claims.token_type != "refresh" {
return Err(anyhow!("Invalid token type"));
}
)
.map_err(|e| anyhow::anyhow!("Invalid refresh token: {}", e))?;
Ok(token_data.claims)
}
/// Store refresh token for a user
pub async fn store_refresh_token(&self, user_id: &str, token: &str) -> Result<()> {
let mut tokens = self.refresh_tokens.write().await;
tokens.entry(user_id.to_string())
.or_insert_with(Vec::new)
.push(token.to_string());
// Keep only last 5 tokens per user
if let Some(user_tokens) = tokens.get_mut(user_id) {
user_tokens.sort();
user_tokens.dedup();
if user_tokens.len() > 5 {
*user_tokens = user_tokens.split_off(user_tokens.len() - 5);
}
}
Ok(())
}
/// Verify if a refresh token is stored
pub async fn verify_refresh_token_stored(&self, user_id: &str, token: &str) -> Result<bool> {
let tokens = self.refresh_tokens.read().await;
if let Some(user_tokens) = tokens.get(user_id) {
Ok(user_tokens.contains(&token.to_string()))
} else {
Ok(false)
}
}
/// Rotate refresh token (remove old, add new)
pub async fn rotate_refresh_token(&self, user_id: &str, old_token: &str, new_token: &str) -> Result<()> {
// Remove old token
self.revoke_refresh_token(old_token).await?;
// Add new token
self.store_refresh_token(user_id, new_token).await?;
Ok(())
}
/// Revoke a specific refresh token
pub async fn revoke_refresh_token(&self, token: &str) -> Result<()> {
let mut tokens = self.refresh_tokens.write().await;
for user_tokens in tokens.values_mut() {
user_tokens.retain(|t| t != token);
}
Ok(())
}
/// Revoke all refresh tokens for a user
pub async fn revoke_all_user_tokens(&self, user_id: &str) -> Result<()> {
let mut tokens = self.refresh_tokens.write().await;
tokens.remove(user_id);
Ok(())
}
}

File diff suppressed because it is too large Load diff

View file

@ -84,6 +84,9 @@ async fn main() -> anyhow::Result<()> {
.route("/api/auth/login", post(handlers::login))
.route("/api/auth/refresh", post(handlers::refresh_token))
.route("/api/auth/logout", post(handlers::logout))
// Password recovery (public - user doesn't have access yet)
.route("/api/auth/recovery/verify", post(handlers::verify_recovery))
.route("/api/auth/recovery/reset-password", post(handlers::reset_password))
.layer(
ServiceBuilder::new()
.layer(TraceLayer::new_for_http())
@ -91,7 +94,10 @@ async fn main() -> anyhow::Result<()> {
);
let protected_routes = Router::new()
// Profile management
.route("/api/users/me", get(handlers::get_profile))
// Password recovery (protected - user must be logged in)
.route("/api/auth/recovery/setup", post(handlers::setup_recovery))
.layer(
ServiceBuilder::new()
.layer(TraceLayer::new_for_http())

View file

@ -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?;