use crate::config::{JwtConfig, LdapConfig, SecurityConfig, UserConfig};
use crate::error::{FusekiError, FusekiResult};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::{debug, info};
pub mod api_key_service; pub mod certificate;
pub mod graph_acl; pub mod graph_auth; pub mod http_auth; pub mod jwt; pub mod jwt_validation; pub mod ldap;
pub mod oauth;
pub mod oauth_providers; pub mod password;
pub mod permissions;
pub mod policy_engine; pub mod query_filter; pub mod rbac; pub mod rdf_rebac; pub mod rebac; pub mod rebac_migration; pub mod refresh_token; #[cfg(feature = "saml")]
pub mod saml;
pub mod session;
pub mod token_management; pub mod types;
pub use certificate::CertificateAuthService as CertificateAuthenticator;
pub use session::SessionManager;
pub use types::*;
#[derive(Clone)]
pub struct AuthService {
config: Arc<SecurityConfig>,
users: Arc<RwLock<HashMap<String, UserConfig>>>,
session_manager: Arc<SessionManager>,
certificate_auth: Arc<CertificateAuthenticator>,
oauth2_service: Option<oauth::OAuth2Service>,
ldap_service: Option<ldap::LdapService>,
#[cfg(feature = "saml")]
saml_provider: Option<Arc<saml::SamlProvider>>,
mfa_challenges: Arc<RwLock<HashMap<String, MfaChallenge>>>,
}
impl AuthService {
pub async fn new(config: SecurityConfig) -> FusekiResult<Self> {
let users = config.users.clone();
let config_arc = Arc::new(config);
let session_manager = Arc::new(SessionManager::new(config_arc.session.timeout_secs as i64));
let certificate_auth = Arc::new(CertificateAuthenticator::new(config_arc.clone()));
let oauth2_service = config_arc
.oauth
.as_ref()
.map(|oauth_config| oauth::OAuth2Service::new(oauth_config.clone()));
let ldap_service = if let Some(ldap_config) = config_arc.ldap.as_ref() {
Some(ldap::LdapService::new(ldap_config.clone()).await?)
} else {
None
};
#[cfg(feature = "saml")]
let saml_provider = if let Some(saml_config) = config_arc.saml.as_ref() {
if saml_config.enabled {
use url::Url;
let saml_internal_config = saml::SamlConfig {
sp: saml::ServiceProviderConfig {
entity_id: saml_config.sp_entity_id.clone(),
acs_url: Url::parse(&saml_config.acs_url).map_err(|e| {
FusekiError::configuration(format!("Invalid SAML ACS URL: {}", e))
})?,
sls_url: saml_config
.slo_url
.as_ref()
.and_then(|url| Url::parse(url).ok()),
certificate: None, private_key: None, },
idp: saml::IdentityProviderConfig {
entity_id: saml_config.idp.entity_id.clone(),
sso_url: Url::parse(&saml_config.idp.sso_url).map_err(|e| {
FusekiError::configuration(format!("Invalid SAML SSO URL: {}", e))
})?,
slo_url: saml_config
.idp
.slo_url
.as_ref()
.and_then(|url| Url::parse(url).ok()),
certificate: String::new(), metadata_url: saml_config
.idp
.metadata_url
.as_ref()
.and_then(|url| Url::parse(url).ok()),
},
attribute_mapping: saml::AttributeMapping {
username: saml_config.attribute_mappings.username_attribute.clone(),
email: saml_config.attribute_mappings.email_attribute.clone(),
display_name: saml_config.attribute_mappings.name_attribute.clone(),
groups: saml_config.attribute_mappings.groups_attribute.clone(),
custom: std::collections::HashMap::new(),
},
session: saml::SessionConfig {
timeout: std::time::Duration::from_secs(saml_config.session_timeout_secs),
allow_idp_initiated: false,
force_authn: false,
track_session_index: true,
},
};
Some(Arc::new(saml::SamlProvider::new(saml_internal_config)))
} else {
None
}
} else {
None
};
Ok(Self {
config: config_arc,
users: Arc::new(RwLock::new(users)),
session_manager,
certificate_auth,
oauth2_service,
ldap_service,
#[cfg(feature = "saml")]
saml_provider,
mfa_challenges: Arc::new(RwLock::new(HashMap::new())),
})
}
pub async fn authenticate_user(
&self,
username: &str,
password: &str,
) -> FusekiResult<AuthResult> {
let users = self.users.read().await;
if let Some(user_config) = users.get(username) {
if !user_config.enabled {
info!("Login attempt for disabled user: {}", username);
return Ok(AuthResult::Forbidden);
}
if password::PasswordUtils::verify_password(password, &user_config.password_hash)? {
debug!("Successful local authentication for user: {}", username);
let permissions =
permissions::PermissionChecker::compute_user_permissions(user_config);
let user = User {
username: username.to_string(),
roles: user_config.roles.clone(),
email: user_config.email.clone(),
full_name: user_config.full_name.clone(),
last_login: user_config.last_login,
permissions,
};
return Ok(AuthResult::Authenticated(user));
}
}
if let Some(ldap_service) = &self.ldap_service {
debug!("Trying LDAP authentication for user: {}", username);
return ldap_service
.authenticate_ldap_user(username, password)
.await;
}
Ok(AuthResult::Unauthenticated)
}
pub async fn authenticate_certificate(&self, cert_data: &[u8]) -> FusekiResult<AuthResult> {
self.certificate_auth
.authenticate_certificate(cert_data)
.await
}
pub async fn create_session(&self, user: User) -> FusekiResult<String> {
self.session_manager.create_session(user).await
}
pub async fn validate_session(&self, session_id: &str) -> FusekiResult<Option<User>> {
match self.session_manager.validate_session(session_id).await? {
AuthResult::Authenticated(user) => Ok(Some(user)),
_ => Ok(None),
}
}
pub async fn logout(&self, session_id: &str) -> FusekiResult<bool> {
self.session_manager
.invalidate_session(session_id)
.await
.map(|_| true)
}
pub fn create_jwt_token(&self, user: &User) -> FusekiResult<String> {
self.session_manager.create_jwt_token(user)
}
pub fn validate_jwt_token(&self, token: &str) -> FusekiResult<TokenValidation> {
self.session_manager.validate_jwt_token(token)
}
pub fn get_oauth2_auth_url(&self, state: &str) -> FusekiResult<String> {
self.oauth2_service
.as_ref()
.ok_or_else(|| FusekiError::configuration("OAuth2 not configured"))?
.get_auth_url(state)
}
pub async fn complete_oauth2_authentication(
&self,
code: &str,
state: &str,
redirect_uri: &str,
) -> FusekiResult<AuthResult> {
let oauth2_service = self
.oauth2_service
.as_ref()
.ok_or_else(|| FusekiError::configuration("OAuth2 not configured"))?;
let token = oauth2_service
.exchange_code_for_token(code, state, redirect_uri)
.await?;
let user_info = oauth2_service.get_user_info(&token.access_token).await?;
let username = user_info.sub.clone();
let email = user_info.email.clone();
let full_name = user_info.name.clone();
let roles = vec!["user".to_string()];
let mut permissions = std::collections::HashSet::new();
for role in &roles {
if let Some(role_permissions) =
permissions::PermissionChecker::get_role_permissions(role)
{
permissions.extend(role_permissions);
}
}
let permissions: Vec<_> = permissions.into_iter().collect();
let user = User {
username: username.clone(),
roles,
email,
full_name,
last_login: Some(chrono::Utc::now()),
permissions,
};
debug!("Successful OAuth2 authentication for user: {}", username);
Ok(AuthResult::Authenticated(user))
}
pub fn is_oauth2_enabled(&self) -> bool {
self.oauth2_service.is_some()
}
pub async fn generate_oauth2_auth_url(
&self,
redirect_uri: &str,
scopes: &[String],
use_pkce: bool,
) -> FusekiResult<(String, String)> {
self.oauth2_service
.as_ref()
.ok_or_else(|| FusekiError::configuration("OAuth2 not configured"))?
.generate_authorization_url(redirect_uri, scopes, use_pkce)
.await
}
pub async fn validate_access_token(&self, access_token: &str) -> FusekiResult<bool> {
self.oauth2_service
.as_ref()
.ok_or_else(|| FusekiError::configuration("OAuth2 not configured"))?
.validate_access_token(access_token)
.await
}
pub fn get_oauth_config(&self) -> Option<&crate::config::OAuthConfig> {
self.config.oauth.as_ref()
}
pub async fn get_oauth2_user_info(
&self,
access_token: &str,
) -> FusekiResult<oauth::OIDCUserInfo> {
self.oauth2_service
.as_ref()
.ok_or_else(|| FusekiError::configuration("OAuth2 not configured"))?
.get_user_info(access_token)
.await
}
pub async fn refresh_oauth2_token(
&self,
refresh_token: &str,
) -> FusekiResult<oauth::OAuth2Token> {
self.oauth2_service
.as_ref()
.ok_or_else(|| FusekiError::configuration("OAuth2 not configured"))?
.refresh_token(refresh_token)
.await
}
#[cfg(feature = "saml")]
pub async fn generate_saml_auth_request(
&self,
relay_state: Option<String>,
) -> FusekiResult<String> {
if let Some(saml_provider) = &self.saml_provider {
let url = saml_provider.generate_login_url(relay_state).await?;
Ok(url.to_string())
} else {
Err(FusekiError::configuration("SAML not configured"))
}
}
#[cfg(feature = "saml")]
pub fn is_saml_enabled(&self) -> bool {
self.saml_provider.is_some()
}
#[cfg(feature = "saml")]
pub async fn complete_saml_authentication(
&self,
saml_response: &str,
relay_state: Option<&str>,
) -> FusekiResult<AuthResult> {
let saml_provider = self
.saml_provider
.as_ref()
.ok_or_else(|| FusekiError::configuration("SAML not configured"))?;
let user = saml_provider
.process_response(saml_response, relay_state)
.await?;
debug!("Successful SAML authentication for user: {}", user.username);
Ok(AuthResult::Authenticated(user))
}
#[cfg(feature = "saml")]
pub async fn logout_by_session_index(&self, session_index: &str) -> FusekiResult<bool> {
let saml_provider = self
.saml_provider
.as_ref()
.ok_or_else(|| FusekiError::configuration("SAML not configured"))?;
if let Some(session_id) = saml_provider.get_session_by_index(session_index).await? {
self.session_manager.invalidate_session(&session_id).await?;
debug!("Logged out session with SAML index: {}", session_index);
Ok(true)
} else {
debug!("No session found for SAML index: {}", session_index);
Ok(false)
}
}
#[cfg(feature = "saml")]
pub fn get_saml_sp_config(&self) -> FusekiResult<saml::ServiceProviderConfig> {
self.saml_provider
.as_ref()
.map(|provider| provider.config.sp.clone())
.ok_or_else(|| FusekiError::configuration("SAML not configured"))
}
#[cfg(feature = "saml")]
pub fn get_saml_attribute_mapping(&self) -> FusekiResult<saml::AttributeMapping> {
self.saml_provider
.as_ref()
.map(|provider| provider.config.attribute_mapping.clone())
.ok_or_else(|| FusekiError::configuration("SAML not configured"))
}
#[cfg(feature = "saml")]
pub async fn generate_saml_logout_request(
&self,
session_index: &str,
name_id: &str,
) -> FusekiResult<String> {
let saml_provider = self
.saml_provider
.as_ref()
.ok_or_else(|| FusekiError::configuration("SAML not configured"))?;
saml_provider
.generate_logout_request(session_index, name_id)
.await
}
#[cfg(feature = "saml")]
pub fn get_saml_metadata(&self) -> FusekiResult<String> {
let saml_provider = self
.saml_provider
.as_ref()
.ok_or_else(|| FusekiError::configuration("SAML not configured"))?;
Ok(saml_provider.get_metadata())
}
pub fn config(&self) -> &SecurityConfig {
&self.config
}
pub fn session_manager(&self) -> &SessionManager {
&self.session_manager
}
pub async fn get_user(&self, username: &str) -> Option<UserConfig> {
let users = self.users.read().await;
users.get(username).cloned()
}
pub fn hash_password(&self, password: &str) -> FusekiResult<String> {
#[cfg(feature = "auth")]
{
use bcrypt::{hash, DEFAULT_COST};
hash(password, DEFAULT_COST)
.map_err(|e| FusekiError::authentication(format!("Failed to hash password: {e}")))
}
#[cfg(not(feature = "auth"))]
{
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
password.hash(&mut hasher);
Ok(format!("hash_{:x}", hasher.finish()))
}
}
pub fn verify_password(&self, password: &str, hash: &str) -> FusekiResult<bool> {
#[cfg(feature = "auth")]
{
use bcrypt::verify;
verify(password, hash)
.map_err(|e| FusekiError::authentication(format!("Failed to verify password: {e}")))
}
#[cfg(not(feature = "auth"))]
{
let computed_hash = self.hash_password(password)?;
Ok(computed_hash == hash)
}
}
pub async fn upsert_user(&self, username: String, config: UserConfig) -> FusekiResult<()> {
let mut users = self.users.write().await;
users.insert(username, config);
Ok(())
}
pub async fn remove_user(&self, username: &str) -> FusekiResult<bool> {
let mut users = self.users.write().await;
Ok(users.remove(username).is_some())
}
pub fn is_ldap_enabled(&self) -> bool {
self.ldap_service.is_some()
}
pub async fn authenticate_ldap(
&self,
username: &str,
password: &str,
) -> FusekiResult<AuthResult> {
if let Some(ref ldap_service) = self.ldap_service {
ldap_service
.authenticate_ldap_user(username, password)
.await
} else {
Err(FusekiError::configuration("LDAP not configured"))
}
}
pub fn jwt_config(&self) -> Option<&JwtConfig> {
self.config.jwt.as_ref()
}
pub async fn generate_jwt_token(&self, user: &User) -> FusekiResult<String> {
self.create_jwt_token(user)
}
pub async fn test_ldap_connection(&self) -> FusekiResult<bool> {
if let Some(ref ldap_service) = self.ldap_service {
ldap_service.test_connection().await
} else {
Err(FusekiError::configuration("LDAP not configured"))
}
}
pub fn ldap_config(&self) -> Option<&LdapConfig> {
self.config.ldap.as_ref()
}
pub async fn get_ldap_user_groups(&self, username: &str) -> FusekiResult<Vec<String>> {
if let Some(ref ldap_service) = self.ldap_service {
let groups = ldap_service.get_user_groups(username).await?;
Ok(groups.into_iter().map(|group| group.cn).collect())
} else {
Err(FusekiError::configuration("LDAP not configured"))
}
}
pub async fn store_mfa_challenge(
&self,
challenge_id: &str,
challenge: MfaChallenge,
) -> FusekiResult<()> {
let mut challenges = self.mfa_challenges.write().await;
challenges.insert(challenge_id.to_string(), challenge);
debug!("Stored MFA challenge: {}", challenge_id);
Ok(())
}
pub async fn get_mfa_challenge(
&self,
challenge_id: &str,
) -> FusekiResult<Option<MfaChallenge>> {
let challenges = self.mfa_challenges.read().await;
Ok(challenges.get(challenge_id).cloned())
}
pub async fn remove_mfa_challenge(&self, challenge_id: &str) -> FusekiResult<bool> {
let mut challenges = self.mfa_challenges.write().await;
let removed = challenges.remove(challenge_id).is_some();
if removed {
debug!("Removed MFA challenge: {}", challenge_id);
}
Ok(removed)
}
pub async fn store_mfa_email(&self, username: &str, _email: &str) -> FusekiResult<()> {
info!("Storing MFA email for user: {}", username);
Ok(())
}
pub async fn get_user_sms_phone(&self, _username: &str) -> FusekiResult<Option<String>> {
Ok(Some("+1-555-0123".to_string())) }
pub async fn get_user_mfa_email(&self, _username: &str) -> FusekiResult<Option<String>> {
Ok(Some("user@example.com".to_string())) }
pub async fn store_webauthn_challenge(
&self,
username: &str,
_challenge: &str,
) -> FusekiResult<()> {
info!("Storing WebAuthn challenge for user: {}", username);
Ok(())
}
pub async fn store_sms_phone(&self, username: &str, _phone: &str) -> FusekiResult<()> {
info!("Storing SMS phone for user: {}", username);
Ok(())
}
pub async fn update_mfa_challenge(
&self,
challenge_id: &str,
challenge: MfaChallenge,
) -> FusekiResult<()> {
let mut challenges = self.mfa_challenges.write().await;
challenges.insert(challenge_id.to_string(), challenge);
debug!("Updated MFA challenge: {}", challenge_id);
Ok(())
}
pub async fn get_user_mfa_status(&self, _username: &str) -> FusekiResult<MfaStatus> {
Ok(MfaStatus {
enabled: false,
enrolled_methods: vec![],
backup_codes_remaining: 0,
last_used: None,
expires_at: None,
message: "MFA disabled".to_string(),
})
}
pub async fn disable_mfa_method(
&self,
_username: &str,
_method: MfaMethod,
) -> FusekiResult<()> {
Ok(())
}
pub async fn store_backup_codes(
&self,
_username: &str,
_codes: Vec<String>,
) -> FusekiResult<()> {
Ok(())
}
pub async fn store_totp_secret(&self, _username: &str, _secret: &str) -> FusekiResult<()> {
Ok(())
}
pub async fn cleanup_ldap_cache(&self) {
if let Some(ref ldap_service) = self.ldap_service {
ldap_service.cleanup_expired_cache().await;
}
}
}
#[derive(Debug, Clone)]
pub struct AuthUser(pub User);
impl AuthUser {
pub fn into_inner(self) -> User {
self.0
}
}
impl From<AuthUser> for User {
fn from(auth_user: AuthUser) -> Self {
auth_user.0
}
}
impl From<User> for AuthUser {
fn from(user: User) -> Self {
AuthUser(user)
}
}
use axum::{
extract::{FromRequestParts, OptionalFromRequestParts},
http::{request::Parts, StatusCode},
};
use axum_extra::headers::{authorization::Bearer, Authorization, HeaderMapExt};
impl<S> FromRequestParts<S> for AuthUser
where
S: Send + Sync,
{
type Rejection = StatusCode;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
if let Some(auth_header) = parts.headers.typed_get::<Authorization<Bearer>>() {
let _token = auth_header.token();
return Err(StatusCode::UNAUTHORIZED);
}
Err(StatusCode::UNAUTHORIZED)
}
}
impl<S> OptionalFromRequestParts<S> for AuthUser
where
S: Send + Sync,
{
type Rejection = std::convert::Infallible;
async fn from_request_parts(
parts: &mut Parts,
state: &S,
) -> Result<Option<Self>, Self::Rejection> {
Ok(
<Self as FromRequestParts<S>>::from_request_parts(parts, state)
.await
.ok(),
)
}
}
pub struct RequirePermission(pub Permission);
#[derive(Debug, thiserror::Error)]
pub enum AuthError {
#[error("Authentication required")]
AuthenticationRequired,
#[error("Invalid credentials")]
InvalidCredentials,
#[error("Permission denied")]
PermissionDenied,
#[error("Token expired")]
TokenExpired,
#[error("Invalid token")]
InvalidToken,
#[error("MFA required")]
MfaRequired,
}
impl axum::response::IntoResponse for AuthError {
fn into_response(self) -> axum::response::Response {
let status = match self {
AuthError::AuthenticationRequired => StatusCode::UNAUTHORIZED,
AuthError::InvalidCredentials => StatusCode::UNAUTHORIZED,
AuthError::PermissionDenied => StatusCode::FORBIDDEN,
AuthError::TokenExpired => StatusCode::UNAUTHORIZED,
AuthError::InvalidToken => StatusCode::UNAUTHORIZED,
AuthError::MfaRequired => StatusCode::UNAUTHORIZED,
};
(status, self.to_string()).into_response()
}
}
#[allow(dead_code)]
fn decode_basic_auth(encoded: &str) -> Result<(String, String), Box<dyn std::error::Error + Send>> {
use base64::{engine::general_purpose::STANDARD, Engine};
let decoded = STANDARD
.decode(encoded)
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send>)?;
let credential =
String::from_utf8(decoded).map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send>)?;
if let Some((username, password)) = credential.split_once(':') {
Ok((username.to_string(), password.to_string()))
} else {
Err(Box::new(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Invalid basic auth format",
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_decode_basic_auth() {
let encoded = "dGVzdDpwYXNzd29yZA=="; let result = decode_basic_auth(encoded).unwrap();
assert_eq!(result.0, "test");
assert_eq!(result.1, "password");
}
}