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}