use argon2::password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use argon2::Argon2;
use uuid::Uuid;
use super::pos_write_service::{relay_ambient_scope, ManagerAuth, PosError, PosWriteService};
#[derive(Debug, Clone)]
pub struct SetPin {
pub employee_party_id: Uuid,
pub new_pin: String,
pub current: Option<ManagerAuth>,
pub source_ip: Option<String>,
}
impl PosWriteService {
pub async fn set_pin(&self, s: SetPin) -> Result<(), PosError> {
validate_pin_strength(&s.new_pin, &self.pin_policy)?;
let mut tx = self.rpool().begin().await?;
relay_ambient_scope(&mut tx).await?;
let bootstrapping = !self.pins.any_live_pin_exists_on(&mut *tx).await?;
if !bootstrapping {
let proof = s.current.as_ref().ok_or(PosError::ManagerAuthRequired)?;
self.verify_manager_internal(proof, s.source_ip.as_deref()).await?;
}
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default()
.hash_password(s.new_pin.as_bytes(), &salt)
.map_err(|e| PosError::Db(sqlx::Error::Protocol(format!("pin hash: {e}"))))?;
self.pins
.upsert_hash_on(&mut tx, s.employee_party_id, &hash.to_string(), chrono::Utc::now(), s.source_ip.as_deref())
.await?;
tx.commit().await?;
Ok(())
}
pub async fn verify_pin(
&self,
employee_party_id: Uuid,
pin: &str,
source_ip: Option<&str>,
) -> Result<(), PosError> {
self.verify_manager_internal(
&ManagerAuth { employee_party_id, pin: pin.to_string() },
source_ip,
)
.await
}
pub(super) async fn verify_manager_internal(
&self,
auth: &ManagerAuth,
source_ip: Option<&str>,
) -> Result<(), PosError> {
if let Some(ip) = source_ip {
if !self.take_ip_budget(ip) {
return Err(PosError::PinThrottled);
}
}
let cred = self.pins
.fetch_credential(&self.rpool(), auth.employee_party_id)
.await?
.ok_or(PosError::PinNotFound)?;
let now = chrono::Utc::now();
if let Some(until) = cred.locked_until {
if until > now {
return Err(PosError::PinLocked { until });
}
}
let parsed = match PasswordHash::new(&cred.pin_hash) {
Ok(h) => h,
Err(_) => return Err(PosError::PinInvalid),
};
if Argon2::default().verify_password(auth.pin.as_bytes(), &parsed).is_ok() {
self.pins.record_success(&self.rpool(), cred.id, now, source_ip).await?;
Ok(())
} else {
let lockout_until = now + chrono::Duration::seconds(self.pin_policy.lockout_secs);
self.pins
.record_failure(&self.rpool(), cred.id, self.pin_policy.max_attempts, lockout_until, now, source_ip)
.await?;
if cred.failed_attempts + 1 >= self.pin_policy.max_attempts as i32 {
Err(PosError::PinLocked { until: lockout_until })
} else {
Err(PosError::PinInvalid)
}
}
}
fn take_ip_budget(&self, ip: &str) -> bool {
let key = format!("pin:{ip}");
let now = chrono::Utc::now().timestamp();
let mut ring = self.ip_attempts.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
let in_window = match ring.get(&key) {
Some((window_start, count)) if now - *window_start < self.pin_policy.ip_window_secs => Some(*count),
_ => None,
};
match in_window {
Some(count) if count >= self.pin_policy.ip_max_attempts => false,
Some(_) => {
if let Some((_, count)) = ring.get_mut(&key) {
*count += 1;
}
true
}
None => {
ring.insert(key, (now, 1));
true
}
}
}
}
fn validate_pin_strength(pin: &str, policy: &super::pos_write_service::PinPolicy) -> Result<(), PosError> {
if pin.is_empty() || !pin.bytes().all(|b| b.is_ascii_digit()) {
return Err(PosError::WeakPin("pin must be digits only"));
}
let n = pin.len();
if n < policy.min_digits {
return Err(PosError::WeakPin("pin is too short"));
}
if n > policy.max_digits {
return Err(PosError::WeakPin("pin is too long"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::application::service::pos_write_service::PinPolicy;
fn policy() -> PinPolicy {
PinPolicy { max_attempts: 3, lockout_secs: 5, min_digits: 4, max_digits: 6, ip_window_secs: 10, ip_max_attempts: 4 }
}
#[test]
fn strength_rejects_short_long_and_nondigits() {
let p = policy();
assert!(matches!(validate_pin_strength("123", &p), Err(PosError::WeakPin(_))));
assert!(matches!(validate_pin_strength("1234567", &p), Err(PosError::WeakPin(_))));
assert!(matches!(validate_pin_strength("12ab", &p), Err(PosError::WeakPin(_))));
assert!(validate_pin_strength("4321", &p).is_ok());
}
#[test]
fn env_policy_falls_back_to_defaults_on_garbage() {
std::env::set_var("POS_PIN_TEST_SENTINEL", "not-a-number");
let d = PinPolicy::default();
let p = PinPolicy::from_env();
assert_eq!(p.max_attempts, d.max_attempts);
assert_eq!(p.lockout_secs, d.lockout_secs);
}
}