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}