Skip to main content

static_web_server/
basic_auth.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// This file is part of Static Web Server.
3// See https://static-web-server.net/ for more information
4// Copyright (C) 2019-present Jose Quintana <joseluisq.net>
5
6//! Basic HTTP Authorization Schema module.
7//!
8
9use 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
17/// Initializes `Basic` HTTP Authorization handling
18pub(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
52/// Validates that a configured bcrypt hash is well-formed.
53fn validate_hash(hash: &str) -> bcrypt::BcryptResult<()> {
54    bcrypt::non_truncating_verify("", hash)?;
55    Ok(())
56}
57
58/// Handles `Basic` HTTP Authorization Schema
59pub(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
104/// Check for a `Basic` HTTP Authorization Schema of an incoming request
105/// and uses `bcrypt` for password hashing verification.
106pub 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}