Skip to main content

mockforge_http/auth/
admin_auth.rs

1//! Admin UI authentication
2//!
3//! This module provides authentication for admin UI endpoints
4
5use axum::body::Body;
6use axum::http::header::HeaderValue;
7use axum::http::{Request, StatusCode};
8use axum::response::Response;
9use base64::{engine::general_purpose, Engine as _};
10use subtle::ConstantTimeEq;
11use tracing::{debug, warn};
12
13/// Length-safe constant-time byte equality.
14///
15/// `subtle`'s `[u8]` implementation returns a zero `Choice` when the operand
16/// lengths differ, so no length information leaks through an early return.
17fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
18    a.ct_eq(b).into()
19}
20
21/// Verify a supplied password against the configured admin secret.
22///
23/// The configured value may be either plaintext or a bcrypt PHC hash
24/// (`$2b$…`, same format `mockforge_registry_core::auth::hash_password`
25/// produces). Storing a hash means a config-file or env leak no longer
26/// leaks the working credential. Verification cost is identical for both
27/// branches from the caller's perspective.
28fn verify_admin_password(supplied: &str, configured: &str) -> bool {
29    if configured.starts_with("$2") {
30        match bcrypt::verify(supplied, configured) {
31            Ok(ok) => ok,
32            Err(e) => {
33                warn!("Malformed bcrypt hash in admin password config: {}", e);
34                false
35            }
36        }
37    } else {
38        constant_time_eq(supplied.as_bytes(), configured.as_bytes())
39    }
40}
41
42/// Check if admin authentication is required and valid
43pub fn check_admin_auth(
44    req: &Request<Body>,
45    admin_auth_required: bool,
46    admin_username: &Option<String>,
47    admin_password: &Option<String>,
48) -> Result<(), Response> {
49    // If auth not required, allow through
50    if !admin_auth_required {
51        debug!("Admin auth not required, allowing access");
52        return Ok(());
53    }
54
55    // Get authorization header
56    let auth_header = req.headers().get("authorization").and_then(|h| h.to_str().ok());
57
58    if let Some(auth_value) = auth_header {
59        // Check if it's Basic auth
60        if let Some(basic_creds) = auth_value.strip_prefix("Basic ") {
61            // Decode base64 credentials
62            match general_purpose::STANDARD.decode(basic_creds) {
63                Ok(decoded) => {
64                    if let Ok(creds_str) = String::from_utf8(decoded) {
65                        // Split on first colon
66                        if let Some((username, password)) = creds_str.split_once(':') {
67                            // Compare with configured credentials
68                            if let (Some(expected_user), Some(expected_pass)) =
69                                (admin_username, admin_password)
70                            {
71                                // Username compare is constant-time; password
72                                // goes through the hash-aware verifier (bcrypt
73                                // verify is itself constant-time in the digest).
74                                let user_ok =
75                                    constant_time_eq(username.as_bytes(), expected_user.as_bytes());
76                                if user_ok && verify_admin_password(password, expected_pass) {
77                                    debug!("Admin authentication successful");
78                                    return Ok(());
79                                }
80                            }
81                        }
82                    }
83                }
84                Err(e) => {
85                    warn!("Failed to decode admin credentials: {}", e);
86                }
87            }
88        }
89    }
90
91    // Authentication failed
92    warn!("Admin authentication failed or missing");
93    let mut res = Response::new(Body::from(
94        serde_json::json!({
95            "error": "Authentication required",
96            "message": "Admin UI requires authentication"
97        })
98        .to_string(),
99    ));
100    *res.status_mut() = StatusCode::UNAUTHORIZED;
101    res.headers_mut()
102        .insert("www-authenticate", HeaderValue::from_static("Basic realm=\"MockForge Admin\""));
103
104    Err(res)
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use axum::http::Request;
111
112    #[test]
113    fn test_admin_auth_not_required() {
114        let req = Request::builder().body(Body::empty()).unwrap();
115        assert!(check_admin_auth(&req, false, &None, &None).is_ok());
116    }
117
118    #[test]
119    fn test_admin_auth_missing() {
120        let req = Request::builder().body(Body::empty()).unwrap();
121        let username = Some("admin".to_string());
122        let password = Some("secret".to_string());
123        assert!(check_admin_auth(&req, true, &username, &password).is_err());
124    }
125
126    #[test]
127    fn test_admin_auth_valid() {
128        let username = Some("admin".to_string());
129        let password = Some("secret".to_string());
130
131        // Create Basic auth header: admin:secret
132        let credentials = general_purpose::STANDARD.encode("admin:secret");
133        let auth_value = format!("Basic {}", credentials);
134
135        let req = Request::builder()
136            .header("authorization", auth_value)
137            .body(Body::empty())
138            .unwrap();
139
140        assert!(check_admin_auth(&req, true, &username, &password).is_ok());
141    }
142
143    #[test]
144    fn test_admin_auth_invalid_password() {
145        let username = Some("admin".to_string());
146        let password = Some("secret".to_string());
147
148        // Wrong password
149        let credentials = general_purpose::STANDARD.encode("admin:wrong");
150        let auth_value = format!("Basic {}", credentials);
151
152        let req = Request::builder()
153            .header("authorization", auth_value)
154            .body(Body::empty())
155            .unwrap();
156
157        assert!(check_admin_auth(&req, true, &username, &password).is_err());
158    }
159
160    #[test]
161    fn test_admin_auth_bcrypt_hashed_password() {
162        let username = Some("admin".to_string());
163        // Config stores a bcrypt hash, not the plaintext.
164        let hashed = bcrypt::hash("secret", bcrypt::DEFAULT_COST).unwrap();
165        let password = Some(hashed);
166
167        let credentials = general_purpose::STANDARD.encode("admin:secret");
168        let req = Request::builder()
169            .header("authorization", format!("Basic {}", credentials))
170            .body(Body::empty())
171            .unwrap();
172        assert!(check_admin_auth(&req, true, &username, &password).is_ok());
173
174        // Wrong password against a stored hash must fail.
175        let wrong = general_purpose::STANDARD.encode("admin:wrong");
176        let req = Request::builder()
177            .header("authorization", format!("Basic {}", wrong))
178            .body(Body::empty())
179            .unwrap();
180        assert!(check_admin_auth(&req, true, &username, &password).is_err());
181    }
182}