trillium_basic_auth/
lib.rs1#![forbid(unsafe_code)]
2#![deny(
3 clippy::dbg_macro,
4 missing_copy_implementations,
5 rustdoc::missing_crate_level_docs,
6 missing_debug_implementations,
7 missing_docs,
8 nonstandard_style,
9 unused_qualifications
10)]
11
12#[cfg(test)]
35#[doc = include_str!("../README.md")]
36mod readme {}
37
38use base64::{
39 Engine,
40 engine::general_purpose::{STANDARD as BASE64, STANDARD_NO_PAD as BASE64_NO_PAD},
41};
42use sha2::{Digest, Sha256};
43use std::{
44 fmt::{self, Debug, Formatter},
45 future::Future,
46 pin::Pin,
47};
48use subtle::ConstantTimeEq;
49use trillium::{
50 Conn, Handler,
51 KnownHeaderName::{Authorization, WwwAuthenticate},
52 Status,
53};
54
55const SCHEME: &str = "Basic ";
56
57#[derive(Debug)]
59pub struct BasicAuth {
60 validation: Validation,
61 realm: Option<String>,
62 www_authenticate: String,
63}
64
65enum Validation {
66 Digest([u8; 32]),
68 Predicate(PredicateFn),
69 AsyncPredicate(AsyncPredicateFn),
70}
71
72impl Debug for Validation {
73 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
74 let name = match self {
75 Self::Digest(_) => "Digest",
76 Self::Predicate(_) => "Predicate",
77 Self::AsyncPredicate(_) => "AsyncPredicate",
78 };
79 f.debug_tuple(name).field(&format_args!("..")).finish()
80 }
81}
82
83struct PredicateFn(Box<dyn Fn(&Credentials) -> bool + Send + Sync + 'static>);
84
85type BoxFuture = Pin<Box<dyn Future<Output = bool> + Send + 'static>>;
86struct AsyncPredicateFn(Box<dyn Fn(Credentials) -> BoxFuture + Send + Sync + 'static>);
87
88#[derive(Clone, PartialEq, Eq, fieldwork::Fieldwork)]
90#[fieldwork(get)]
91pub struct Credentials {
92 username: String,
94
95 password: String,
97}
98
99impl Debug for Credentials {
100 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
101 f.debug_struct("Credentials")
102 .field("username", &self.username)
103 .field("password", &"<<secret>>")
104 .finish()
105 }
106}
107
108impl Credentials {
109 pub fn new(username: impl Into<String>, password: impl Into<String>) -> Self {
114 Self {
115 username: username.into(),
116 password: password.into(),
117 }
118 }
119
120 pub fn from_conn(conn: &Conn) -> Option<Self> {
127 Self::from_header(conn.request_headers().get_str(Authorization)?)
128 }
129
130 fn from_header(header: &str) -> Option<Self> {
131 let token = header
132 .get(..SCHEME.len())
133 .filter(|scheme| scheme.eq_ignore_ascii_case(SCHEME))
134 .map(|scheme| &header[scheme.len()..])?;
135
136 let decoded = BASE64
137 .decode(token)
138 .or_else(|_| BASE64_NO_PAD.decode(token))
139 .ok()?;
140 let decoded = String::from_utf8(decoded).ok()?;
141 let (username, password) = decoded.split_once(':')?;
142 Some(Self::new(username, password))
143 }
144
145 fn digest(&self) -> [u8; 32] {
148 Sha256::new()
149 .chain_update(
150 u64::try_from(self.username.len())
151 .unwrap_or(u64::MAX)
152 .to_le_bytes(),
153 )
154 .chain_update(&self.username)
155 .chain_update(&self.password)
156 .finalize()
157 .into()
158 }
159}
160
161impl BasicAuth {
162 pub fn new(username: impl Into<String>, password: impl Into<String>) -> Self {
166 Self::from_validation(Validation::Digest(
167 Credentials::new(username, password).digest(),
168 ))
169 }
170
171 pub fn validate_fn<F>(predicate: F) -> Self
185 where
186 F: Fn(&Credentials) -> bool + Send + Sync + 'static,
187 {
188 Self::from_validation(Validation::Predicate(PredicateFn(Box::new(predicate))))
189 }
190
191 pub fn validate_async_fn<F, Fut>(predicate: F) -> Self
206 where
207 F: Fn(Credentials) -> Fut + Send + Sync + 'static,
208 Fut: Future<Output = bool> + Send + 'static,
209 {
210 Self::from_validation(Validation::AsyncPredicate(AsyncPredicateFn(Box::new(
211 move |credentials| Box::pin(predicate(credentials)),
212 ))))
213 }
214
215 fn from_validation(validation: Validation) -> Self {
216 Self {
217 validation,
218 realm: None,
219 www_authenticate: String::from("Basic"),
220 }
221 }
222
223 pub fn with_realm(mut self, realm: &str) -> Self {
225 self.www_authenticate = format!("Basic realm=\"{}\"", realm.replace('\"', "\\\""));
226 self.realm = Some(String::from(realm));
227 self
228 }
229
230 pub fn realm(&self) -> Option<&str> {
232 self.realm.as_deref()
233 }
234
235 async fn is_allowed(&self, credentials: &Credentials) -> bool {
236 match &self.validation {
237 Validation::Digest(expected) => credentials.digest().ct_eq(expected).into(),
238 Validation::Predicate(PredicateFn(predicate)) => predicate(credentials),
239 Validation::AsyncPredicate(AsyncPredicateFn(predicate)) => {
240 predicate(credentials.clone()).await
241 }
242 }
243 }
244
245 fn deny(&self, conn: Conn) -> Conn {
246 conn.with_status(Status::Unauthorized)
247 .with_response_header(WwwAuthenticate, self.www_authenticate.clone())
248 .halt()
249 }
250}
251
252struct AuthenticatedUsername(String);
253
254pub trait BasicAuthConnExt {
256 fn basic_auth_username(&self) -> Option<&str>;
258}
259
260impl BasicAuthConnExt for Conn {
261 fn basic_auth_username(&self) -> Option<&str> {
262 self.state::<AuthenticatedUsername>()
263 .map(|AuthenticatedUsername(username)| &**username)
264 }
265}
266
267impl Handler for BasicAuth {
268 async fn run(&self, conn: Conn) -> Conn {
269 let Some(credentials) = Credentials::from_conn(&conn) else {
270 return self.deny(conn);
271 };
272
273 if self.is_allowed(&credentials).await {
274 conn.with_state(AuthenticatedUsername(credentials.username))
275 } else {
276 self.deny(conn)
277 }
278 }
279}