mockforge_http/auth/
admin_auth.rs1use 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
14const ADMIN_HASH_SALT: &str = "mockforge-admin-v1";
17
18#[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
30pub 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 !admin_auth_required {
44 debug!("Admin auth not required, allowing access");
45 return Ok(());
46 }
47
48 let auth_header = req.headers().get("authorization").and_then(|h| h.to_str().ok());
50
51 if let Some(auth_value) = auth_header {
52 if let Some(basic_creds) = auth_value.strip_prefix("Basic ") {
54 match general_purpose::STANDARD.decode(basic_creds) {
56 Ok(decoded) => {
57 if let Ok(creds_str) = String::from_utf8(decoded) {
58 if let Some((username, password)) = creds_str.split_once(':') {
60 if let (Some(expected_user), Some(expected_hash)) =
62 (admin_username, admin_password_hash)
63 {
64 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 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
105fn 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 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 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 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 assert!(!is_sha256_hex(
203 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"
204 ));
205 }
206}