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//! Passwords are stored as salted SHA-256 hashes rather than plain text.
5
6use axum::body::Body;
7use axum::http::header::HeaderValue;
8use axum::http::{Request, StatusCode};
9use axum::response::Response;
10use base64::{engine::general_purpose, Engine as _};
11use sha2::{Digest, Sha256};
12use tracing::{debug, warn};
13
14/// Salt used for admin password hashing.
15/// This provides defense against rainbow table attacks on stored hashes.
16const ADMIN_HASH_SALT: &str = "mockforge-admin-v1";
17
18/// Hash a password with SHA-256 and a fixed salt.
19///
20/// Returns a hex-encoded hash string suitable for storage and comparison.
21/// The salt is prepended to the password before hashing.
22#[must_use]
23pub fn hash_admin_password(password: &str) -> String {
24    let mut hasher = Sha256::new();
25    hasher.update(ADMIN_HASH_SALT.as_bytes());
26    hasher.update(password.as_bytes());
27    hex::encode(hasher.finalize())
28}
29
30/// Check if admin authentication is required and valid.
31///
32/// The `admin_password_hash` parameter should contain a SHA-256 hash
33/// produced by [`hash_admin_password`]. If a plain-text password is
34/// passed instead (i.e. it is not a valid 64-char hex string), it will
35/// be hashed on the fly for backward compatibility.
36pub fn check_admin_auth(
37    req: &Request<Body>,
38    admin_auth_required: bool,
39    admin_username: &Option<String>,
40    admin_password_hash: &Option<String>,
41) -> Result<(), Response> {
42    // If auth not required, allow through
43    if !admin_auth_required {
44        debug!("Admin auth not required, allowing access");
45        return Ok(());
46    }
47
48    // Get authorization header
49    let auth_header = req.headers().get("authorization").and_then(|h| h.to_str().ok());
50
51    if let Some(auth_value) = auth_header {
52        // Check if it's Basic auth
53        if let Some(basic_creds) = auth_value.strip_prefix("Basic ") {
54            // Decode base64 credentials
55            match general_purpose::STANDARD.decode(basic_creds) {
56                Ok(decoded) => {
57                    if let Ok(creds_str) = String::from_utf8(decoded) {
58                        // Split on first colon
59                        if let Some((username, password)) = creds_str.split_once(':') {
60                            // Compare with configured credentials
61                            if let (Some(expected_user), Some(expected_hash)) =
62                                (admin_username, admin_password_hash)
63                            {
64                                // Normalize: if the stored value looks like a raw password
65                                // (not a 64-char hex string), hash it for comparison.
66                                let effective_hash = if is_sha256_hex(expected_hash) {
67                                    expected_hash.clone()
68                                } else {
69                                    hash_admin_password(expected_hash)
70                                };
71
72                                let incoming_hash = hash_admin_password(password);
73
74                                if username == expected_user && incoming_hash == effective_hash {
75                                    debug!("Admin authentication successful");
76                                    return Ok(());
77                                }
78                            }
79                        }
80                    }
81                }
82                Err(e) => {
83                    warn!("Failed to decode admin credentials: {}", e);
84                }
85            }
86        }
87    }
88
89    // Authentication failed
90    warn!("Admin authentication failed or missing");
91    let mut res = Response::new(Body::from(
92        serde_json::json!({
93            "error": "Authentication required",
94            "message": "Admin UI requires authentication"
95        })
96        .to_string(),
97    ));
98    *res.status_mut() = StatusCode::UNAUTHORIZED;
99    res.headers_mut()
100        .insert("www-authenticate", HeaderValue::from_static("Basic realm=\"MockForge Admin\""));
101
102    Err(res)
103}
104
105/// Check whether a string looks like a SHA-256 hex digest (exactly 64 hex chars).
106fn is_sha256_hex(s: &str) -> bool {
107    s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit())
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use axum::http::Request;
114
115    #[test]
116    fn test_admin_auth_not_required() {
117        let req = Request::builder().body(Body::empty()).unwrap();
118        assert!(check_admin_auth(&req, false, &None, &None).is_ok());
119    }
120
121    #[test]
122    fn test_admin_auth_missing() {
123        let req = Request::builder().body(Body::empty()).unwrap();
124        let username = Some("admin".to_string());
125        let password_hash = Some(hash_admin_password("secret"));
126        assert!(check_admin_auth(&req, true, &username, &password_hash).is_err());
127    }
128
129    #[test]
130    fn test_admin_auth_valid_with_hash() {
131        let username = Some("admin".to_string());
132        let password_hash = Some(hash_admin_password("secret"));
133
134        // Create Basic auth header: admin:secret
135        let credentials = general_purpose::STANDARD.encode("admin:secret");
136        let auth_value = format!("Basic {credentials}");
137
138        let req = Request::builder()
139            .header("authorization", auth_value)
140            .body(Body::empty())
141            .unwrap();
142
143        assert!(check_admin_auth(&req, true, &username, &password_hash).is_ok());
144    }
145
146    #[test]
147    fn test_admin_auth_valid_with_plain_text_compat() {
148        // Backward compatibility: plain-text password is accepted and hashed on the fly
149        let username = Some("admin".to_string());
150        let password = Some("secret".to_string());
151
152        let credentials = general_purpose::STANDARD.encode("admin:secret");
153        let auth_value = format!("Basic {credentials}");
154
155        let req = Request::builder()
156            .header("authorization", auth_value)
157            .body(Body::empty())
158            .unwrap();
159
160        assert!(check_admin_auth(&req, true, &username, &password).is_ok());
161    }
162
163    #[test]
164    fn test_admin_auth_invalid_password() {
165        let username = Some("admin".to_string());
166        let password_hash = Some(hash_admin_password("secret"));
167
168        // Wrong password
169        let credentials = general_purpose::STANDARD.encode("admin:wrong");
170        let auth_value = format!("Basic {credentials}");
171
172        let req = Request::builder()
173            .header("authorization", auth_value)
174            .body(Body::empty())
175            .unwrap();
176
177        assert!(check_admin_auth(&req, true, &username, &password_hash).is_err());
178    }
179
180    #[test]
181    fn test_hash_admin_password_deterministic() {
182        let h1 = hash_admin_password("test123");
183        let h2 = hash_admin_password("test123");
184        assert_eq!(h1, h2);
185    }
186
187    #[test]
188    fn test_hash_admin_password_different_inputs() {
189        let h1 = hash_admin_password("password1");
190        let h2 = hash_admin_password("password2");
191        assert_ne!(h1, h2);
192    }
193
194    #[test]
195    fn test_is_sha256_hex() {
196        assert!(is_sha256_hex(
197            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
198        ));
199        assert!(!is_sha256_hex("short"));
200        assert!(!is_sha256_hex("secret"));
201        // 64 chars but not hex
202        assert!(!is_sha256_hex(
203            "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"
204        ));
205    }
206}