1use bcrypt::non_truncating_verify as bcrypt_verify;
10use headers::{Authorization, HeaderMap, HeaderMapExt, authorization::Basic};
11use hyper::{Body, Request, Response, StatusCode, header::WWW_AUTHENTICATE};
12
13use crate::{Error, error_page, handler::RequestHandlerOpts, http_ext::MethodExt};
14
15const BCRYPT_MAX_PASSWORD_BYTES: usize = 72;
16
17pub(crate) fn init(credentials: &str, handler_opts: &mut RequestHandlerOpts) -> crate::Result<()> {
19 credentials.trim().clone_into(&mut handler_opts.basic_auth);
20 tracing::info!(
21 "basic authentication: enabled={}",
22 !handler_opts.basic_auth.is_empty()
23 );
24
25 if handler_opts.basic_auth.is_empty() {
26 return Ok(());
27 }
28
29 let Some((user_id, password_hash)) = handler_opts.basic_auth.split_once(':') else {
30 bail!("basic authentication credentials must be in the form `user_id:password_hash`");
31 };
32
33 if user_id.is_empty() {
34 bail!("basic authentication user ID is empty");
35 }
36 if password_hash.is_empty() {
37 bail!("basic authentication password hash is empty");
38 }
39
40 if let Err(err) = validate_hash(password_hash) {
41 bail!("basic authentication configured bcrypt hash is invalid: {err}");
42 }
43
44 tracing::warn!(
45 "basic authentication: bcrypt is limited to {BCRYPT_MAX_PASSWORD_BYTES} bytes per password; \
46 use ASCII-only passwords of {BCRYPT_MAX_PASSWORD_BYTES} characters or fewer to avoid authentication failures"
47 );
48
49 Ok(())
50}
51
52fn validate_hash(hash: &str) -> bcrypt::BcryptResult<()> {
54 bcrypt::non_truncating_verify("", hash)?;
55 Ok(())
56}
57
58pub(crate) fn pre_process<T>(
60 opts: &RequestHandlerOpts,
61 req: &Request<T>,
62) -> Option<Result<Response<Body>, Error>> {
63 if opts.basic_auth.is_empty() {
64 return None;
65 }
66
67 let method = req.method();
68 if method.is_options() {
69 return None;
70 }
71
72 let uri = req.uri();
73 if let Some((user_id, password)) = opts.basic_auth.split_once(':') {
74 let err = check_request(req.headers(), user_id, password).err()?;
75 tracing::warn!("basic authentication failed {:?}", err);
76 let mut result = error_page::error_response(
77 uri,
78 method,
79 &StatusCode::UNAUTHORIZED,
80 &opts.page404,
81 &opts.page50x,
82 );
83 if let Ok(ref mut resp) = result {
84 resp.headers_mut().insert(
85 WWW_AUTHENTICATE,
86 hyper::header::HeaderValue::from_static(
87 "Basic realm=\"Static Web Server\", charset=\"UTF-8\"",
88 ),
89 );
90 }
91 Some(result)
92 } else {
93 tracing::error!("invalid basic authentication `user_id:password` pairs");
94 Some(error_page::error_response(
95 uri,
96 method,
97 &StatusCode::INTERNAL_SERVER_ERROR,
98 &opts.page404,
99 &opts.page50x,
100 ))
101 }
102}
103
104pub fn check_request(headers: &HeaderMap, userid: &str, password: &str) -> Result<(), StatusCode> {
107 let credentials = headers
108 .typed_get::<Authorization<Basic>>()
109 .ok_or(StatusCode::UNAUTHORIZED)?;
110
111 let user_match = credentials.0.username() == userid;
112 let password_match = match bcrypt_verify(credentials.0.password(), password) {
113 Ok(matched) => matched,
114 Err(bcrypt::BcryptError::Truncation(len)) => {
115 tracing::warn!(
116 "basic auth password exceeds the bcrypt {BCRYPT_MAX_PASSWORD_BYTES}-byte limit ({len} bytes), returning 401"
117 );
118 false
119 }
120 Err(err) => {
121 tracing::error!("bcrypt password verification error: {:?}", err);
122 false
123 }
124 };
125 let valid = user_match && password_match;
126 valid.then_some(()).ok_or(StatusCode::UNAUTHORIZED)
127}
128
129#[cfg(test)]
130mod tests {
131 use super::{check_request, init, pre_process};
132 use crate::{Error, handler::RequestHandlerOpts};
133 use headers::{Authorization, HeaderMap, HeaderMapExt};
134 use hyper::{Body, Request, Response, StatusCode, header::WWW_AUTHENTICATE};
135
136 fn make_request(method: &str, auth_header: &str) -> Request<Body> {
137 let mut builder = Request::builder();
138 if !auth_header.is_empty() {
139 builder = builder.header("Authorization", auth_header);
140 }
141 builder.method(method).uri("/").body(Body::empty()).unwrap()
142 }
143
144 fn is_401(result: Option<Result<Response<Body>, Error>>) -> bool {
145 if let Some(Ok(response)) = result {
146 response.status() == StatusCode::UNAUTHORIZED
147 && response.headers().get(WWW_AUTHENTICATE).is_some()
148 } else {
149 false
150 }
151 }
152
153 fn is_500(result: Option<Result<Response<Body>, Error>>) -> bool {
154 if let Some(Ok(response)) = result {
155 response.status() == StatusCode::INTERNAL_SERVER_ERROR
156 } else {
157 false
158 }
159 }
160
161 #[test]
162 fn test_auth_disabled() {
163 assert!(
164 pre_process(
165 &RequestHandlerOpts {
166 basic_auth: "".into(),
167 ..Default::default()
168 },
169 &make_request("GET", "Basic anE6anE=")
170 )
171 .is_none()
172 );
173 }
174
175 #[test]
176 fn test_invalid_auth_configuration() {
177 assert!(is_500(pre_process(
178 &RequestHandlerOpts {
179 basic_auth: "xyz".into(),
180 ..Default::default()
181 },
182 &make_request("GET", "Basic anE6anE=")
183 )));
184 }
185
186 #[test]
187 fn test_init_with_valid_auth_configuration() {
188 let mut handler_opts = RequestHandlerOpts::default();
189 let hash = "$2y$05$32zazJ1yzhlDHnt26L3MFOgY0HVqPmDUvG0KUx6cjf9RDiUGp/M9q";
190
191 assert!(init(&format!("jq:{hash}"), &mut handler_opts).is_ok());
192 assert_eq!(handler_opts.basic_auth, format!("jq:{hash}"));
193 }
194
195 #[test]
196 fn test_init_with_invalid_auth_hash() {
197 let mut handler_opts = RequestHandlerOpts::default();
198
199 assert!(init("jq:not-a-bcrypt-hash", &mut handler_opts).is_err());
200 }
201
202 #[test]
203 fn test_options_request() {
204 assert!(
205 pre_process(
206 &RequestHandlerOpts {
207 basic_auth: "jq:$2y$05$32zazJ1yzhlDHnt26L3MFOgY0HVqPmDUvG0KUx6cjf9RDiUGp/M9q"
208 .into(),
209 ..Default::default()
210 },
211 &make_request("OPTIONS", "")
212 )
213 .is_none()
214 );
215 }
216
217 #[test]
218 fn test_valid_auth() {
219 let mut headers = HeaderMap::new();
220 headers.insert("Authorization", "Basic anE6anE=".parse().unwrap());
221 assert!(
222 check_request(
223 &headers,
224 "jq",
225 "$2y$05$32zazJ1yzhlDHnt26L3MFOgY0HVqPmDUvG0KUx6cjf9RDiUGp/M9q"
226 )
227 .is_ok()
228 );
229
230 assert!(
231 pre_process(
232 &RequestHandlerOpts {
233 basic_auth: "jq:$2y$05$32zazJ1yzhlDHnt26L3MFOgY0HVqPmDUvG0KUx6cjf9RDiUGp/M9q"
234 .into(),
235 ..Default::default()
236 },
237 &make_request("GET", "Basic anE6anE=")
238 )
239 .is_none()
240 );
241 }
242
243 #[test]
244 fn test_invalid_auth_header() {
245 let headers = HeaderMap::new();
246 assert!(check_request(&headers, "jq", "").is_err());
247
248 assert!(is_401(pre_process(
249 &RequestHandlerOpts {
250 basic_auth: "jq:".into(),
251 ..Default::default()
252 },
253 &make_request("GET", "")
254 )));
255 }
256
257 #[test]
258 fn test_invalid_auth_pairs() {
259 let mut headers = HeaderMap::new();
260 headers.insert("Authorization", "Basic anE6anE=".parse().unwrap());
261 assert!(check_request(&headers, "xyz", "").is_err());
262
263 assert!(is_401(pre_process(
264 &RequestHandlerOpts {
265 basic_auth: "xyz:".into(),
266 ..Default::default()
267 },
268 &make_request("GET", "Basic anE6anE=")
269 )));
270 }
271
272 #[test]
273 fn test_invalid_auth() {
274 let mut headers = HeaderMap::new();
275 headers.insert("Authorization", "Basic anE6anE=".parse().unwrap());
276 assert!(
277 check_request(
278 &headers,
279 "abc",
280 "$2y$05$32zazJ1yzhlDHnt26L3MFOgY0HVqPmDUvG0KUx6cjf9RDiUGp/M9q"
281 )
282 .is_err()
283 );
284 assert!(check_request(&headers, "jq", "password").is_err());
285 assert!(check_request(&headers, "", "password").is_err());
286 assert!(check_request(&headers, "jq", "").is_err());
287
288 assert!(is_401(pre_process(
289 &RequestHandlerOpts {
290 basic_auth: "abc:$2y$05$32zazJ1yzhlDHnt26L3MFOgY0HVqPmDUvG0KUx6cjf9RDiUGp/M9q"
291 .into(),
292 ..Default::default()
293 },
294 &make_request("GET", "Basic anE6anE=")
295 )));
296 assert!(is_401(pre_process(
297 &RequestHandlerOpts {
298 basic_auth: "jq:password".into(),
299 ..Default::default()
300 },
301 &make_request("GET", "Basic anE6anE=")
302 )));
303 assert!(is_401(pre_process(
304 &RequestHandlerOpts {
305 basic_auth: ":password".into(),
306 ..Default::default()
307 },
308 &make_request("GET", "Basic anE6anE=")
309 )));
310 assert!(is_401(pre_process(
311 &RequestHandlerOpts {
312 basic_auth: "jq:".into(),
313 ..Default::default()
314 },
315 &make_request("GET", "Basic anE6anE=")
316 )));
317 }
318
319 #[test]
320 fn test_invalid_auth_encoding() {
321 let mut headers = HeaderMap::new();
322 headers.insert("Authorization", "Basic xyz".parse().unwrap());
323 assert!(
324 check_request(
325 &headers,
326 "jq",
327 "$2y$05$32zazJ1yzhlDHnt26L3MFOgY0HVqPmDUvG0KUx6cjf9RDiUGp/M9q"
328 )
329 .is_err()
330 );
331
332 assert!(is_401(pre_process(
333 &RequestHandlerOpts {
334 basic_auth: "jq:$2y$05$32zazJ1yzhlDHnt26L3MFOgY0HVqPmDUvG0KUx6cjf9RDiUGp/M9q"
335 .into(),
336 ..Default::default()
337 },
338 &make_request("GET", "Basic xyz")
339 )));
340 }
341
342 #[test]
343 fn test_invalid_auth_encoding2() {
344 let mut headers = HeaderMap::new();
345 headers.insert("Authorization", "abcd".parse().unwrap());
346 assert!(
347 check_request(
348 &headers,
349 "jq",
350 "$2y$05$32zazJ1yzhlDHnt26L3MFOgY0HVqPmDUvG0KUx6cjf9RDiUGp/M9q"
351 )
352 .is_err()
353 );
354
355 assert!(is_401(pre_process(
356 &RequestHandlerOpts {
357 basic_auth: "jq:$2y$05$32zazJ1yzhlDHnt26L3MFOgY0HVqPmDUvG0KUx6cjf9RDiUGp/M9q"
358 .into(),
359 ..Default::default()
360 },
361 &make_request("GET", "abcd")
362 )));
363 }
364
365 #[test]
366 fn test_bcrypt_password_truncation_rejected() {
367 let password_71 = "a".repeat(71);
368 let password_72 = password_71.clone() + "x";
369 let hash = bcrypt::hash(&password_71, 4).unwrap();
370
371 let mut headers = HeaderMap::new();
372 headers.typed_insert(Authorization::basic("jq", &password_71));
373 assert!(check_request(&headers, "jq", &hash).is_ok());
374
375 let mut headers = HeaderMap::new();
376 headers.typed_insert(Authorization::basic("jq", &password_72));
377 assert!(check_request(&headers, "jq", &hash).is_err());
378 }
379}