use base64ct::{Base64UrlUnpadded, Encoding};
use chrono::{DateTime, Duration, Utc};
use rand::TryRngCore;
use rand::rngs::OsRng;
use sha2::{Digest, Sha256};
use crate::db::Db;
use crate::email::{EmailMessage, EmailSender};
use crate::error::AuthError;
use crate::password::hash_password;
use crate::types::{Email, ResetTokenId, UserId};
const RESET_TTL_MINUTES: i64 = 30;
fn generate_reset_token() -> String {
let mut bytes = [0u8; 32];
OsRng
.try_fill_bytes(&mut bytes)
.expect("OS RNG unavailable");
Base64UrlUnpadded::encode_string(&bytes)
}
fn hash_reset_token(token: &str) -> String {
let digest = Sha256::digest(token.as_bytes());
format!("{digest:x}")
}
impl Db {
pub async fn create_password_reset(&self, email: &Email) -> Result<Option<String>, AuthError> {
let user = match self.get_user_by_email(email).await {
Ok(u) => u,
Err(AuthError::NotFound) => return Ok(None),
Err(e) => return Err(e),
};
let raw_token = generate_reset_token();
let token_hash = hash_reset_token(&raw_token);
let id = ResetTokenId::new();
let expires_at = Utc::now() + Duration::minutes(RESET_TTL_MINUTES);
let expires_at_str = expires_at.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
sqlx::query(
"INSERT INTO allowthem_password_reset_tokens \
(id, user_id, token_hash, expires_at) \
VALUES (?, ?, ?, ?)",
)
.bind(id)
.bind(user.id)
.bind(&token_hash)
.bind(&expires_at_str)
.execute(self.pool())
.await
.map_err(AuthError::Database)?;
Ok(Some(raw_token))
}
pub async fn validate_reset_token(
&self,
raw_token: &str,
) -> Result<Option<(ResetTokenId, UserId)>, AuthError> {
let token_hash = hash_reset_token(raw_token);
let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
let row: Option<(ResetTokenId, UserId)> = sqlx::query_as(
"SELECT id, user_id FROM allowthem_password_reset_tokens \
WHERE token_hash = ? AND expires_at > ? AND used_at IS NULL",
)
.bind(&token_hash)
.bind(&now)
.fetch_optional(self.pool())
.await
.map_err(AuthError::Database)?;
Ok(row)
}
pub async fn execute_reset(
&self,
raw_token: &str,
new_password: &str,
) -> Result<bool, AuthError> {
let mut tx = self.pool().begin().await.map_err(AuthError::Database)?;
let token_hash = hash_reset_token(raw_token);
let now = Utc::now();
let now_str = now.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
let row: Option<(ResetTokenId, UserId)> = sqlx::query_as(
"SELECT id, user_id FROM allowthem_password_reset_tokens \
WHERE token_hash = ? AND expires_at > ? AND used_at IS NULL",
)
.bind(&token_hash)
.bind(&now_str)
.fetch_optional(&mut *tx)
.await
.map_err(AuthError::Database)?;
let (token_id, user_id) = match row {
None => return Ok(false),
Some(r) => r,
};
sqlx::query("UPDATE allowthem_password_reset_tokens SET used_at = ? WHERE id = ?")
.bind(&now_str)
.bind(token_id)
.execute(&mut *tx)
.await
.map_err(AuthError::Database)?;
let pw_hash = hash_password(new_password)?;
sqlx::query("UPDATE allowthem_users SET password_hash = ?, updated_at = ? WHERE id = ?")
.bind(pw_hash)
.bind(&now_str)
.bind(user_id)
.execute(&mut *tx)
.await
.map_err(AuthError::Database)?;
tx.commit().await.map_err(AuthError::Database)?;
Ok(true)
}
pub async fn send_password_reset(
&self,
email: &Email,
base_url: &str,
sender: &dyn EmailSender,
) -> Result<(), AuthError> {
let raw_token = match self.create_password_reset(email).await? {
None => return Ok(()),
Some(t) => t,
};
let reset_url = format!("{}/auth/reset-password?token={}", base_url, raw_token);
let body = format!(
"You requested a password reset. Click the link below to set a new password:\n\n{}\n\nThis link expires in {} minutes.",
reset_url, RESET_TTL_MINUTES,
);
let html = format!(
"<p>You requested a password reset. <a href=\"{}\">Click here to set a new password</a>.</p><p>This link expires in {} minutes.</p>",
reset_url, RESET_TTL_MINUTES,
);
let message = EmailMessage {
to: email.as_str(),
subject: "Reset your password",
body: &body,
html: Some(&html),
};
sender
.send(message)
.await
.map_err(|e| AuthError::Email(e.to_string()))
}
}
#[allow(dead_code)]
pub fn reset_expires_at(from: DateTime<Utc>) -> DateTime<Utc> {
from + Duration::minutes(RESET_TTL_MINUTES)
}
#[cfg(test)]
mod tests {
use crate::db::Db;
use crate::email::LogEmailSender;
use crate::types::Email;
async fn test_db() -> Db {
Db::connect("sqlite::memory:").await.expect("in-memory db")
}
async fn make_user(db: &Db) -> Email {
let email = Email::new("reset@example.com".to_string()).unwrap();
db.create_user(email.clone(), "initial-password", None, None)
.await
.expect("create user");
email
}
#[tokio::test]
async fn create_reset_returns_token_for_known_email() {
let db = test_db().await;
let email = make_user(&db).await;
let token = db
.create_password_reset(&email)
.await
.expect("create_password_reset");
assert!(token.is_some(), "should return a token for a known email");
let raw = token.unwrap();
assert!(!raw.is_empty(), "token must not be empty");
}
#[tokio::test]
async fn create_reset_returns_none_for_unknown_email() {
let db = test_db().await;
let email = Email::new("nobody@example.com".to_string()).unwrap();
let token = db
.create_password_reset(&email)
.await
.expect("create_password_reset");
assert!(token.is_none(), "should return None for unknown email");
}
#[tokio::test]
async fn validate_reset_token_returns_ids_for_valid_token() {
let db = test_db().await;
let email = make_user(&db).await;
let raw = db
.create_password_reset(&email)
.await
.expect("create")
.unwrap();
let result = db.validate_reset_token(&raw).await.expect("validate");
assert!(result.is_some(), "valid token must return Some");
}
#[tokio::test]
async fn validate_reset_token_returns_none_for_garbage() {
let db = test_db().await;
let _ = make_user(&db).await;
let result = db
.validate_reset_token("not-a-real-token")
.await
.expect("validate");
assert!(result.is_none(), "invalid token must return None");
}
#[tokio::test]
async fn execute_reset_changes_password_and_marks_token_used() {
let db = test_db().await;
let email = make_user(&db).await;
let raw = db
.create_password_reset(&email)
.await
.expect("create")
.unwrap();
let success = db
.execute_reset(&raw, "new-secure-password")
.await
.expect("execute_reset");
assert!(success, "execute_reset must return true on success");
let again = db
.execute_reset(&raw, "another-password")
.await
.expect("second execute_reset");
assert!(!again, "used token must not be reusable");
let user = db
.find_for_login("reset@example.com")
.await
.expect("find_for_login");
let valid = crate::password::verify_password(
"new-secure-password",
user.password_hash.as_ref().unwrap(),
)
.expect("verify");
assert!(valid, "new password must verify correctly");
}
#[tokio::test]
async fn send_password_reset_succeeds_for_known_email() {
let db = test_db().await;
let email = make_user(&db).await;
let sender = LogEmailSender;
let result = db
.send_password_reset(&email, "https://example.com", &sender)
.await;
assert!(
result.is_ok(),
"send_password_reset must succeed for known email"
);
}
#[tokio::test]
async fn send_password_reset_is_silent_for_unknown_email() {
let db = test_db().await;
let email = Email::new("ghost@example.com".to_string()).unwrap();
let sender = LogEmailSender;
let result = db
.send_password_reset(&email, "https://example.com", &sender)
.await;
assert!(
result.is_ok(),
"send_password_reset must not error for unknown email"
);
}
}