acton_htmx/auth/user.rs
1//! User model and authentication types
2//!
3//! Provides the core user model with email validation, password hashing,
4//! and database integration via SQLx.
5//!
6//! # Example
7//!
8//! ```rust,no_run
9//! use acton_htmx::auth::user::{User, CreateUser, EmailAddress};
10//!
11//! # async fn example() -> anyhow::Result<()> {
12//! // Create a new user
13//! let email = EmailAddress::parse("user@example.com")?;
14//! let create_user = CreateUser {
15//! email,
16//! password: "secure-password".to_string(),
17//! };
18//!
19//! // Hash password and save to database
20//! // let user = User::create(create_user, &pool).await?;
21//! # Ok(())
22//! # }
23//! ```
24
25use crate::auth::password::{hash_password, verify_password, PasswordError};
26use chrono::{DateTime, Utc};
27use serde::{Deserialize, Serialize};
28use sqlx::{FromRow, Type};
29use thiserror::Error;
30use validator::Validate;
31
32/// User authentication errors
33#[derive(Debug, Error)]
34pub enum UserError {
35 /// Invalid email address format
36 #[error("Invalid email address: {0}")]
37 InvalidEmail(String),
38
39 /// Password too weak
40 #[error("Password does not meet requirements: {0}")]
41 WeakPassword(String),
42
43 /// Validation failed
44 #[error("Validation error: {0}")]
45 ValidationFailed(String),
46
47 /// Password hashing failed
48 #[error("Password hashing failed: {0}")]
49 PasswordHashingFailed(#[from] PasswordError),
50
51 /// Database operation failed
52 #[error("Database error: {0}")]
53 DatabaseError(#[from] sqlx::Error),
54
55 /// User not found
56 #[error("User not found")]
57 NotFound,
58
59 /// Invalid credentials
60 #[error("Invalid email or password")]
61 InvalidCredentials,
62}
63
64/// Email address newtype for validation
65///
66/// Ensures all email addresses in the system are valid.
67///
68/// # Example
69///
70/// ```rust
71/// use acton_htmx::auth::user::EmailAddress;
72///
73/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
74/// let email = EmailAddress::parse("user@example.com")?;
75/// assert_eq!(email.as_str(), "user@example.com");
76/// # Ok(())
77/// # }
78/// ```
79#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Type)]
80#[serde(transparent)]
81#[sqlx(transparent)]
82pub struct EmailAddress(String);
83
84impl EmailAddress {
85 /// Parse and validate an email address
86 ///
87 /// # Errors
88 ///
89 /// Returns error if email format is invalid
90 ///
91 /// # Example
92 ///
93 /// ```rust
94 /// use acton_htmx::auth::user::EmailAddress;
95 ///
96 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
97 /// let email = EmailAddress::parse("user@example.com")?;
98 /// assert_eq!(email.as_str(), "user@example.com");
99 ///
100 /// let invalid = EmailAddress::parse("not-an-email");
101 /// assert!(invalid.is_err());
102 /// # Ok(())
103 /// # }
104 /// ```
105 pub fn parse(email: impl Into<String>) -> Result<Self, UserError> {
106 // Validate with validator crate
107 #[derive(Validate)]
108 struct EmailValidator {
109 #[validate(email)]
110 email: String,
111 }
112
113 let email = email.into();
114
115 // Basic email validation
116 if !email.contains('@') || !email.contains('.') {
117 return Err(UserError::InvalidEmail(
118 "Email must contain @ and domain".to_string(),
119 ));
120 }
121
122 let validator = EmailValidator {
123 email: email.clone(),
124 };
125
126 validator.validate().map_err(|e| {
127 UserError::ValidationFailed(format!("Invalid email format: {e}"))
128 })?;
129
130 Ok(Self(email.to_lowercase()))
131 }
132
133 /// Get the email as a string slice
134 #[must_use]
135 pub fn as_str(&self) -> &str {
136 &self.0
137 }
138
139 /// Convert into the inner string
140 #[must_use]
141 pub fn into_inner(self) -> String {
142 self.0
143 }
144}
145
146impl std::fmt::Display for EmailAddress {
147 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148 write!(f, "{}", self.0)
149 }
150}
151
152impl std::str::FromStr for EmailAddress {
153 type Err = UserError;
154
155 fn from_str(s: &str) -> Result<Self, Self::Err> {
156 Self::parse(s)
157 }
158}
159
160/// User model representing an authenticated user
161///
162/// This model is designed to be stored in a database and includes
163/// all necessary fields for authentication, authorization, and session management.
164///
165/// # Security Considerations
166///
167/// - Password hash is stored, never the plaintext password
168/// - Email addresses are normalized to lowercase
169/// - Created/updated timestamps for audit trail
170/// - Roles and permissions for authorization (Cedar policy integration)
171///
172/// # Database Schema
173///
174/// ```sql
175/// CREATE TABLE users (
176/// id BIGSERIAL PRIMARY KEY,
177/// email TEXT NOT NULL UNIQUE,
178/// password_hash TEXT NOT NULL,
179/// roles TEXT[] NOT NULL DEFAULT '{"user"}',
180/// permissions TEXT[] NOT NULL DEFAULT '{}',
181/// email_verified BOOLEAN NOT NULL DEFAULT FALSE,
182/// created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
183/// updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
184/// );
185///
186/// CREATE INDEX idx_users_email ON users(email);
187/// CREATE INDEX idx_users_roles ON users USING GIN(roles);
188/// ```
189#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
190pub struct User {
191 /// User ID (primary key)
192 pub id: i64,
193
194 /// Email address (unique, normalized to lowercase)
195 #[serde(serialize_with = "serialize_email")]
196 #[serde(deserialize_with = "deserialize_email")]
197 pub email: EmailAddress,
198
199 /// Argon2id password hash (never exposed in responses)
200 #[serde(skip_serializing)]
201 pub password_hash: String,
202
203 /// User roles for authorization
204 /// Common roles: "user", "admin", "moderator"
205 /// Used by Cedar policy engine for role-based access control (RBAC)
206 pub roles: Vec<String>,
207
208 /// User permissions for fine-grained authorization
209 /// Format: "resource:action" (e.g., "posts:create", "posts:delete")
210 /// Used by Cedar policy engine for attribute-based access control (ABAC)
211 pub permissions: Vec<String>,
212
213 /// Email verification status
214 /// Required for certain actions (e.g., posting content)
215 pub email_verified: bool,
216
217 /// Timestamp when user was created
218 pub created_at: DateTime<Utc>,
219
220 /// Timestamp when user was last updated
221 pub updated_at: DateTime<Utc>,
222}
223
224// Custom serialization for EmailAddress in User struct
225fn serialize_email<S>(email: &EmailAddress, serializer: S) -> Result<S::Ok, S::Error>
226where
227 S: serde::Serializer,
228{
229 serializer.serialize_str(email.as_str())
230}
231
232fn deserialize_email<'de, D>(deserializer: D) -> Result<EmailAddress, D::Error>
233where
234 D: serde::Deserializer<'de>,
235{
236 let s = String::deserialize(deserializer)?;
237 EmailAddress::parse(s).map_err(serde::de::Error::custom)
238}
239
240impl User {
241 /// Verify a password against this user's hash
242 ///
243 /// Uses constant-time comparison to prevent timing attacks.
244 ///
245 /// # Errors
246 ///
247 /// Returns error if verification fails
248 ///
249 /// # Example
250 ///
251 /// ```rust,no_run
252 /// # use acton_htmx::auth::user::User;
253 /// # async fn example(user: User) -> anyhow::Result<()> {
254 /// if user.verify_password("user-password")? {
255 /// println!("Password correct!");
256 /// }
257 /// # Ok(())
258 /// # }
259 /// ```
260 pub fn verify_password(&self, password: &str) -> Result<bool, PasswordError> {
261 verify_password(password, &self.password_hash)
262 }
263
264 /// Create a new user with hashed password
265 ///
266 /// # Errors
267 ///
268 /// Returns error if:
269 /// - Password hashing fails
270 /// - Database operation fails
271 /// - Email already exists (unique constraint)
272 ///
273 /// # Example
274 ///
275 /// ```rust,no_run
276 /// use acton_htmx::auth::user::{User, CreateUser, EmailAddress};
277 /// use sqlx::PgPool;
278 ///
279 /// # async fn example(pool: &PgPool) -> anyhow::Result<()> {
280 /// let email = EmailAddress::parse("new@example.com")?;
281 /// let create = CreateUser {
282 /// email,
283 /// password: "secure-password".to_string(),
284 /// };
285 ///
286 /// let user = User::create(create, pool).await?;
287 /// println!("Created user with ID: {}", user.id);
288 /// # Ok(())
289 /// # }
290 /// ```
291 #[cfg(feature = "postgres")]
292 pub async fn create(
293 data: CreateUser,
294 pool: &sqlx::PgPool,
295 ) -> Result<Self, UserError> {
296 // Validate password strength
297 validate_password_strength(&data.password)?;
298
299 // Hash password
300 let password_hash = hash_password(&data.password)?;
301
302 // Insert into database with default role "user"
303 let user = sqlx::query_as::<_, Self>(
304 r"
305 INSERT INTO users (email, password_hash, roles, permissions, email_verified)
306 VALUES ($1, $2, $3, $4, $5)
307 RETURNING id, email, password_hash, roles, permissions, email_verified, created_at, updated_at
308 ",
309 )
310 .bind(data.email.as_str())
311 .bind(&password_hash)
312 .bind(vec!["user".to_string()]) // Default role
313 .bind(Vec::<String>::new()) // Empty permissions
314 .bind(false) // Email not verified
315 .fetch_one(pool)
316 .await?;
317
318 Ok(user)
319 }
320
321 /// Find a user by email
322 ///
323 /// # Errors
324 ///
325 /// Returns error if database operation fails or user not found
326 ///
327 /// # Example
328 ///
329 /// ```rust,no_run
330 /// use acton_htmx::auth::user::{User, EmailAddress};
331 /// use sqlx::PgPool;
332 ///
333 /// # async fn example(pool: &PgPool) -> anyhow::Result<()> {
334 /// let email = EmailAddress::parse("user@example.com")?;
335 /// let user = User::find_by_email(&email, pool).await?;
336 /// println!("Found user: {}", user.email);
337 /// # Ok(())
338 /// # }
339 /// ```
340 #[cfg(feature = "postgres")]
341 pub async fn find_by_email(
342 email: &EmailAddress,
343 pool: &sqlx::PgPool,
344 ) -> Result<Self, UserError> {
345 let user = sqlx::query_as::<_, Self>(
346 r"
347 SELECT id, email, password_hash, roles, permissions, email_verified, created_at, updated_at
348 FROM users
349 WHERE email = $1
350 ",
351 )
352 .bind(email.as_str())
353 .fetch_optional(pool)
354 .await?
355 .ok_or(UserError::NotFound)?;
356
357 Ok(user)
358 }
359
360 /// Find a user by ID
361 ///
362 /// # Errors
363 ///
364 /// Returns error if database operation fails or user not found
365 #[cfg(feature = "postgres")]
366 pub async fn find_by_id(id: i64, pool: &sqlx::PgPool) -> Result<Self, UserError> {
367 let user = sqlx::query_as::<_, Self>(
368 r"
369 SELECT id, email, password_hash, roles, permissions, email_verified, created_at, updated_at
370 FROM users
371 WHERE id = $1
372 ",
373 )
374 .bind(id)
375 .fetch_optional(pool)
376 .await?
377 .ok_or(UserError::NotFound)?;
378
379 Ok(user)
380 }
381
382 /// Authenticate a user with email and password
383 ///
384 /// # Errors
385 ///
386 /// Returns `UserError::InvalidCredentials` if:
387 /// - Email not found
388 /// - Password incorrect
389 ///
390 /// Returns other errors for database or verification failures
391 ///
392 /// # Example
393 ///
394 /// ```rust,no_run
395 /// use acton_htmx::auth::user::{User, EmailAddress};
396 /// use sqlx::PgPool;
397 ///
398 /// # async fn example(pool: &PgPool) -> anyhow::Result<()> {
399 /// let email = EmailAddress::parse("user@example.com")?;
400 /// match User::authenticate(&email, "password", pool).await {
401 /// Ok(user) => println!("Authenticated: {}", user.email),
402 /// Err(_) => println!("Invalid credentials"),
403 /// }
404 /// # Ok(())
405 /// # }
406 /// ```
407 #[cfg(feature = "postgres")]
408 pub async fn authenticate(
409 email: &EmailAddress,
410 password: &str,
411 pool: &sqlx::PgPool,
412 ) -> Result<Self, UserError> {
413 // Find user by email
414 let user = Self::find_by_email(email, pool)
415 .await
416 .map_err(|_| UserError::InvalidCredentials)?;
417
418 // Verify password
419 let valid = user
420 .verify_password(password)
421 .map_err(|_| UserError::InvalidCredentials)?;
422
423 if !valid {
424 return Err(UserError::InvalidCredentials);
425 }
426
427 Ok(user)
428 }
429}
430
431/// Data for creating a new user
432///
433/// # Example
434///
435/// ```rust
436/// use acton_htmx::auth::user::{CreateUser, EmailAddress};
437///
438/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
439/// let create = CreateUser {
440/// email: EmailAddress::parse("new@example.com")?,
441/// password: "secure-password".to_string(),
442/// };
443/// # Ok(())
444/// # }
445/// ```
446#[derive(Debug, Clone, Validate)]
447pub struct CreateUser {
448 /// User's email address
449 pub email: EmailAddress,
450
451 /// Plaintext password (will be hashed before storage)
452 #[validate(length(min = 8, message = "Password must be at least 8 characters"))]
453 pub password: String,
454}
455
456/// Validate password strength
457///
458/// # Requirements
459///
460/// - At least 8 characters
461/// - At least one uppercase letter
462/// - At least one lowercase letter
463/// - At least one digit
464///
465/// # Errors
466///
467/// Returns error if password does not meet requirements
468fn validate_password_strength(password: &str) -> Result<(), UserError> {
469 if password.len() < 8 {
470 return Err(UserError::WeakPassword(
471 "Password must be at least 8 characters".to_string(),
472 ));
473 }
474
475 let has_uppercase = password.chars().any(char::is_uppercase);
476 let has_lowercase = password.chars().any(char::is_lowercase);
477 let has_digit = password.chars().any(|c| c.is_ascii_digit());
478
479 if !has_uppercase {
480 return Err(UserError::WeakPassword(
481 "Password must contain at least one uppercase letter".to_string(),
482 ));
483 }
484
485 if !has_lowercase {
486 return Err(UserError::WeakPassword(
487 "Password must contain at least one lowercase letter".to_string(),
488 ));
489 }
490
491 if !has_digit {
492 return Err(UserError::WeakPassword(
493 "Password must contain at least one digit".to_string(),
494 ));
495 }
496
497 Ok(())
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503
504 #[test]
505 fn test_email_address_parsing() {
506 // Valid emails
507 assert!(EmailAddress::parse("user@example.com").is_ok());
508 assert!(EmailAddress::parse("user.name@example.co.uk").is_ok());
509 assert!(EmailAddress::parse("user+tag@example.com").is_ok());
510
511 // Invalid emails
512 assert!(EmailAddress::parse("not-an-email").is_err());
513 assert!(EmailAddress::parse("@example.com").is_err());
514 assert!(EmailAddress::parse("user@").is_err());
515 assert!(EmailAddress::parse("user").is_err());
516 }
517
518 #[test]
519 fn test_email_normalization() {
520 let email1 = EmailAddress::parse("User@Example.COM").unwrap();
521 let email2 = EmailAddress::parse("user@example.com").unwrap();
522
523 assert_eq!(email1, email2);
524 assert_eq!(email1.as_str(), "user@example.com");
525 }
526
527 #[test]
528 fn test_password_strength_validation() {
529 // Valid passwords
530 assert!(validate_password_strength("SecurePass123").is_ok());
531 assert!(validate_password_strength("MyP@ssw0rd").is_ok());
532
533 // Too short
534 assert!(validate_password_strength("Pass1").is_err());
535
536 // Missing uppercase
537 assert!(matches!(
538 validate_password_strength("password123"),
539 Err(UserError::WeakPassword(_))
540 ));
541
542 // Missing lowercase
543 assert!(matches!(
544 validate_password_strength("PASSWORD123"),
545 Err(UserError::WeakPassword(_))
546 ));
547
548 // Missing digit
549 assert!(matches!(
550 validate_password_strength("PasswordOnly"),
551 Err(UserError::WeakPassword(_))
552 ));
553 }
554
555 #[test]
556 fn test_user_password_verification() {
557 let password = "TestPassword123";
558 let hash = hash_password(password).expect("Failed to hash password");
559
560 let user = User {
561 id: 1,
562 email: EmailAddress::parse("test@example.com").unwrap(),
563 password_hash: hash,
564 roles: vec!["user".to_string()],
565 permissions: vec![],
566 email_verified: false,
567 created_at: Utc::now(),
568 updated_at: Utc::now(),
569 };
570
571 assert!(user.verify_password(password).expect("Verification failed"));
572 assert!(!user
573 .verify_password("wrong-password")
574 .expect("Verification failed"));
575 }
576
577 #[test]
578 fn test_email_serialization() {
579 let email = EmailAddress::parse("test@example.com").unwrap();
580 let json = serde_json::to_string(&email).expect("Failed to serialize");
581 assert_eq!(json, r#""test@example.com""#);
582
583 let deserialized: EmailAddress =
584 serde_json::from_str(&json).expect("Failed to deserialize");
585 assert_eq!(deserialized, email);
586 }
587
588 #[test]
589 fn test_user_serialization_skips_password() {
590 let user = User {
591 id: 1,
592 email: EmailAddress::parse("test@example.com").unwrap(),
593 password_hash: "hash".to_string(),
594 roles: vec!["user".to_string()],
595 permissions: vec![],
596 email_verified: false,
597 created_at: Utc::now(),
598 updated_at: Utc::now(),
599 };
600
601 let json = serde_json::to_string(&user).expect("Failed to serialize");
602 assert!(!json.contains("password_hash"));
603 assert!(json.contains("test@example.com"));
604 }
605}