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}