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}