actix_security_core/http/security/crypto.rs
1//! Password encoding utilities.
2//!
3//! # Spring Security Equivalent
4//! `org.springframework.security.crypto.password.PasswordEncoder`
5//!
6//! # Feature Flags
7//! - `argon2`: Enables `Argon2PasswordEncoder` (recommended, default)
8//! - `bcrypt`: Enables `BCryptPasswordEncoder` (widely compatible)
9
10#[cfg(feature = "argon2")]
11use argon2::password_hash::rand_core::OsRng;
12#[cfg(feature = "argon2")]
13use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
14#[cfg(feature = "argon2")]
15use argon2::Argon2;
16
17/// Trait for encoding and verifying passwords.
18///
19/// # Spring Security Equivalent
20/// `PasswordEncoder` interface
21///
22/// # Example
23/// ```ignore
24/// use actix_security_core::http::security::crypto::{PasswordEncoder, Argon2PasswordEncoder};
25///
26/// let encoder = Argon2PasswordEncoder::new();
27/// let hash = encoder.encode("my_password");
28/// assert!(encoder.matches("my_password", &hash));
29/// ```
30pub trait PasswordEncoder: Send + Sync {
31 /// Encode the raw password.
32 ///
33 /// # Spring Equivalent
34 /// `PasswordEncoder.encode(CharSequence rawPassword)`
35 fn encode(&self, raw_password: &str) -> String;
36
37 /// Verify a raw password against an encoded password.
38 ///
39 /// # Spring Equivalent
40 /// `PasswordEncoder.matches(CharSequence rawPassword, String encodedPassword)`
41 fn matches(&self, raw_password: &str, encoded_password: &str) -> bool;
42
43 /// Returns true if the encoded password should be upgraded for better security.
44 ///
45 /// # Spring Equivalent
46 /// `PasswordEncoder.upgradeEncoding(String encodedPassword)`
47 fn upgrade_encoding(&self, _encoded_password: &str) -> bool {
48 false
49 }
50}
51
52/// Argon2 password encoder - the recommended encoder for new applications.
53///
54/// # Spring Security Equivalent
55/// `Argon2PasswordEncoder`
56///
57/// Argon2 is the winner of the Password Hashing Competition and is recommended
58/// by OWASP for password storage.
59///
60/// # Feature Flag
61/// Requires the `argon2` feature (enabled by default).
62///
63/// # Example
64/// ```
65/// use actix_security_core::http::security::crypto::{PasswordEncoder, Argon2PasswordEncoder};
66///
67/// let encoder = Argon2PasswordEncoder::new();
68/// let hash = encoder.encode("secret_password");
69///
70/// // Verify correct password
71/// assert!(encoder.matches("secret_password", &hash));
72///
73/// // Verify wrong password
74/// assert!(!encoder.matches("wrong_password", &hash));
75/// ```
76#[cfg(feature = "argon2")]
77#[derive(Clone)]
78pub struct Argon2PasswordEncoder {
79 argon2: Argon2<'static>,
80}
81
82#[cfg(feature = "argon2")]
83impl Argon2PasswordEncoder {
84 /// Creates a new Argon2 password encoder with default settings.
85 pub fn new() -> Self {
86 Argon2PasswordEncoder {
87 argon2: Argon2::default(),
88 }
89 }
90}
91
92#[cfg(feature = "argon2")]
93impl Default for Argon2PasswordEncoder {
94 fn default() -> Self {
95 Self::new()
96 }
97}
98
99#[cfg(feature = "argon2")]
100impl PasswordEncoder for Argon2PasswordEncoder {
101 fn encode(&self, raw_password: &str) -> String {
102 let salt = SaltString::generate(&mut OsRng);
103 self.argon2
104 .hash_password(raw_password.as_bytes(), &salt)
105 .expect("Failed to hash password")
106 .to_string()
107 }
108
109 fn matches(&self, raw_password: &str, encoded_password: &str) -> bool {
110 match PasswordHash::new(encoded_password) {
111 Ok(parsed_hash) => self
112 .argon2
113 .verify_password(raw_password.as_bytes(), &parsed_hash)
114 .is_ok(),
115 Err(_) => false,
116 }
117 }
118}
119
120/// BCrypt password encoder - widely compatible with other frameworks.
121///
122/// # Spring Security Equivalent
123/// `BCryptPasswordEncoder`
124///
125/// BCrypt is a widely-used password hashing algorithm that is compatible with
126/// many other frameworks (PHP, Node.js, etc.). Use this when migrating from
127/// other systems or for interoperability.
128///
129/// # Feature Flag
130/// Requires the `bcrypt` feature.
131///
132/// # Example
133/// ```ignore
134/// use actix_security_core::http::security::crypto::{PasswordEncoder, BCryptPasswordEncoder};
135///
136/// let encoder = BCryptPasswordEncoder::new();
137/// let hash = encoder.encode("secret_password");
138///
139/// // Verify correct password
140/// assert!(encoder.matches("secret_password", &hash));
141/// ```
142#[cfg(feature = "bcrypt")]
143#[derive(Clone)]
144pub struct BCryptPasswordEncoder {
145 cost: u32,
146}
147
148#[cfg(feature = "bcrypt")]
149impl BCryptPasswordEncoder {
150 /// Creates a new BCrypt password encoder with default cost (12).
151 pub fn new() -> Self {
152 Self { cost: 12 }
153 }
154
155 /// Creates a new BCrypt password encoder with custom cost.
156 ///
157 /// Cost should be between 4 and 31. Higher values are more secure
158 /// but slower. Default is 12.
159 pub fn with_cost(cost: u32) -> Self {
160 let cost = cost.clamp(4, 31);
161 Self { cost }
162 }
163
164 /// Create encoder with strength level.
165 ///
166 /// - `weak`: cost 10 (fast, for development)
167 /// - `default`: cost 12 (balanced)
168 /// - `strong`: cost 14 (secure, slower)
169 pub fn with_strength(strength: &str) -> Self {
170 let cost = match strength {
171 "weak" => 10,
172 "strong" => 14,
173 _ => 12,
174 };
175 Self { cost }
176 }
177}
178
179#[cfg(feature = "bcrypt")]
180impl Default for BCryptPasswordEncoder {
181 fn default() -> Self {
182 Self::new()
183 }
184}
185
186#[cfg(feature = "bcrypt")]
187impl PasswordEncoder for BCryptPasswordEncoder {
188 fn encode(&self, raw_password: &str) -> String {
189 bcrypt::hash(raw_password, self.cost).expect("Failed to hash password with bcrypt")
190 }
191
192 fn matches(&self, raw_password: &str, encoded_password: &str) -> bool {
193 bcrypt::verify(raw_password, encoded_password).unwrap_or(false)
194 }
195
196 fn upgrade_encoding(&self, encoded_password: &str) -> bool {
197 // Check if the cost in the hash is lower than current setting
198 // BCrypt hashes start with $2a$, $2b$, or $2y$ followed by cost
199 if encoded_password.starts_with("$2") && encoded_password.len() > 7 {
200 // Extract cost from hash (format: $2a$XX$ where XX is cost)
201 if let Some(cost_str) = encoded_password.get(4..6) {
202 if let Ok(hash_cost) = cost_str.parse::<u32>() {
203 return hash_cost < self.cost;
204 }
205 }
206 }
207 true // Invalid hash or unable to parse, recommend re-encoding
208 }
209}
210
211/// No-op password encoder that stores passwords in plain text.
212///
213/// # Spring Security Equivalent
214/// `NoOpPasswordEncoder`
215///
216/// # Warning
217/// **NEVER use this in production!** This is only for testing/development.
218/// Passwords are stored in plain text without any hashing.
219///
220/// # Example
221/// ```
222/// use actix_security_core::http::security::crypto::{PasswordEncoder, NoOpPasswordEncoder};
223///
224/// let encoder = NoOpPasswordEncoder;
225/// let encoded = encoder.encode("password");
226/// assert_eq!(encoded, "password"); // Plain text!
227/// assert!(encoder.matches("password", &encoded));
228/// ```
229#[derive(Clone, Copy, Default)]
230pub struct NoOpPasswordEncoder;
231
232impl PasswordEncoder for NoOpPasswordEncoder {
233 fn encode(&self, raw_password: &str) -> String {
234 raw_password.to_string()
235 }
236
237 fn matches(&self, raw_password: &str, encoded_password: &str) -> bool {
238 raw_password == encoded_password
239 }
240}
241
242/// Default encoding algorithm for DelegatingPasswordEncoder.
243#[derive(Debug, Clone, Copy, Default)]
244pub enum DefaultEncoder {
245 /// Use Argon2 (recommended)
246 #[default]
247 Argon2,
248 /// Use BCrypt (compatible)
249 BCrypt,
250}
251
252/// Delegating password encoder that supports multiple encoding formats.
253///
254/// # Spring Security Equivalent
255/// `DelegatingPasswordEncoder`
256///
257/// This encoder can verify passwords encoded with different algorithms
258/// by detecting the encoding format from a prefix in the stored hash.
259///
260/// Supported formats:
261/// - `{argon2}hash` - Argon2 encoded password
262/// - `{bcrypt}hash` - BCrypt encoded password
263/// - `{noop}plain` - Plain text (for testing only!)
264///
265/// # Feature Flag
266/// Requires the `argon2` feature (enabled by default).
267///
268/// # Example
269/// ```
270/// use actix_security_core::http::security::crypto::{PasswordEncoder, DelegatingPasswordEncoder};
271///
272/// let encoder = DelegatingPasswordEncoder::new();
273///
274/// // Encode with default (argon2)
275/// let hash = encoder.encode("password");
276/// assert!(hash.starts_with("{argon2}"));
277///
278/// // Can verify both formats
279/// assert!(encoder.matches("password", &hash));
280/// assert!(encoder.matches("plain", "{noop}plain"));
281/// ```
282#[cfg(feature = "argon2")]
283#[derive(Clone)]
284pub struct DelegatingPasswordEncoder {
285 argon2: Argon2PasswordEncoder,
286 #[cfg(feature = "bcrypt")]
287 bcrypt: BCryptPasswordEncoder,
288 default_encoder: DefaultEncoder,
289}
290
291#[cfg(feature = "argon2")]
292impl DelegatingPasswordEncoder {
293 /// Creates a new delegating password encoder.
294 /// Default encoding is Argon2.
295 pub fn new() -> Self {
296 DelegatingPasswordEncoder {
297 argon2: Argon2PasswordEncoder::new(),
298 #[cfg(feature = "bcrypt")]
299 bcrypt: BCryptPasswordEncoder::new(),
300 default_encoder: DefaultEncoder::Argon2,
301 }
302 }
303
304 /// Set the default encoder to use for new passwords.
305 pub fn default_encoder(mut self, encoder: DefaultEncoder) -> Self {
306 self.default_encoder = encoder;
307 self
308 }
309
310 /// Use BCrypt as the default encoder.
311 #[cfg(feature = "bcrypt")]
312 pub fn use_bcrypt(self) -> Self {
313 self.default_encoder(DefaultEncoder::BCrypt)
314 }
315}
316
317#[cfg(feature = "argon2")]
318impl Default for DelegatingPasswordEncoder {
319 fn default() -> Self {
320 Self::new()
321 }
322}
323
324#[cfg(feature = "argon2")]
325impl PasswordEncoder for DelegatingPasswordEncoder {
326 fn encode(&self, raw_password: &str) -> String {
327 match self.default_encoder {
328 DefaultEncoder::Argon2 => {
329 format!("{{argon2}}{}", self.argon2.encode(raw_password))
330 }
331 #[cfg(feature = "bcrypt")]
332 DefaultEncoder::BCrypt => {
333 format!("{{bcrypt}}{}", self.bcrypt.encode(raw_password))
334 }
335 #[cfg(not(feature = "bcrypt"))]
336 DefaultEncoder::BCrypt => {
337 // Fall back to argon2 if bcrypt not available
338 format!("{{argon2}}{}", self.argon2.encode(raw_password))
339 }
340 }
341 }
342
343 fn matches(&self, raw_password: &str, encoded_password: &str) -> bool {
344 if let Some(hash) = encoded_password.strip_prefix("{argon2}") {
345 self.argon2.matches(raw_password, hash)
346 } else if let Some(plain) = encoded_password.strip_prefix("{noop}") {
347 raw_password == plain
348 } else {
349 #[cfg(feature = "bcrypt")]
350 if let Some(hash) = encoded_password.strip_prefix("{bcrypt}") {
351 return self.bcrypt.matches(raw_password, hash);
352 }
353 // Legacy: try bcrypt without prefix (common in migrations)
354 #[cfg(feature = "bcrypt")]
355 if encoded_password.starts_with("$2") {
356 return self.bcrypt.matches(raw_password, encoded_password);
357 }
358 // Legacy: assume plain text for backward compatibility
359 raw_password == encoded_password
360 }
361 }
362
363 fn upgrade_encoding(&self, encoded_password: &str) -> bool {
364 // Recommend upgrade if not using the preferred encoder
365 match self.default_encoder {
366 DefaultEncoder::Argon2 => !encoded_password.starts_with("{argon2}"),
367 DefaultEncoder::BCrypt => !encoded_password.starts_with("{bcrypt}"),
368 }
369 }
370}
371
372#[cfg(all(test, feature = "argon2"))]
373mod tests {
374 use super::*;
375
376 #[test]
377 fn test_argon2_encoder() {
378 let encoder = Argon2PasswordEncoder::new();
379 let password = "test_password_123";
380
381 let hash = encoder.encode(password);
382
383 // Hash should not equal plain password
384 assert_ne!(hash, password);
385
386 // Should verify correctly
387 assert!(encoder.matches(password, &hash));
388 assert!(!encoder.matches("wrong_password", &hash));
389 }
390
391 #[test]
392 fn test_noop_encoder() {
393 let encoder = NoOpPasswordEncoder;
394 let password = "plain_password";
395
396 let encoded = encoder.encode(password);
397 assert_eq!(encoded, password);
398 assert!(encoder.matches(password, &encoded));
399 }
400
401 #[test]
402 fn test_delegating_encoder() {
403 let encoder = DelegatingPasswordEncoder::new();
404
405 // Test argon2 encoding
406 let hash = encoder.encode("password");
407 assert!(hash.starts_with("{argon2}"));
408 assert!(encoder.matches("password", &hash));
409
410 // Test noop format
411 assert!(encoder.matches("plain", "{noop}plain"));
412
413 // Test upgrade recommendation
414 assert!(encoder.upgrade_encoding("{noop}plain"));
415 assert!(!encoder.upgrade_encoding(&hash));
416 }
417}