Skip to main content

authx_plugins/email_password/
service.rs

1use chrono::Utc;
2use rand::Rng;
3use tracing::instrument;
4use uuid::Uuid;
5
6use authx_core::{
7    brute_force::{LockoutConfig, LoginAttemptTracker},
8    crypto::{hash_password, sha256_hex, verify_password},
9    error::{AuthError, Result},
10    events::{AuthEvent, EventBus},
11    models::{CreateCredential, CreateSession, CreateUser, CredentialKind, Session, User},
12    validation::{validate_email, validate_password},
13};
14use authx_storage::ports::{CredentialRepository, SessionRepository, UserRepository};
15
16pub struct SignUpRequest {
17    pub email: String,
18    pub password: String,
19    pub ip: String,
20}
21
22pub struct SignInRequest {
23    pub email: String,
24    pub password: String,
25    pub ip: String,
26}
27
28#[derive(Debug)]
29pub struct AuthResponse {
30    pub user: User,
31    pub session: Session,
32    /// Raw opaque token returned to the client once. The SHA-256 hash is
33    /// stored in the database — never the raw value.
34    pub token: String,
35}
36
37pub struct EmailPasswordService<S> {
38    storage: S,
39    events: EventBus,
40    min_password_len: usize,
41    session_ttl_secs: i64,
42    lockout: Option<LoginAttemptTracker>,
43}
44
45impl<S> EmailPasswordService<S>
46where
47    S: UserRepository + SessionRepository + CredentialRepository + Clone + Send + Sync + 'static,
48{
49    pub fn new(
50        storage: S,
51        events: EventBus,
52        min_password_len: usize,
53        session_ttl_secs: i64,
54    ) -> Self {
55        Self {
56            storage,
57            events,
58            min_password_len,
59            session_ttl_secs,
60            lockout: None,
61        }
62    }
63
64    /// Enable brute-force lockout with the given configuration.
65    pub fn with_lockout(mut self, cfg: LockoutConfig) -> Self {
66        self.lockout = Some(LoginAttemptTracker::new(cfg));
67        self
68    }
69
70    #[instrument(skip(self, req), fields(email = %req.email))]
71    pub async fn sign_up(&self, req: SignUpRequest) -> Result<User> {
72        validate_email(&req.email)?;
73        validate_password(&req.password, self.min_password_len)?;
74
75        if UserRepository::find_by_email(&self.storage, &req.email)
76            .await?
77            .is_some()
78        {
79            return Err(AuthError::EmailTaken);
80        }
81
82        let hash = hash_password(&req.password)?;
83
84        let user = UserRepository::create(
85            &self.storage,
86            CreateUser {
87                email: req.email,
88                username: None,
89                metadata: None,
90            },
91        )
92        .await?;
93
94        CredentialRepository::create(
95            &self.storage,
96            CreateCredential {
97                user_id: user.id,
98                kind: CredentialKind::Password,
99                credential_hash: hash,
100                metadata: None,
101            },
102        )
103        .await?;
104
105        self.events
106            .emit(AuthEvent::UserCreated { user: user.clone() });
107        tracing::info!(user_id = %user.id, "user registered");
108        Ok(user)
109    }
110
111    #[instrument(skip(self, req), fields(email = %req.email))]
112    pub async fn sign_in(&self, req: SignInRequest) -> Result<AuthResponse> {
113        // Lockout check before touching storage — avoids timing leak.
114        if let Some(tracker) = &self.lockout {
115            if tracker.is_locked(&req.email) {
116                tracing::warn!(email = %req.email, "sign-in blocked: account locked");
117                return Err(AuthError::AccountLocked);
118            }
119        }
120
121        let user = UserRepository::find_by_email(&self.storage, &req.email)
122            .await?
123            .ok_or(AuthError::InvalidCredentials)?;
124
125        let hash = CredentialRepository::find_password_hash(&self.storage, user.id)
126            .await?
127            .ok_or(AuthError::InvalidCredentials)?;
128
129        if !verify_password(&hash, &req.password)? {
130            tracing::warn!(email = %req.email, "wrong password");
131            if let Some(tracker) = &self.lockout {
132                tracker.record_failure(&req.email);
133            }
134            return Err(AuthError::InvalidCredentials);
135        }
136
137        // Success — clear the failure counter.
138        if let Some(tracker) = &self.lockout {
139            tracker.record_success(&req.email);
140        }
141
142        let raw_token = generate_token();
143        let token_hash = sha256_hex(raw_token.as_bytes());
144
145        let session = SessionRepository::create(
146            &self.storage,
147            CreateSession {
148                user_id: user.id,
149                token_hash,
150                device_info: serde_json::Value::Null,
151                ip_address: req.ip,
152                org_id: None,
153                expires_at: Utc::now() + chrono::Duration::seconds(self.session_ttl_secs),
154            },
155        )
156        .await?;
157
158        self.events.emit(AuthEvent::SignIn {
159            user: user.clone(),
160            session: session.clone(),
161        });
162        tracing::info!(user_id = %user.id, session_id = %session.id, "signed in");
163
164        Ok(AuthResponse {
165            user,
166            session,
167            token: raw_token,
168        })
169    }
170
171    #[instrument(skip(self))]
172    pub async fn sign_out(&self, session_id: Uuid) -> Result<()> {
173        SessionRepository::invalidate(&self.storage, session_id).await?;
174        tracing::info!(session_id = %session_id, "session invalidated");
175        Ok(())
176    }
177
178    #[instrument(skip(self))]
179    pub async fn sign_out_all(&self, user_id: Uuid) -> Result<()> {
180        SessionRepository::invalidate_all_for_user(&self.storage, user_id).await?;
181        tracing::info!(user_id = %user_id, "all sessions invalidated");
182        Ok(())
183    }
184
185    #[instrument(skip(self))]
186    pub async fn list_sessions(&self, user_id: Uuid) -> Result<Vec<Session>> {
187        SessionRepository::find_by_user(&self.storage, user_id).await
188    }
189}
190
191fn generate_token() -> String {
192    let bytes: [u8; 32] = rand::thread_rng().gen();
193    hex::encode(bytes)
194}