use http::StatusCode;
use securitydept_creds::{
BasicAuthCred, BasicAuthCredsValidator, CredsError, MapBasicAuthCredsValidator,
parse_basic_auth_header_opt,
};
use securitydept_realip::ResolvedClientIp;
use securitydept_utils::{
error::{ErrorPresentation, ToErrorPresentation, UserRecovery},
http::{HttpResponse, ToHttpStatus},
observability::{
AuthFlowDiagnosis, AuthFlowDiagnosisField, AuthFlowDiagnosisOutcome, AuthFlowOperation,
DiagnosedResult,
},
};
use serde::{Deserialize, Serialize};
use snafu::Snafu;
use crate::{BasicAuthContext, BasicAuthContextError, BasicAuthZone};
fn basic_auth_diagnosis(operation: &str) -> AuthFlowDiagnosis {
AuthFlowDiagnosis::started(operation).field(AuthFlowDiagnosisField::AUTH_FAMILY, "basic-auth")
}
#[derive(Debug, Snafu)]
pub enum BasicAuthContextServiceError {
#[snafu(transparent)]
BasicAuthContext { source: BasicAuthContextError },
#[snafu(transparent)]
Creds { source: CredsError },
}
impl ToHttpStatus for BasicAuthContextServiceError {
fn to_http_status(&self) -> StatusCode {
match self {
Self::BasicAuthContext { .. } => StatusCode::INTERNAL_SERVER_ERROR,
Self::Creds { source } => source.to_http_status(),
}
}
}
impl ToErrorPresentation for BasicAuthContextServiceError {
fn to_error_presentation(&self) -> ErrorPresentation {
match self {
Self::BasicAuthContext { .. } => ErrorPresentation::new(
"basic_auth_context_invalid",
"Basic-auth context is misconfigured.",
UserRecovery::ContactSupport,
),
Self::Creds { source } => source.to_error_presentation(),
}
}
}
pub struct BasicAuthContextService<'a, Creds>
where
Creds: BasicAuthCred + Serialize + for<'de> Deserialize<'de>,
{
basic_auth_context: &'a BasicAuthContext<Creds>,
}
impl<'a, Creds> BasicAuthContextService<'a, Creds>
where
Creds: BasicAuthCred + Serialize + for<'de> Deserialize<'de>,
{
pub fn new(
basic_auth_context: &'a BasicAuthContext<Creds>,
) -> Result<Self, BasicAuthContextServiceError> {
MapBasicAuthCredsValidator::from_config(&basic_auth_context.creds)
.map_err(|source| BasicAuthContextServiceError::Creds { source })?;
Ok(Self { basic_auth_context })
}
pub fn context(&self) -> &BasicAuthContext<Creds> {
self.basic_auth_context
}
pub fn login(
&self,
request_path: &str,
authorization_header: Option<&str>,
requested_post_auth_redirect_uri: Option<&str>,
resolved_client_ip: Option<&ResolvedClientIp>,
) -> Result<HttpResponse, BasicAuthContextServiceError> {
self.login_diagnosed(
request_path,
authorization_header,
requested_post_auth_redirect_uri,
resolved_client_ip,
)
.into_result()
}
pub fn login_diagnosed(
&self,
request_path: &str,
authorization_header: Option<&str>,
requested_post_auth_redirect_uri: Option<&str>,
resolved_client_ip: Option<&ResolvedClientIp>,
) -> DiagnosedResult<HttpResponse, BasicAuthContextServiceError> {
let diagnosis = basic_auth_diagnosis(AuthFlowOperation::BASIC_AUTH_LOGIN)
.field(AuthFlowDiagnosisField::REQUEST_PATH, request_path)
.field("authorization_present", authorization_header.is_some())
.field(
"has_requested_post_auth_redirect_uri",
requested_post_auth_redirect_uri.is_some(),
)
.field(
AuthFlowDiagnosisField::RESOLVED_CLIENT_IP_PRESENT,
resolved_client_ip.is_some(),
);
let Some(zone) = self.basic_auth_context.zone_for_request_path(request_path) else {
return DiagnosedResult::success(
diagnosis
.with_outcome(AuthFlowDiagnosisOutcome::Rejected)
.field(AuthFlowDiagnosisField::REASON, "zone_not_found")
.field(
AuthFlowDiagnosisField::HTTP_STATUS,
StatusCode::NOT_FOUND.as_u16(),
),
HttpResponse::new(StatusCode::NOT_FOUND),
);
};
let real_ip_allowed = match self.real_ip_allowed(resolved_client_ip) {
Ok(allowed) => allowed,
Err(source) => {
return DiagnosedResult::failure(
diagnosis
.clone()
.with_outcome(AuthFlowDiagnosisOutcome::Failed)
.field(AuthFlowDiagnosisField::REASON, "real_ip_resolution_failed"),
source,
);
}
};
if !real_ip_allowed {
return DiagnosedResult::success(
diagnosis
.with_outcome(AuthFlowDiagnosisOutcome::Rejected)
.field(AuthFlowDiagnosisField::REASON, "real_ip_forbidden")
.field(
AuthFlowDiagnosisField::HTTP_STATUS,
StatusCode::FORBIDDEN.as_u16(),
),
HttpResponse::new(StatusCode::FORBIDDEN),
);
}
let authenticated = match self.verify_basic_auth(authorization_header) {
Ok(authenticated) => authenticated,
Err(source) => {
return DiagnosedResult::failure(
diagnosis
.clone()
.with_outcome(AuthFlowDiagnosisOutcome::Failed)
.field(
AuthFlowDiagnosisField::REASON,
"credential_validation_failed",
),
source,
);
}
};
if authenticated {
match zone.login_success_response(requested_post_auth_redirect_uri) {
Ok(response) => DiagnosedResult::success(
diagnosis
.with_outcome(AuthFlowDiagnosisOutcome::Succeeded)
.field("authenticated", true)
.field(
AuthFlowDiagnosisField::HTTP_STATUS,
response.status.as_u16(),
),
response,
),
Err(source) => DiagnosedResult::failure(
diagnosis
.with_outcome(AuthFlowDiagnosisOutcome::Failed)
.field("authenticated", true)
.field(AuthFlowDiagnosisField::REASON, "post_auth_redirect_invalid"),
BasicAuthContextServiceError::BasicAuthContext { source },
),
}
} else {
let response = zone.login_challenge_response();
DiagnosedResult::success(
diagnosis
.with_outcome(AuthFlowDiagnosisOutcome::Rejected)
.field("authenticated", false)
.field(AuthFlowDiagnosisField::REASON, "challenge_required")
.field(
AuthFlowDiagnosisField::HTTP_STATUS,
response.status.as_u16(),
),
response,
)
}
}
pub fn logout(&self, request_path: &str) -> HttpResponse {
self.logout_diagnosed(request_path)
.into_result()
.expect("basic-auth logout diagnosis should not fail")
}
pub fn logout_diagnosed(
&self,
request_path: &str,
) -> DiagnosedResult<HttpResponse, BasicAuthContextServiceError> {
let diagnosis = basic_auth_diagnosis(AuthFlowOperation::BASIC_AUTH_LOGOUT)
.field(AuthFlowDiagnosisField::REQUEST_PATH, request_path);
if let Some(protocol_response) = self.logout_protocol_response(request_path) {
let response = protocol_response.into_http_response();
DiagnosedResult::success(
diagnosis
.with_outcome(AuthFlowDiagnosisOutcome::Succeeded)
.field("response_kind", "logout_poison")
.field(
AuthFlowDiagnosisField::HTTP_STATUS,
response.status.as_u16(),
),
response,
)
} else {
DiagnosedResult::success(
diagnosis
.with_outcome(AuthFlowDiagnosisOutcome::Rejected)
.field("response_kind", "not_found")
.field(
AuthFlowDiagnosisField::HTTP_STATUS,
StatusCode::NOT_FOUND.as_u16(),
),
HttpResponse::new(StatusCode::NOT_FOUND),
)
}
}
pub fn logout_protocol_response(
&self,
request_path: &str,
) -> Option<crate::BasicAuthProtocolResponse> {
self.basic_auth_context
.zone_for_request_path(request_path)
.map(BasicAuthZone::logout_poison_protocol_response)
}
pub fn authorize_request(
&self,
authorization_header: Option<&str>,
resolved_client_ip: Option<&ResolvedClientIp>,
) -> Result<bool, BasicAuthContextServiceError> {
self.authorize_request_diagnosed(authorization_header, resolved_client_ip)
.into_result()
}
pub fn authorize_request_diagnosed(
&self,
authorization_header: Option<&str>,
resolved_client_ip: Option<&ResolvedClientIp>,
) -> DiagnosedResult<bool, BasicAuthContextServiceError> {
let diagnosis = basic_auth_diagnosis(AuthFlowOperation::BASIC_AUTH_AUTHORIZE)
.field("authorization_present", authorization_header.is_some())
.field(
AuthFlowDiagnosisField::RESOLVED_CLIENT_IP_PRESENT,
resolved_client_ip.is_some(),
);
let real_ip_allowed = match self.real_ip_allowed(resolved_client_ip) {
Ok(allowed) => allowed,
Err(source) => {
return DiagnosedResult::failure(
diagnosis
.clone()
.with_outcome(AuthFlowDiagnosisOutcome::Failed)
.field(AuthFlowDiagnosisField::REASON, "real_ip_resolution_failed"),
source,
);
}
};
if !real_ip_allowed {
return DiagnosedResult::success(
diagnosis
.with_outcome(AuthFlowDiagnosisOutcome::Rejected)
.field("authorized", false)
.field(AuthFlowDiagnosisField::REASON, "real_ip_forbidden"),
false,
);
}
match self.verify_basic_auth(authorization_header) {
Ok(authorized) => {
let outcome = if authorized {
AuthFlowDiagnosisOutcome::Succeeded
} else {
AuthFlowDiagnosisOutcome::Rejected
};
let reason = if authorized {
"credentials_verified"
} else {
"credentials_missing_or_invalid"
};
DiagnosedResult::success(
diagnosis
.with_outcome(outcome)
.field("authorized", authorized)
.field(AuthFlowDiagnosisField::REASON, reason),
authorized,
)
}
Err(source) => DiagnosedResult::failure(
diagnosis
.with_outcome(AuthFlowDiagnosisOutcome::Failed)
.field(
AuthFlowDiagnosisField::REASON,
"credential_validation_failed",
),
source,
),
}
}
fn verify_basic_auth(
&self,
authorization_header: Option<&str>,
) -> Result<bool, BasicAuthContextServiceError> {
let Some(authorization_header) = authorization_header else {
return Ok(false);
};
let Some((username, password)) = parse_basic_auth_header_opt(authorization_header) else {
return Ok(false);
};
let validator = MapBasicAuthCredsValidator::from_config(&self.basic_auth_context.creds)
.map_err(|source| BasicAuthContextServiceError::Creds { source })?;
validator
.verify_cred(&username, &password)
.map(|result| result.is_some())
.map_err(|source| BasicAuthContextServiceError::Creds { source })
}
fn real_ip_allowed(
&self,
resolved_client_ip: Option<&ResolvedClientIp>,
) -> Result<bool, BasicAuthContextServiceError> {
match (
self.basic_auth_context.real_ip_access.is_some(),
resolved_client_ip,
) {
(false, _) => Ok(true),
(true, None) => Ok(false),
(true, Some(resolved_client_ip)) => self
.basic_auth_context
.ensure_real_ip_allowed(resolved_client_ip)
.map(|_| true)
.or_else(|error| match error {
BasicAuthContextError::RealIp { .. } => Ok(false),
source => Err(BasicAuthContextServiceError::BasicAuthContext { source }),
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
BasicAuthContext, BasicAuthContextConfig, BasicAuthContextConfigSource, BasicAuthZoneConfig,
};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct TestCred {
username: String,
password: String,
}
impl BasicAuthCred for TestCred {
fn username(&self) -> &str {
&self.username
}
fn verify_password(&self, password: &str) -> securitydept_creds::CredsResult<bool> {
Ok(password == self.password)
}
}
fn test_context() -> BasicAuthContext<TestCred> {
let config = BasicAuthContextConfig::builder()
.creds(securitydept_creds::BasicAuthCredsConfig {
users: vec![TestCred {
username: "admin".to_string(),
password: "secret".to_string(),
}],
})
.zones(vec![BasicAuthZoneConfig::default()])
.build();
BasicAuthContext::from_resolved_config(
BasicAuthContextConfigSource::resolve_all(&config)
.expect("basic auth config should resolve"),
)
.expect("context should build")
}
fn test_context_with_dynamic_redirect() -> BasicAuthContext<TestCred> {
let config = BasicAuthContextConfig::builder()
.creds(securitydept_creds::BasicAuthCredsConfig {
users: vec![TestCred {
username: "admin".to_string(),
password: "secret".to_string(),
}],
})
.zones(vec![BasicAuthZoneConfig::builder()
.post_auth_redirect(
securitydept_utils::redirect::RedirectTargetConfig::dynamic_default_and_dynamic_targets(
"/",
[securitydept_utils::redirect::RedirectTargetRule::Strict {
value: "/console".to_string(),
}],
),
)
.build()])
.build();
BasicAuthContext::from_resolved_config(
BasicAuthContextConfigSource::resolve_all(&config)
.expect("basic auth config should resolve"),
)
.expect("context should build")
}
#[test]
fn login_without_credentials_returns_challenge() {
let context = test_context();
let service = BasicAuthContextService::new(&context).expect("service should build");
let diagnosed = service.login_diagnosed("/basic/login", None, None, None);
let response = diagnosed
.result()
.as_ref()
.expect("login should return response");
assert_eq!(response.status, StatusCode::UNAUTHORIZED);
assert_eq!(
diagnosed.diagnosis().operation,
AuthFlowOperation::BASIC_AUTH_LOGIN
);
assert_eq!(
diagnosed.diagnosis().outcome,
AuthFlowDiagnosisOutcome::Rejected
);
assert_eq!(diagnosed.diagnosis().fields["reason"], "challenge_required");
}
#[test]
fn login_with_valid_credentials_redirects() {
let context = test_context_with_dynamic_redirect();
let service = BasicAuthContextService::new(&context).expect("service should build");
let diagnosed = service.login_diagnosed(
"/basic/login",
Some("Basic YWRtaW46c2VjcmV0"),
Some("/console"),
None,
);
let response = diagnosed
.result()
.as_ref()
.expect("login should return response");
assert_eq!(response.status, StatusCode::FOUND);
assert_eq!(
response.headers.get(http::header::LOCATION).unwrap(),
"/console"
);
assert_eq!(
diagnosed.diagnosis().operation,
AuthFlowOperation::BASIC_AUTH_LOGIN
);
assert_eq!(
diagnosed.diagnosis().outcome,
AuthFlowDiagnosisOutcome::Succeeded
);
assert_eq!(diagnosed.diagnosis().fields["authenticated"], true);
}
#[test]
fn authorize_request_diagnosed_reports_verified_credentials() {
let context = test_context();
let service = BasicAuthContextService::new(&context).expect("service should build");
let diagnosed = service.authorize_request_diagnosed(Some("Basic YWRtaW46c2VjcmV0"), None);
assert!(
diagnosed
.result()
.as_ref()
.is_ok_and(|authorized| *authorized)
);
assert_eq!(
diagnosed.diagnosis().operation,
AuthFlowOperation::BASIC_AUTH_AUTHORIZE
);
assert_eq!(
diagnosed.diagnosis().outcome,
AuthFlowDiagnosisOutcome::Succeeded
);
assert_eq!(diagnosed.diagnosis().fields["authorized"], true);
}
#[test]
fn logout_diagnosed_reports_logout_poison_protocol_response() {
let context = test_context();
let service = BasicAuthContextService::new(&context).expect("service should build");
let diagnosed = service.logout_diagnosed("/basic/logout");
let response = diagnosed
.result()
.as_ref()
.expect("logout should produce response");
assert_eq!(response.status, StatusCode::UNAUTHORIZED);
assert_eq!(
diagnosed.diagnosis().operation,
AuthFlowOperation::BASIC_AUTH_LOGOUT
);
assert_eq!(
diagnosed.diagnosis().outcome,
AuthFlowDiagnosisOutcome::Succeeded
);
assert_eq!(
diagnosed.diagnosis().fields["response_kind"],
"logout_poison"
);
}
}