Skip to main content

actix_security_core/http/security/
authorizer.rs

1//! Request Matcher based Authorization.
2//!
3//! # Spring Security Equivalent
4//! `org.springframework.security.web.access.intercept.RequestMatcherDelegatingAuthorizationManager`
5
6use std::collections::HashMap;
7
8use actix_web::body::EitherBody;
9use actix_web::dev::{ServiceRequest, ServiceResponse};
10use actix_web::{http, Error, HttpResponse};
11use futures_util::future::LocalBoxFuture;
12use regex::Regex;
13
14use crate::http::security::config::Authorizer;
15use crate::http::security::user::User;
16
17#[cfg(feature = "http-basic")]
18use crate::http::security::http_basic::HttpBasicConfig;
19
20/// URL pattern-based authorization.
21///
22/// # Spring Security Equivalent
23/// `RequestMatcher` + `AuthorizationManager`
24///
25/// # Example
26/// ```ignore
27/// use actix_security_core::http::security::authorizer::{RequestMatcherAuthorizer, Access};
28///
29/// let authorizer = RequestMatcherAuthorizer::new()
30///     .login_url("/login")
31///     .http_basic()
32///     .add_matcher("/admin/.*", Access::new().roles(vec!["ADMIN"]))
33///     .add_matcher("/api/.*", Access::new().authorities(vec!["api:read"]));
34/// ```
35pub struct RequestMatcherAuthorizer {
36    login_url: &'static str,
37    matchers: HashMap<String, Access>,
38    #[cfg(feature = "http-basic")]
39    http_basic: Option<HttpBasicConfig>,
40}
41
42impl RequestMatcherAuthorizer {
43    /// Creates a new authorizer with default settings.
44    pub fn new() -> Self {
45        RequestMatcherAuthorizer {
46            login_url: "/login",
47            matchers: HashMap::new(),
48            #[cfg(feature = "http-basic")]
49            http_basic: None,
50        }
51    }
52
53    /// Adds a URL pattern with access requirements.
54    ///
55    /// # Arguments
56    /// * `url_regex` - A regex pattern to match URLs
57    /// * `access` - The access requirements for matching URLs
58    pub fn add_matcher(mut self, url_regex: &'static str, access: Access) -> Self {
59        self.matchers.insert(String::from(url_regex), access);
60        self
61    }
62
63    /// Sets the login URL (default: "/login").
64    ///
65    /// Unauthenticated users will be redirected to this URL
66    /// unless HTTP Basic auth is enabled.
67    pub fn login_url(mut self, url: &'static str) -> Self {
68        self.login_url = url;
69        self
70    }
71
72    /// Enables HTTP Basic authentication.
73    ///
74    /// # Spring Security Equivalent
75    /// `HttpSecurity.httpBasic()`
76    ///
77    /// When enabled, unauthenticated requests will receive a
78    /// `401 Unauthorized` response with `WWW-Authenticate: Basic realm="..."`
79    /// header instead of being redirected to the login page.
80    #[cfg(feature = "http-basic")]
81    pub fn http_basic(mut self) -> Self {
82        self.http_basic = Some(HttpBasicConfig::new());
83        self
84    }
85
86    /// Enables HTTP Basic authentication with custom configuration.
87    #[cfg(feature = "http-basic")]
88    pub fn http_basic_with_config(mut self, config: HttpBasicConfig) -> Self {
89        self.http_basic = Some(config);
90        self
91    }
92
93    /// Checks if a path matches any registered pattern.
94    pub fn matches(&self, path: &str) -> Option<&Access> {
95        for (pattern, access) in &self.matchers {
96            if let Ok(re) = Regex::new(pattern) {
97                if re.is_match(path) {
98                    return Some(access);
99                }
100            }
101        }
102        None
103    }
104
105    fn check_access(&self, user: &User, access: &Access) -> bool {
106        user.has_authorities(&access.authorities) || user.has_roles(&access.roles)
107    }
108}
109
110impl Default for RequestMatcherAuthorizer {
111    fn default() -> Self {
112        Self::new()
113    }
114}
115
116impl<B: 'static> Authorizer<B> for RequestMatcherAuthorizer {
117    fn process(
118        &self,
119        req: ServiceRequest,
120        user: Option<&User>,
121        next: impl FnOnce(ServiceRequest) -> LocalBoxFuture<'static, Result<ServiceResponse<B>, Error>>
122            + 'static,
123    ) -> LocalBoxFuture<'static, Result<ServiceResponse<EitherBody<B>>, Error>> {
124        let path = req.path().to_string();
125        let login_url = self.login_url;
126        #[cfg(feature = "http-basic")]
127        let http_basic = self.http_basic.clone();
128
129        match user {
130            Some(u) => {
131                // Authenticated user
132                if path == login_url {
133                    // Redirect authenticated users away from login page
134                    return Box::pin(async move {
135                        Ok(req.into_response(
136                            HttpResponse::Found()
137                                .append_header((http::header::LOCATION, "/"))
138                                .finish()
139                                .map_into_right_body(),
140                        ))
141                    });
142                }
143
144                // Check if user has required access
145                if let Some(access) = self.matches(&path) {
146                    if self.check_access(u, access) {
147                        // User has access, forward to inner service
148                        #[cfg(feature = "audit")]
149                        tracing::debug!(
150                            target: "actix_security::audit",
151                            event_type = "ACCESS_GRANTED",
152                            user = %u.get_username(),
153                            path = %path,
154                            "Access granted"
155                        );
156                        return Box::pin(async move {
157                            let res = next(req).await?;
158                            Ok(res.map_into_left_body())
159                        });
160                    } else {
161                        // User lacks required access -> 403 Forbidden
162                        #[cfg(feature = "audit")]
163                        tracing::warn!(
164                            target: "actix_security::audit",
165                            event_type = "ACCESS_DENIED",
166                            user = %u.get_username(),
167                            path = %path,
168                            required_roles = ?access.roles,
169                            required_authorities = ?access.authorities,
170                            "Access denied: insufficient permissions"
171                        );
172                        return Box::pin(async move {
173                            Ok(req.into_response(
174                                HttpResponse::Forbidden().finish().map_into_right_body(),
175                            ))
176                        });
177                    }
178                }
179
180                // No matching pattern - allow through to handler
181                // (handler may use macro-level security like #[secured])
182                Box::pin(async move {
183                    let res = next(req).await?;
184                    Ok(res.map_into_left_body())
185                })
186            }
187            None => {
188                // Unauthenticated user
189                if path == login_url {
190                    // Allow access to login page
191                    Box::pin(async move {
192                        let res = next(req).await?;
193                        Ok(res.map_into_left_body())
194                    })
195                } else {
196                    #[cfg(feature = "http-basic")]
197                    if let Some(basic_config) = http_basic {
198                        // HTTP Basic Auth: Return 401 with WWW-Authenticate header
199                        #[cfg(feature = "audit")]
200                        tracing::debug!(
201                            target: "actix_security::audit",
202                            event_type = "AUTHENTICATION_REQUIRED",
203                            path = %path,
204                            auth_method = "http_basic",
205                            "Authentication required (HTTP Basic challenge)"
206                        );
207                        let www_auth = basic_config.www_authenticate_header();
208                        return Box::pin(async move {
209                            Ok(req.into_response(
210                                HttpResponse::Unauthorized()
211                                    .append_header((http::header::WWW_AUTHENTICATE, www_auth))
212                                    .finish()
213                                    .map_into_right_body(),
214                            ))
215                        });
216                    }
217
218                    // Redirect to login page (fallback when http-basic not enabled or not configured)
219                    #[cfg(feature = "audit")]
220                    tracing::debug!(
221                        target: "actix_security::audit",
222                        event_type = "AUTHENTICATION_REQUIRED",
223                        path = %path,
224                        redirect_to = %login_url,
225                        "Authentication required (redirecting to login)"
226                    );
227                    let redirect_url = login_url.to_string();
228                    Box::pin(async move {
229                        Ok(req.into_response(
230                            HttpResponse::Found()
231                                .append_header((http::header::LOCATION, redirect_url))
232                                .finish()
233                                .map_into_right_body(),
234                        ))
235                    })
236                }
237            }
238        }
239    }
240}
241
242// =============================================================================
243// Access Configuration
244// =============================================================================
245
246/// Access configuration for URL patterns.
247///
248/// # Spring Security Equivalent
249/// `AuthorizeHttpRequestsConfigurer`
250///
251/// # Example
252/// ```ignore
253/// use actix_security_core::http::security::authorizer::Access;
254///
255/// // Require ADMIN role
256/// let admin_access = Access::new().roles(vec!["ADMIN"]);
257///
258/// // Require any of these authorities
259/// let api_access = Access::new().authorities(vec!["api:read", "api:write"]);
260///
261/// // Require ADMIN or MANAGER role
262/// let management_access = Access::new().roles(vec!["ADMIN", "MANAGER"]);
263/// ```
264#[derive(Default)]
265pub struct Access {
266    pub(crate) roles: Vec<String>,
267    pub(crate) authorities: Vec<String>,
268}
269
270impl Access {
271    /// Creates a new empty access configuration.
272    pub fn new() -> Self {
273        Access {
274            roles: Vec::new(),
275            authorities: Vec::new(),
276        }
277    }
278
279    /// Requires any of the specified roles.
280    ///
281    /// # Spring Security Equivalent
282    /// `hasAnyRole("ADMIN", "USER")`
283    pub fn roles(mut self, roles: Vec<&str>) -> Self {
284        for role in roles {
285            let role_str = String::from(role);
286            if !self.roles.contains(&role_str) {
287                self.roles.push(role_str);
288            }
289        }
290        self
291    }
292
293    /// Requires any of the specified authorities.
294    ///
295    /// # Spring Security Equivalent
296    /// `hasAnyAuthority("users:read", "users:write")`
297    pub fn authorities(mut self, authorities: Vec<&str>) -> Self {
298        for authority in authorities {
299            let auth_str = String::from(authority);
300            if !self.authorities.contains(&auth_str) {
301                self.authorities.push(auth_str);
302            }
303        }
304        self
305    }
306}