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::{
374 BasicAuthContext, BasicAuthContextConfig, BasicAuthContextConfigSource, BasicAuthZoneConfig,
375 };
376
377 #[derive(Debug, Clone, Serialize, Deserialize, Default)]
378 struct TestCred {
379 username: String,
380 password: String,
381 }
382
383 impl BasicAuthCred for TestCred {
384 fn username(&self) -> &str {
385 &self.username
386 }
387
388 fn verify_password(&self, password: &str) -> securitydept_creds::CredsResult<bool> {
389 Ok(password == self.password)
390 }
391 }
392
393 fn test_context() -> BasicAuthContext<TestCred> {
394 let config = BasicAuthContextConfig::builder()
395 .creds(securitydept_creds::BasicAuthCredsConfig {
396 users: vec![TestCred {
397 username: "admin".to_string(),
398 password: "secret".to_string(),
399 }],
400 })
401 .zones(vec![BasicAuthZoneConfig::default()])
402 .build();
403
404 BasicAuthContext::from_resolved_config(
405 BasicAuthContextConfigSource::resolve_all(&config)
406 .expect("basic auth config should resolve"),
407 )
408 .expect("context should build")
409 }
410
411 fn test_context_with_dynamic_redirect() -> BasicAuthContext<TestCred> {
412 let config = BasicAuthContextConfig::builder()
413 .creds(securitydept_creds::BasicAuthCredsConfig {
414 users: vec![TestCred {
415 username: "admin".to_string(),
416 password: "secret".to_string(),
417 }],
418 })
419 .zones(vec![BasicAuthZoneConfig::builder()
420 .post_auth_redirect(
421 securitydept_utils::redirect::RedirectTargetConfig::dynamic_default_and_dynamic_targets(
422 "/",
423 [securitydept_utils::redirect::RedirectTargetRule::Strict {
424 value: "/console".to_string(),
425 }],
426 ),
427 )
428 .build()])
429 .build();
430
431 BasicAuthContext::from_resolved_config(
432 BasicAuthContextConfigSource::resolve_all(&config)
433 .expect("basic auth config should resolve"),
434 )
435 .expect("context should build")
436 }
437
438 #[test]
439 fn login_without_credentials_returns_challenge() {
440 let context = test_context();
441 let service = BasicAuthContextService::new(&context).expect("service should build");
442 let diagnosed = service.login_diagnosed("/basic/login", None, None, None);
443 let response = diagnosed
444 .result()
445 .as_ref()
446 .expect("login should return response");
447
448 assert_eq!(response.status, StatusCode::UNAUTHORIZED);
449 assert_eq!(
450 diagnosed.diagnosis().operation,
451 AuthFlowOperation::BASIC_AUTH_LOGIN
452 );
453 assert_eq!(
454 diagnosed.diagnosis().outcome,
455 AuthFlowDiagnosisOutcome::Rejected
456 );
457 assert_eq!(diagnosed.diagnosis().fields["reason"], "challenge_required");
458 }
459
460 #[test]
461 fn login_with_valid_credentials_redirects() {
462 let context = test_context_with_dynamic_redirect();
463 let service = BasicAuthContextService::new(&context).expect("service should build");
464 let diagnosed = service.login_diagnosed(
465 "/basic/login",
466 Some("Basic YWRtaW46c2VjcmV0"),
467 Some("/console"),
468 None,
469 );
470 let response = diagnosed
471 .result()
472 .as_ref()
473 .expect("login should return response");
474
475 assert_eq!(response.status, StatusCode::FOUND);
476 assert_eq!(
477 response.headers.get(http::header::LOCATION).unwrap(),
478 "/console"
479 );
480 assert_eq!(
481 diagnosed.diagnosis().operation,
482 AuthFlowOperation::BASIC_AUTH_LOGIN
483 );
484 assert_eq!(
485 diagnosed.diagnosis().outcome,
486 AuthFlowDiagnosisOutcome::Succeeded
487 );
488 assert_eq!(diagnosed.diagnosis().fields["authenticated"], true);
489 }
490
491 #[test]
492 fn authorize_request_diagnosed_reports_verified_credentials() {
493 let context = test_context();
494 let service = BasicAuthContextService::new(&context).expect("service should build");
495 let diagnosed = service.authorize_request_diagnosed(Some("Basic YWRtaW46c2VjcmV0"), None);
496
497 assert!(
498 diagnosed
499 .result()
500 .as_ref()
501 .is_ok_and(|authorized| *authorized)
502 );
503 assert_eq!(
504 diagnosed.diagnosis().operation,
505 AuthFlowOperation::BASIC_AUTH_AUTHORIZE
506 );
507 assert_eq!(
508 diagnosed.diagnosis().outcome,
509 AuthFlowDiagnosisOutcome::Succeeded
510 );
511 assert_eq!(diagnosed.diagnosis().fields["authorized"], true);
512 }
513
514 #[test]
515 fn logout_diagnosed_reports_logout_poison_protocol_response() {
516 let context = test_context();
517 let service = BasicAuthContextService::new(&context).expect("service should build");
518 let diagnosed = service.logout_diagnosed("/basic/logout");
519 let response = diagnosed
520 .result()
521 .as_ref()
522 .expect("logout should produce response");
523
524 assert_eq!(response.status, StatusCode::UNAUTHORIZED);
525 assert_eq!(
526 diagnosed.diagnosis().operation,
527 AuthFlowOperation::BASIC_AUTH_LOGOUT
528 );
529 assert_eq!(
530 diagnosed.diagnosis().outcome,
531 AuthFlowDiagnosisOutcome::Succeeded
532 );
533 assert_eq!(
534 diagnosed.diagnosis().fields["response_kind"],
535 "logout_poison"
536 );
537 }
538}