1use http::StatusCode;
2use securitydept_creds::{
3 BasicAuthCred, BasicAuthCredsValidator, CredsError, MapBasicAuthCredsValidator,
4 parse_basic_auth_header_opt,
5};
6use securitydept_realip::ResolvedClientIp;
7use securitydept_utils::{
8 error::{ErrorPresentation, ToErrorPresentation, UserRecovery},
9 http::{HttpResponse, ToHttpStatus},
10 observability::{
11 AuthFlowDiagnosis, AuthFlowDiagnosisField, AuthFlowDiagnosisOutcome, AuthFlowOperation,
12 DiagnosedResult,
13 },
14};
15use serde::{Deserialize, Serialize};
16use snafu::Snafu;
17
18use crate::{BasicAuthContext, BasicAuthContextError, BasicAuthZone};
19
20fn basic_auth_diagnosis(operation: &str) -> AuthFlowDiagnosis {
21 AuthFlowDiagnosis::started(operation).field(AuthFlowDiagnosisField::AUTH_FAMILY, "basic-auth")
22}
23
24#[derive(Debug, Snafu)]
26pub enum BasicAuthContextServiceError {
27 #[snafu(transparent)]
28 BasicAuthContext { source: BasicAuthContextError },
29 #[snafu(transparent)]
30 Creds { source: CredsError },
31}
32
33impl ToHttpStatus for BasicAuthContextServiceError {
34 fn to_http_status(&self) -> StatusCode {
35 match self {
36 Self::BasicAuthContext { .. } => StatusCode::INTERNAL_SERVER_ERROR,
37 Self::Creds { source } => source.to_http_status(),
38 }
39 }
40}
41
42impl ToErrorPresentation for BasicAuthContextServiceError {
43 fn to_error_presentation(&self) -> ErrorPresentation {
44 match self {
45 Self::BasicAuthContext { .. } => ErrorPresentation::new(
46 "basic_auth_context_invalid",
47 "Basic-auth context is misconfigured.",
48 UserRecovery::ContactSupport,
49 ),
50 Self::Creds { source } => source.to_error_presentation(),
51 }
52 }
53}
54
55pub struct BasicAuthContextService<'a, Creds>
60where
61 Creds: BasicAuthCred + Serialize + for<'de> Deserialize<'de>,
62{
63 basic_auth_context: &'a BasicAuthContext<Creds>,
64}
65
66impl<'a, Creds> BasicAuthContextService<'a, Creds>
67where
68 Creds: BasicAuthCred + Serialize + for<'de> Deserialize<'de>,
69{
70 pub fn new(
71 basic_auth_context: &'a BasicAuthContext<Creds>,
72 ) -> Result<Self, BasicAuthContextServiceError> {
73 MapBasicAuthCredsValidator::from_config(&basic_auth_context.creds)
74 .map_err(|source| BasicAuthContextServiceError::Creds { source })?;
75
76 Ok(Self { basic_auth_context })
77 }
78
79 pub fn context(&self) -> &BasicAuthContext<Creds> {
80 self.basic_auth_context
81 }
82
83 pub fn login(
84 &self,
85 request_path: &str,
86 authorization_header: Option<&str>,
87 requested_post_auth_redirect_uri: Option<&str>,
88 resolved_client_ip: Option<&ResolvedClientIp>,
89 ) -> Result<HttpResponse, BasicAuthContextServiceError> {
90 self.login_diagnosed(
91 request_path,
92 authorization_header,
93 requested_post_auth_redirect_uri,
94 resolved_client_ip,
95 )
96 .into_result()
97 }
98
99 pub fn login_diagnosed(
100 &self,
101 request_path: &str,
102 authorization_header: Option<&str>,
103 requested_post_auth_redirect_uri: Option<&str>,
104 resolved_client_ip: Option<&ResolvedClientIp>,
105 ) -> DiagnosedResult<HttpResponse, BasicAuthContextServiceError> {
106 let diagnosis = basic_auth_diagnosis(AuthFlowOperation::BASIC_AUTH_LOGIN)
107 .field(AuthFlowDiagnosisField::REQUEST_PATH, request_path)
108 .field("authorization_present", authorization_header.is_some())
109 .field(
110 "has_requested_post_auth_redirect_uri",
111 requested_post_auth_redirect_uri.is_some(),
112 )
113 .field(
114 AuthFlowDiagnosisField::RESOLVED_CLIENT_IP_PRESENT,
115 resolved_client_ip.is_some(),
116 );
117 let Some(zone) = self.basic_auth_context.zone_for_request_path(request_path) else {
118 return DiagnosedResult::success(
119 diagnosis
120 .with_outcome(AuthFlowDiagnosisOutcome::Rejected)
121 .field(AuthFlowDiagnosisField::REASON, "zone_not_found")
122 .field(
123 AuthFlowDiagnosisField::HTTP_STATUS,
124 StatusCode::NOT_FOUND.as_u16(),
125 ),
126 HttpResponse::new(StatusCode::NOT_FOUND),
127 );
128 };
129
130 let real_ip_allowed = match self.real_ip_allowed(resolved_client_ip) {
131 Ok(allowed) => allowed,
132 Err(source) => {
133 return DiagnosedResult::failure(
134 diagnosis
135 .clone()
136 .with_outcome(AuthFlowDiagnosisOutcome::Failed)
137 .field(AuthFlowDiagnosisField::REASON, "real_ip_resolution_failed"),
138 source,
139 );
140 }
141 };
142 if !real_ip_allowed {
143 return DiagnosedResult::success(
144 diagnosis
145 .with_outcome(AuthFlowDiagnosisOutcome::Rejected)
146 .field(AuthFlowDiagnosisField::REASON, "real_ip_forbidden")
147 .field(
148 AuthFlowDiagnosisField::HTTP_STATUS,
149 StatusCode::FORBIDDEN.as_u16(),
150 ),
151 HttpResponse::new(StatusCode::FORBIDDEN),
152 );
153 }
154
155 let authenticated = match self.verify_basic_auth(authorization_header) {
156 Ok(authenticated) => authenticated,
157 Err(source) => {
158 return DiagnosedResult::failure(
159 diagnosis
160 .clone()
161 .with_outcome(AuthFlowDiagnosisOutcome::Failed)
162 .field(
163 AuthFlowDiagnosisField::REASON,
164 "credential_validation_failed",
165 ),
166 source,
167 );
168 }
169 };
170
171 if authenticated {
172 match zone.login_success_response(requested_post_auth_redirect_uri) {
173 Ok(response) => DiagnosedResult::success(
174 diagnosis
175 .with_outcome(AuthFlowDiagnosisOutcome::Succeeded)
176 .field("authenticated", true)
177 .field(
178 AuthFlowDiagnosisField::HTTP_STATUS,
179 response.status.as_u16(),
180 ),
181 response,
182 ),
183 Err(source) => DiagnosedResult::failure(
184 diagnosis
185 .with_outcome(AuthFlowDiagnosisOutcome::Failed)
186 .field("authenticated", true)
187 .field(AuthFlowDiagnosisField::REASON, "post_auth_redirect_invalid"),
188 BasicAuthContextServiceError::BasicAuthContext { source },
189 ),
190 }
191 } else {
192 let response = zone.login_challenge_response();
193 DiagnosedResult::success(
194 diagnosis
195 .with_outcome(AuthFlowDiagnosisOutcome::Rejected)
196 .field("authenticated", false)
197 .field(AuthFlowDiagnosisField::REASON, "challenge_required")
198 .field(
199 AuthFlowDiagnosisField::HTTP_STATUS,
200 response.status.as_u16(),
201 ),
202 response,
203 )
204 }
205 }
206
207 pub fn logout(&self, request_path: &str) -> HttpResponse {
208 self.logout_diagnosed(request_path)
209 .into_result()
210 .expect("basic-auth logout diagnosis should not fail")
211 }
212
213 pub fn logout_diagnosed(
214 &self,
215 request_path: &str,
216 ) -> DiagnosedResult<HttpResponse, BasicAuthContextServiceError> {
217 let diagnosis = basic_auth_diagnosis(AuthFlowOperation::BASIC_AUTH_LOGOUT)
218 .field(AuthFlowDiagnosisField::REQUEST_PATH, request_path);
219 if let Some(protocol_response) = self.logout_protocol_response(request_path) {
220 let response = protocol_response.into_http_response();
221 DiagnosedResult::success(
222 diagnosis
223 .with_outcome(AuthFlowDiagnosisOutcome::Succeeded)
224 .field("response_kind", "logout_poison")
225 .field(
226 AuthFlowDiagnosisField::HTTP_STATUS,
227 response.status.as_u16(),
228 ),
229 response,
230 )
231 } else {
232 DiagnosedResult::success(
233 diagnosis
234 .with_outcome(AuthFlowDiagnosisOutcome::Rejected)
235 .field("response_kind", "not_found")
236 .field(
237 AuthFlowDiagnosisField::HTTP_STATUS,
238 StatusCode::NOT_FOUND.as_u16(),
239 ),
240 HttpResponse::new(StatusCode::NOT_FOUND),
241 )
242 }
243 }
244
245 pub fn logout_protocol_response(
246 &self,
247 request_path: &str,
248 ) -> Option<crate::BasicAuthProtocolResponse> {
249 self.basic_auth_context
250 .zone_for_request_path(request_path)
251 .map(BasicAuthZone::logout_poison_protocol_response)
252 }
253
254 pub fn authorize_request(
255 &self,
256 authorization_header: Option<&str>,
257 resolved_client_ip: Option<&ResolvedClientIp>,
258 ) -> Result<bool, BasicAuthContextServiceError> {
259 self.authorize_request_diagnosed(authorization_header, resolved_client_ip)
260 .into_result()
261 }
262
263 pub fn authorize_request_diagnosed(
264 &self,
265 authorization_header: Option<&str>,
266 resolved_client_ip: Option<&ResolvedClientIp>,
267 ) -> DiagnosedResult<bool, BasicAuthContextServiceError> {
268 let diagnosis = basic_auth_diagnosis(AuthFlowOperation::BASIC_AUTH_AUTHORIZE)
269 .field("authorization_present", authorization_header.is_some())
270 .field(
271 AuthFlowDiagnosisField::RESOLVED_CLIENT_IP_PRESENT,
272 resolved_client_ip.is_some(),
273 );
274
275 let real_ip_allowed = match self.real_ip_allowed(resolved_client_ip) {
276 Ok(allowed) => allowed,
277 Err(source) => {
278 return DiagnosedResult::failure(
279 diagnosis
280 .clone()
281 .with_outcome(AuthFlowDiagnosisOutcome::Failed)
282 .field(AuthFlowDiagnosisField::REASON, "real_ip_resolution_failed"),
283 source,
284 );
285 }
286 };
287 if !real_ip_allowed {
288 return DiagnosedResult::success(
289 diagnosis
290 .with_outcome(AuthFlowDiagnosisOutcome::Rejected)
291 .field("authorized", false)
292 .field(AuthFlowDiagnosisField::REASON, "real_ip_forbidden"),
293 false,
294 );
295 }
296
297 match self.verify_basic_auth(authorization_header) {
298 Ok(authorized) => {
299 let outcome = if authorized {
300 AuthFlowDiagnosisOutcome::Succeeded
301 } else {
302 AuthFlowDiagnosisOutcome::Rejected
303 };
304 let reason = if authorized {
305 "credentials_verified"
306 } else {
307 "credentials_missing_or_invalid"
308 };
309 DiagnosedResult::success(
310 diagnosis
311 .with_outcome(outcome)
312 .field("authorized", authorized)
313 .field(AuthFlowDiagnosisField::REASON, reason),
314 authorized,
315 )
316 }
317 Err(source) => DiagnosedResult::failure(
318 diagnosis
319 .with_outcome(AuthFlowDiagnosisOutcome::Failed)
320 .field(
321 AuthFlowDiagnosisField::REASON,
322 "credential_validation_failed",
323 ),
324 source,
325 ),
326 }
327 }
328
329 fn verify_basic_auth(
330 &self,
331 authorization_header: Option<&str>,
332 ) -> Result<bool, BasicAuthContextServiceError> {
333 let Some(authorization_header) = authorization_header else {
334 return Ok(false);
335 };
336 let Some((username, password)) = parse_basic_auth_header_opt(authorization_header) else {
337 return Ok(false);
338 };
339 let validator = MapBasicAuthCredsValidator::from_config(&self.basic_auth_context.creds)
340 .map_err(|source| BasicAuthContextServiceError::Creds { source })?;
341
342 validator
343 .verify_cred(&username, &password)
344 .map(|result| result.is_some())
345 .map_err(|source| BasicAuthContextServiceError::Creds { source })
346 }
347
348 fn real_ip_allowed(
349 &self,
350 resolved_client_ip: Option<&ResolvedClientIp>,
351 ) -> Result<bool, BasicAuthContextServiceError> {
352 match (
353 self.basic_auth_context.real_ip_access.is_some(),
354 resolved_client_ip,
355 ) {
356 (false, _) => Ok(true),
357 (true, None) => Ok(false),
358 (true, Some(resolved_client_ip)) => self
359 .basic_auth_context
360 .ensure_real_ip_allowed(resolved_client_ip)
361 .map(|_| true)
362 .or_else(|error| match error {
363 BasicAuthContextError::RealIp { .. } => Ok(false),
364 source => Err(BasicAuthContextServiceError::BasicAuthContext { source }),
365 }),
366 }
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373 use crate::{BasicAuthContext, BasicAuthContextConfig, BasicAuthZoneConfig};
374
375 #[derive(Debug, Clone, Serialize, Deserialize, Default)]
376 struct TestCred {
377 username: String,
378 password: String,
379 }
380
381 impl BasicAuthCred for TestCred {
382 fn username(&self) -> &str {
383 &self.username
384 }
385
386 fn verify_password(&self, password: &str) -> securitydept_creds::CredsResult<bool> {
387 Ok(password == self.password)
388 }
389 }
390
391 fn test_context() -> BasicAuthContext<TestCred> {
392 BasicAuthContext::from_config(
393 BasicAuthContextConfig::builder()
394 .creds(securitydept_creds::BasicAuthCredsConfig {
395 users: vec![TestCred {
396 username: "admin".to_string(),
397 password: "secret".to_string(),
398 }],
399 })
400 .zones(vec![BasicAuthZoneConfig::default()])
401 .build(),
402 )
403 .expect("context should build")
404 }
405
406 fn test_context_with_dynamic_redirect() -> BasicAuthContext<TestCred> {
407 BasicAuthContext::from_config(
408 BasicAuthContextConfig::builder()
409 .creds(securitydept_creds::BasicAuthCredsConfig {
410 users: vec![TestCred {
411 username: "admin".to_string(),
412 password: "secret".to_string(),
413 }],
414 })
415 .zones(vec![BasicAuthZoneConfig::builder()
416 .post_auth_redirect(
417 securitydept_utils::redirect::RedirectTargetConfig::dynamic_default_and_dynamic_targets(
418 "/",
419 [securitydept_utils::redirect::RedirectTargetRule::Strict {
420 value: "/console".to_string(),
421 }],
422 ),
423 )
424 .build()])
425 .build(),
426 )
427 .expect("context should build")
428 }
429
430 #[test]
431 fn login_without_credentials_returns_challenge() {
432 let context = test_context();
433 let service = BasicAuthContextService::new(&context).expect("service should build");
434 let diagnosed = service.login_diagnosed("/basic/login", None, None, None);
435 let response = diagnosed
436 .result()
437 .as_ref()
438 .expect("login should return response");
439
440 assert_eq!(response.status, StatusCode::UNAUTHORIZED);
441 assert_eq!(
442 diagnosed.diagnosis().operation,
443 AuthFlowOperation::BASIC_AUTH_LOGIN
444 );
445 assert_eq!(
446 diagnosed.diagnosis().outcome,
447 AuthFlowDiagnosisOutcome::Rejected
448 );
449 assert_eq!(diagnosed.diagnosis().fields["reason"], "challenge_required");
450 }
451
452 #[test]
453 fn login_with_valid_credentials_redirects() {
454 let context = test_context_with_dynamic_redirect();
455 let service = BasicAuthContextService::new(&context).expect("service should build");
456 let diagnosed = service.login_diagnosed(
457 "/basic/login",
458 Some("Basic YWRtaW46c2VjcmV0"),
459 Some("/console"),
460 None,
461 );
462 let response = diagnosed
463 .result()
464 .as_ref()
465 .expect("login should return response");
466
467 assert_eq!(response.status, StatusCode::FOUND);
468 assert_eq!(
469 response.headers.get(http::header::LOCATION).unwrap(),
470 "/console"
471 );
472 assert_eq!(
473 diagnosed.diagnosis().operation,
474 AuthFlowOperation::BASIC_AUTH_LOGIN
475 );
476 assert_eq!(
477 diagnosed.diagnosis().outcome,
478 AuthFlowDiagnosisOutcome::Succeeded
479 );
480 assert_eq!(diagnosed.diagnosis().fields["authenticated"], true);
481 }
482
483 #[test]
484 fn authorize_request_diagnosed_reports_verified_credentials() {
485 let context = test_context();
486 let service = BasicAuthContextService::new(&context).expect("service should build");
487 let diagnosed = service.authorize_request_diagnosed(Some("Basic YWRtaW46c2VjcmV0"), None);
488
489 assert!(
490 diagnosed
491 .result()
492 .as_ref()
493 .is_ok_and(|authorized| *authorized)
494 );
495 assert_eq!(
496 diagnosed.diagnosis().operation,
497 AuthFlowOperation::BASIC_AUTH_AUTHORIZE
498 );
499 assert_eq!(
500 diagnosed.diagnosis().outcome,
501 AuthFlowDiagnosisOutcome::Succeeded
502 );
503 assert_eq!(diagnosed.diagnosis().fields["authorized"], true);
504 }
505
506 #[test]
507 fn logout_diagnosed_reports_logout_poison_protocol_response() {
508 let context = test_context();
509 let service = BasicAuthContextService::new(&context).expect("service should build");
510 let diagnosed = service.logout_diagnosed("/basic/logout");
511 let response = diagnosed
512 .result()
513 .as_ref()
514 .expect("logout should produce response");
515
516 assert_eq!(response.status, StatusCode::UNAUTHORIZED);
517 assert_eq!(
518 diagnosed.diagnosis().operation,
519 AuthFlowOperation::BASIC_AUTH_LOGOUT
520 );
521 assert_eq!(
522 diagnosed.diagnosis().outcome,
523 AuthFlowDiagnosisOutcome::Succeeded
524 );
525 assert_eq!(
526 diagnosed.diagnosis().fields["response_kind"],
527 "logout_poison"
528 );
529 }
530}