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                        return Box::pin(async move {
149                            let res = next(req).await?;
150                            Ok(res.map_into_left_body())
151                        });
152                    } else {
153                        // User lacks required access -> 403 Forbidden
154                        return Box::pin(async move {
155                            Ok(req.into_response(
156                                HttpResponse::Forbidden()
157                                    .finish()
158                                    .map_into_right_body(),
159                            ))
160                        });
161                    }
162                }
163
164                // No matching pattern - allow through to handler
165                // (handler may use macro-level security like #[secured])
166                Box::pin(async move {
167                    let res = next(req).await?;
168                    Ok(res.map_into_left_body())
169                })
170            }
171            None => {
172                // Unauthenticated user
173                if path == login_url {
174                    // Allow access to login page
175                    Box::pin(async move {
176                        let res = next(req).await?;
177                        Ok(res.map_into_left_body())
178                    })
179                } else {
180                    #[cfg(feature = "http-basic")]
181                    if let Some(basic_config) = http_basic {
182                        // HTTP Basic Auth: Return 401 with WWW-Authenticate header
183                        let www_auth = basic_config.www_authenticate_header();
184                        return Box::pin(async move {
185                            Ok(req.into_response(
186                                HttpResponse::Unauthorized()
187                                    .append_header((http::header::WWW_AUTHENTICATE, www_auth))
188                                    .finish()
189                                    .map_into_right_body(),
190                            ))
191                        });
192                    }
193
194                    // Redirect to login page (fallback when http-basic not enabled or not configured)
195                    let redirect_url = login_url.to_string();
196                    Box::pin(async move {
197                        Ok(req.into_response(
198                            HttpResponse::Found()
199                                .append_header((http::header::LOCATION, redirect_url))
200                                .finish()
201                                .map_into_right_body(),
202                        ))
203                    })
204                }
205            }
206        }
207    }
208}
209
210// =============================================================================
211// Access Configuration
212// =============================================================================
213
214/// Access configuration for URL patterns.
215///
216/// # Spring Security Equivalent
217/// `AuthorizeHttpRequestsConfigurer`
218///
219/// # Example
220/// ```ignore
221/// use actix_security_core::http::security::authorizer::Access;
222///
223/// // Require ADMIN role
224/// let admin_access = Access::new().roles(vec!["ADMIN"]);
225///
226/// // Require any of these authorities
227/// let api_access = Access::new().authorities(vec!["api:read", "api:write"]);
228///
229/// // Require ADMIN or MANAGER role
230/// let management_access = Access::new().roles(vec!["ADMIN", "MANAGER"]);
231/// ```
232#[derive(Default)]
233pub struct Access {
234    pub(crate) roles: Vec<String>,
235    pub(crate) authorities: Vec<String>,
236}
237
238impl Access {
239    /// Creates a new empty access configuration.
240    pub fn new() -> Self {
241        Access {
242            roles: Vec::new(),
243            authorities: Vec::new(),
244        }
245    }
246
247    /// Requires any of the specified roles.
248    ///
249    /// # Spring Security Equivalent
250    /// `hasAnyRole("ADMIN", "USER")`
251    pub fn roles(mut self, roles: Vec<&str>) -> Self {
252        for role in roles {
253            let role_str = String::from(role);
254            if !self.roles.contains(&role_str) {
255                self.roles.push(role_str);
256            }
257        }
258        self
259    }
260
261    /// Requires any of the specified authorities.
262    ///
263    /// # Spring Security Equivalent
264    /// `hasAnyAuthority("users:read", "users:write")`
265    pub fn authorities(mut self, authorities: Vec<&str>) -> Self {
266        for authority in authorities {
267            let auth_str = String::from(authority);
268            if !self.authorities.contains(&auth_str) {
269                self.authorities.push(auth_str);
270            }
271        }
272        self
273    }
274}