Skip to content
Clone
f5ba80a
286 lines 10.0 KB Rust
use base64::Engine;
use rand::Rng;
use time::OffsetDateTime;
use uuid::Uuid;
use cache::redis::{RedisCache, RedisError};
use db::conn::sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set,
TransactionTrait,
};
use db::user::user_email_verification_codes::{
ActiveModel as CodeActiveModel, EmailVerificationPurpose, Entity as CodeEntity,
};
use db::user::user_emails::{ActiveModel as EmailActiveModel, Entity as EmailEntity};
use db::user::users::{ActiveModel as UserActiveModel, Entity as UserEntity};
use crate::config::AuthConfig;
use crate::error::AuthError;
use crate::features::TempKey;
use super::types::{IssueCodeArtifacts, IssueCodeCmd, VerifyCodeCmd, VerifyCodeResult};
fn redis_key(purpose: EmailVerificationPurpose, uuid: &Uuid) -> String {
format!("auth:code:{}:{}", purpose_to_str(purpose), uuid.simple())
}
fn purpose_to_str(p: EmailVerificationPurpose) -> &'static str {
match p {
EmailVerificationPurpose::Signup => "signup",
EmailVerificationPurpose::PasswordReset => "password_reset",
EmailVerificationPurpose::EmailChange => "email_change",
}
}
pub(crate) fn cookie_name(prefix: &str, purpose: EmailVerificationPurpose, email: &str) -> String {
let lower = email.to_ascii_lowercase();
let safe: String = lower
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect();
format!("{}{}_{}", prefix, purpose_to_str(purpose), safe)
}
pub fn sha256_hex(data: &[u8]) -> String {
use sha2::Digest;
let digest = sha2::Sha256::digest(data);
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest)
}
pub fn generate_numeric_code(digits: usize) -> String {
let mut buf = vec![0u8; digits];
rand::rng().fill_bytes(&mut buf);
let mut out = String::with_capacity(digits);
for b in buf.iter() {
// Rejection sampling: only accept values in [0, 250) so that
// `b % 10` is uniform across 0..=9. The previous unconditional
// `% 10` skewed toward 0-5 because 256 / 10 leaves a 6-element
// remainder.
let v = if *b < 250 {
*b % 10
} else {
loop {
let mut byte = [0u8; 1];
rand::rng().fill_bytes(&mut byte);
if byte[0] < 250 {
break byte[0] % 10;
}
}
};
out.push((b'0' + v) as char);
}
out
}
fn map_redis(e: RedisError) -> AuthError {
AuthError::Internal(format!("redis: {e}"))
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff: u8 = 0;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
/// Issue a verification code: store the raw value in Redis under a UUID key,
/// persist the SHA-256 hash in `user_email_verification_codes`, and return the
/// [`TempKey`] the caller turns into a cookie.
pub async fn issue_code(
cmd: IssueCodeCmd,
db: &DatabaseConnection,
kv: &RedisCache,
config: &AuthConfig,
) -> Result<IssueCodeArtifacts, AuthError> {
let rate_key = format!(
"auth:code_rate:{}:{}",
purpose_to_str(cmd.purpose),
cmd.email.trim().to_ascii_lowercase()
);
if kv.get_bytes(&rate_key).await.map_err(map_redis)?.is_some() {
return Err(AuthError::RateLimited(
"please wait before requesting another code".into(),
));
}
kv.set_bytes(&rate_key, b"1".to_vec(), config.email_code_rate_limit_secs)
.await
.map_err(map_redis)?;
// VULN-012: invalidate every prior unused code for the same (email,
// purpose) pair so a reissued code cannot be shadowed by an older
// one that the caller still has. Redis-side we cannot index old
// temp_ids cheaply; mark the DB rows as used to short-circuit
// verify_code's UsedAt.is_null() filter. Single round-trip
// UPDATE ... WHERE email = ? AND purpose = ? AND used_at IS NULL.
let stale_mark = CodeActiveModel {
used_at: Set(Some(OffsetDateTime::now_utc())),
..Default::default()
};
let _ = CodeEntity::update_many()
.set(stale_mark)
.filter(CodeEntity::COLUMN.email.eq(cmd.email.clone()))
.filter(CodeEntity::COLUMN.purpose.eq(cmd.purpose))
.filter(CodeEntity::COLUMN.used_at.is_null())
.exec(db)
.await;
let code = generate_numeric_code(config.verify_code_len);
let temp_id = Uuid::new_v4();
let ttl = config.verify_code_ttl_secs;
let now = OffsetDateTime::now_utc();
let expires_at = now + time::Duration::seconds(ttl as i64);
let key = redis_key(cmd.purpose, &temp_id);
kv.set_bytes(&key, code.as_bytes().to_vec(), ttl)
.await
.map_err(map_redis)?;
let token_hash = sha256_hex(code.as_bytes());
let row: CodeActiveModel = CodeActiveModel::builder()
.set_id(Uuid::new_v4())
.set_user_id(cmd.user_id)
.set_email(cmd.email.clone())
.set_purpose(cmd.purpose)
.set_token_hash(token_hash)
.set_created_at(now)
.set_expires_at(expires_at)
.set_used_at(None)
.into();
let inserted = row.insert(db).await?;
let temp_key = TempKey {
name: cookie_name(&config.cookie_name_prefix, cmd.purpose, &cmd.email),
value: temp_id.to_string(),
ttl_seconds: ttl,
secure: config.cookie_secure,
http_only: true,
same_site: config.cookie_same_site.clone(),
domain: config.cookie_domain.clone(),
path: Some("/".to_string()),
};
Ok(IssueCodeArtifacts {
temp_key,
code,
code_row_id: inserted.id,
expires_at,
})
}
/// Verify a code: fetch Redis value via `temp_key_value`, constant-time
/// compare against user input. On match, consume the Redis entry and mark the
/// DB row `used_at = now`. Each attempt bumps a per-code counter; past
/// `max_verify_attempts` the code is destroyed and further attempts are
/// rejected. For `Signup` purposes a successful verification also activates
/// the account (B1).
pub async fn verify_code(
cmd: VerifyCodeCmd,
db: &DatabaseConnection,
kv: &RedisCache,
config: &AuthConfig,
) -> Result<VerifyCodeResult, AuthError> {
let temp_id = Uuid::parse_str(&cmd.temp_key_value)?;
let key = redis_key(cmd.purpose, &temp_id);
let attempts_key = format!(
"auth:code_attempts:{}:{}",
purpose_to_str(cmd.purpose),
temp_id.simple()
);
// B2: lockout — every attempt (correct or not) bumps the counter; past
// the cap we destroy the code and reject further tries.
let attempts = kv
.incr_with_ttl(&attempts_key, config.verify_code_ttl_secs)
.await
.map_err(map_redis)?;
if attempts > config.max_verify_attempts as u64 {
kv.delete(&key).await.map_err(map_redis)?;
return Err(AuthError::RateLimited(
"too many verification attempts; request a new code".into(),
));
}
let stored = kv
.get_bytes(&key)
.await
.map_err(map_redis)?
.ok_or_else(|| AuthError::BadRequest("verification code expired or not found".into()))?;
if !constant_time_eq(stored.as_slice(), cmd.code.as_bytes()) {
return Err(AuthError::BadRequest("invalid verification code".into()));
}
kv.delete(&key).await.map_err(map_redis)?;
let token_hash = sha256_hex(cmd.code.as_bytes());
// B5: the code is proof of mailbox control, so callers may omit the user
// id (reclaim flow); resolve it from the email row when absent.
let user_id = match cmd.user_id {
Some(uid) => uid,
None => EmailEntity::find()
.filter(EmailEntity::COLUMN.lower_email.eq(cmd.email.trim().to_ascii_lowercase()))
.filter(EmailEntity::COLUMN.deleted_at.is_null())
.one(db)
.await?
.ok_or_else(|| AuthError::BadRequest("verification code not found".into()))?
.user_id,
};
let row = CodeEntity::find()
.filter(CodeEntity::COLUMN.email.eq(cmd.email.clone()))
.filter(CodeEntity::COLUMN.purpose.eq(cmd.purpose))
.filter(CodeEntity::COLUMN.token_hash.eq(token_hash))
.filter(CodeEntity::COLUMN.user_id.eq(user_id))
.filter(CodeEntity::COLUMN.used_at.is_null())
.one(db)
.await?
.ok_or_else(|| AuthError::BadRequest("verification code not found".into()))?;
if row.expires_at < OffsetDateTime::now_utc() {
return Err(AuthError::BadRequest("verification code expired".into()));
}
let now = OffsetDateTime::now_utc();
let result = if cmd.purpose == EmailVerificationPurpose::Signup {
// B1: activation is atomic with code consumption — a verified signup
// code activates the account and primary email.
let txn = db.begin().await?;
let mut am: CodeActiveModel = row.into();
am.used_at = Set(Some(now));
let updated = am.update(&txn).await?;
let _ = UserEntity::update_many()
.set(UserActiveModel {
is_active: Set(true),
updated_at: Set(now),
..Default::default()
})
.filter(UserEntity::COLUMN.uid.eq(user_id))
.exec(&txn)
.await?;
let _ = EmailEntity::update_many()
.set(EmailActiveModel {
is_activated: Set(true),
activated_at: Set(Some(now)),
updated_at: Set(now),
..Default::default()
})
.filter(EmailEntity::COLUMN.user_id.eq(user_id))
.filter(EmailEntity::COLUMN.email.eq(cmd.email.clone()))
.exec(&txn)
.await?;
txn.commit().await?;
VerifyCodeResult {
code_row_id: updated.id,
}
} else {
let mut am: CodeActiveModel = row.into();
am.used_at = Set(Some(now));
let updated = am.update(db).await?;
VerifyCodeResult {
code_row_id: updated.id,
}
};
kv.delete(&attempts_key).await.map_err(map_redis)?;
Ok(result)
}