Skip to main content

ch_config/config/
captcha.rs

1use super::{ConfigError, Result, variables};
2
3/// Captcha generation and verification policy.
4#[derive(Clone, Debug, Default, PartialEq)]
5pub struct CaptchaConfig {
6    /// Allowed challenge characters.
7    pub charset: String,
8    /// Noise intensity from zero through one.
9    pub noise_level: f32,
10    /// Challenge lifetime in seconds.
11    pub expires_after_secs: u64,
12    /// Maximum attempts per minute.
13    pub max_attempts_per_minute: u32,
14    /// Failed verification attempts allowed before a challenge is invalidated.
15    ///
16    /// The captcha verifier owns the attempt counter. This value is the
17    /// per-challenge ceiling and is independent of the issuance rate limit in
18    /// [`Self::max_attempts_per_minute`].
19    pub max_failures: u32,
20    /// Explicit development-only bypass flag.
21    pub bypass: bool,
22}
23
24impl CaptchaConfig {
25    /// Returns the shared production policy used by the web applications.
26    pub fn standard() -> Self {
27        Self {
28            charset: "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".to_owned(),
29            noise_level: 0.35,
30            expires_after_secs: 300,
31            max_attempts_per_minute: 12,
32            max_failures: 3,
33            bypass: false,
34        }
35    }
36
37    pub(crate) fn apply_environment(
38        &mut self,
39        value: &mut impl FnMut(&'static str) -> Result<Option<String>>,
40    ) -> Result<()> {
41        if let Some(charset) = value("CAPTCHA_CHARSET")? {
42            self.charset = charset;
43        }
44        if let Some(noise_level) = variables::parsed(
45            "CAPTCHA_NOISE_LEVEL",
46            value("CAPTCHA_NOISE_LEVEL")?,
47            "must be a number between zero and one",
48        )? {
49            self.noise_level = noise_level;
50        }
51        if let Some(expires_after_secs) = variables::parsed(
52            "CAPTCHA_EXPIRES_AFTER_SECS",
53            value("CAPTCHA_EXPIRES_AFTER_SECS")?,
54            "must be a positive integer number of seconds",
55        )? {
56            self.expires_after_secs = expires_after_secs;
57        }
58        if let Some(max_attempts_per_minute) = variables::parsed(
59            "CAPTCHA_MAX_ATTEMPTS_PER_MINUTE",
60            value("CAPTCHA_MAX_ATTEMPTS_PER_MINUTE")?,
61            "must be a positive integer",
62        )? {
63            self.max_attempts_per_minute = max_attempts_per_minute;
64        }
65        if let Some(max_failures) = variables::parsed(
66            "CAPTCHA_MAX_FAILURES",
67            value("CAPTCHA_MAX_FAILURES")?,
68            "must be a positive integer",
69        )? {
70            self.max_failures = max_failures;
71        }
72        if let Some(bypass) = variables::parsed(
73            "CAPTCHA_BYPASS",
74            value("CAPTCHA_BYPASS")?,
75            "must be either `true` or `false`",
76        )? {
77            self.bypass = bypass;
78        }
79        Ok(())
80    }
81
82    /// Reports whether a captcha policy is present.
83    pub fn is_configured(&self) -> bool {
84        !self.charset.is_empty()
85            || self.noise_level != 0.0
86            || self.expires_after_secs != 0
87            || self.max_attempts_per_minute != 0
88            || self.max_failures != 0
89            || self.bypass
90    }
91
92    /// Validates a complete captcha policy.
93    pub fn validate(&self) -> Result<()> {
94        if !self.is_configured() {
95            return Ok(());
96        }
97        if self.charset.trim().is_empty() {
98            return Err(ConfigError::validation(
99                "captcha.charset",
100                "must not be empty",
101            ));
102        }
103        if !(0.0..=1.0).contains(&self.noise_level) {
104            return Err(ConfigError::validation(
105                "captcha.noise_level",
106                "must be between zero and one",
107            ));
108        }
109        for (field, value) in [
110            ("captcha.expires_after_secs", self.expires_after_secs),
111            (
112                "captcha.max_attempts_per_minute",
113                u64::from(self.max_attempts_per_minute),
114            ),
115            ("captcha.max_failures", u64::from(self.max_failures)),
116        ] {
117            if value == 0 {
118                return Err(ConfigError::validation(field, "must be greater than zero"));
119            }
120        }
121        Ok(())
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn standard_policy_is_complete_and_safe() {
131        let policy = CaptchaConfig::standard();
132        policy.validate().expect("standard policy should validate");
133        assert!(!policy.bypass);
134        assert!(!policy.charset.contains(['I', 'O', '0', '1']));
135    }
136}