Skip to main content

sova_auth/
token.rs

1//! Signed one-time tokens (email verify) + recovery code helpers.
2
3use crate::store::{hash_token, random_token};
4use hmac::{Hmac, Mac};
5use sova_core::{Error, Result};
6use sha2::Sha256;
7use std::time::{SystemTime, UNIX_EPOCH};
8
9type HmacSha256 = Hmac<Sha256>;
10
11pub fn now_secs() -> u64 {
12    SystemTime::now()
13        .duration_since(UNIX_EPOCH)
14        .map(|d| d.as_secs())
15        .unwrap_or(0)
16}
17
18pub fn sign_payload(secret: &str, payload: &str) -> String {
19    let mut mac =
20        HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC key length");
21    mac.update(payload.as_bytes());
22    hex_encode(mac.finalize().into_bytes())
23}
24
25pub fn verify_payload(secret: &str, payload: &str, sig: &str) -> bool {
26    let expected = sign_payload(secret, payload);
27    constant_eq(expected.as_bytes(), sig.as_bytes())
28}
29
30fn constant_eq(a: &[u8], b: &[u8]) -> bool {
31    if a.len() != b.len() {
32        return false;
33    }
34    a.iter().zip(b.iter()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
35}
36
37fn hex_encode(bytes: impl AsRef<[u8]>) -> String {
38    bytes.as_ref().iter().map(|b| format!("{b:02x}")).collect()
39}
40
41/// `user_id.exp.sig` for email verification (24h).
42pub fn make_verify_token(secret: &str, user_id: i64) -> String {
43    let exp = now_secs() + 86_400;
44    let payload = format!("verify:{user_id}:{exp}");
45    let sig = sign_payload(secret, &payload);
46    format!("{user_id}.{exp}.{sig}")
47}
48
49pub fn parse_verify_token(secret: &str, token: &str) -> Result<i64> {
50    let mut parts = token.split('.');
51    let id: i64 = parts
52        .next()
53        .and_then(|s| s.parse().ok())
54        .ok_or_else(|| Error::BadRequest("invalid verification token".into()))?;
55    let exp: u64 = parts
56        .next()
57        .and_then(|s| s.parse().ok())
58        .ok_or_else(|| Error::BadRequest("invalid verification token".into()))?;
59    let sig = parts
60        .next()
61        .ok_or_else(|| Error::BadRequest("invalid verification token".into()))?;
62    if parts.next().is_some() {
63        return Err(Error::BadRequest("invalid verification token".into()));
64    }
65    if exp < now_secs() {
66        return Err(Error::BadRequest("verification token expired".into()));
67    }
68    let payload = format!("verify:{id}:{exp}");
69    if !verify_payload(secret, &payload, sig) {
70        return Err(Error::BadRequest("invalid verification token".into()));
71    }
72    Ok(id)
73}
74
75pub fn generate_recovery_codes(n: usize) -> Vec<String> {
76    (0..n)
77        .map(|_| {
78            let t = random_token();
79            format!("{}-{}", &t[..4], &t[4..8])
80        })
81        .collect()
82}
83
84pub fn hash_recovery_codes(codes: &[String]) -> Result<String> {
85    let hashed: Vec<String> = codes.iter().map(|c| hash_token(c)).collect();
86    serde_json::to_string(&hashed).map_err(|e| Error::Internal(e.to_string()))
87}
88
89pub fn consume_recovery_code(codes_json: &str, raw: &str) -> Result<Option<String>> {
90    let mut hashed: Vec<String> =
91        serde_json::from_str(codes_json).map_err(|e| Error::Internal(e.to_string()))?;
92    let want = hash_token(raw.trim());
93    let Some(pos) = hashed.iter().position(|h| h == &want) else {
94        return Ok(None);
95    };
96    hashed.remove(pos);
97    Ok(Some(
98        serde_json::to_string(&hashed).map_err(|e| Error::Internal(e.to_string()))?,
99    ))
100}