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