Skip to main content

better_auth_api/plugins/
email_password.rs

1use argon2::password_hash::{SaltString, rand_core::OsRng};
2use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
3use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5use validator::Validate;
6
7use better_auth_core::adapters::DatabaseAdapter;
8use better_auth_core::entity::{AuthSession, AuthUser};
9use better_auth_core::{AuthContext, AuthPlugin, AuthRoute};
10use better_auth_core::{AuthError, AuthResult};
11use better_auth_core::{AuthRequest, AuthResponse, CreateUser, CreateVerification, HttpMethod};
12
13/// Email and password authentication plugin
14pub struct EmailPasswordPlugin {
15    config: EmailPasswordConfig,
16}
17
18#[derive(Debug, Clone)]
19pub struct EmailPasswordConfig {
20    pub enable_signup: bool,
21    pub require_email_verification: bool,
22    pub password_min_length: usize,
23}
24
25#[derive(Debug, Deserialize, Validate)]
26#[allow(dead_code)]
27struct SignUpRequest {
28    #[validate(length(min = 1, message = "Name is required"))]
29    name: String,
30    #[validate(email(message = "Invalid email address"))]
31    email: String,
32    #[validate(length(min = 1, message = "Password is required"))]
33    password: String,
34    username: Option<String>,
35    #[serde(rename = "displayUsername")]
36    display_username: Option<String>,
37    #[serde(rename = "callbackURL")]
38    callback_url: Option<String>,
39}
40
41#[derive(Debug, Deserialize, Validate)]
42#[allow(dead_code)]
43struct SignInRequest {
44    #[validate(email(message = "Invalid email address"))]
45    email: String,
46    #[validate(length(min = 1, message = "Password is required"))]
47    password: String,
48    #[serde(rename = "callbackURL")]
49    callback_url: Option<String>,
50    #[serde(rename = "rememberMe")]
51    remember_me: Option<bool>,
52}
53
54#[derive(Debug, Deserialize, Validate)]
55#[allow(dead_code)]
56struct SignInUsernameRequest {
57    #[validate(length(min = 1, message = "Username is required"))]
58    username: String,
59    #[validate(length(min = 1, message = "Password is required"))]
60    password: String,
61    #[serde(rename = "rememberMe")]
62    remember_me: Option<bool>,
63}
64
65#[derive(Debug, Serialize)]
66struct SignUpResponse<U: Serialize> {
67    token: Option<String>,
68    user: U,
69}
70
71#[derive(Debug, Serialize)]
72struct SignInResponse<U: Serialize> {
73    redirect: bool,
74    token: String,
75    url: Option<String>,
76    user: U,
77}
78
79impl EmailPasswordPlugin {
80    #[allow(clippy::new_without_default)]
81    pub fn new() -> Self {
82        Self {
83            config: EmailPasswordConfig::default(),
84        }
85    }
86
87    pub fn with_config(config: EmailPasswordConfig) -> Self {
88        Self { config }
89    }
90
91    pub fn enable_signup(mut self, enable: bool) -> Self {
92        self.config.enable_signup = enable;
93        self
94    }
95
96    pub fn require_email_verification(mut self, require: bool) -> Self {
97        self.config.require_email_verification = require;
98        self
99    }
100
101    pub fn password_min_length(mut self, length: usize) -> Self {
102        self.config.password_min_length = length;
103        self
104    }
105
106    async fn handle_sign_up<DB: DatabaseAdapter>(
107        &self,
108        req: &AuthRequest,
109        ctx: &AuthContext<DB>,
110    ) -> AuthResult<AuthResponse> {
111        if !self.config.enable_signup {
112            return Err(AuthError::forbidden("User registration is not enabled"));
113        }
114
115        let signup_req: SignUpRequest = match better_auth_core::validate_request_body(req) {
116            Ok(v) => v,
117            Err(resp) => return Ok(resp),
118        };
119
120        // Validate password
121        self.validate_password(&signup_req.password, ctx)?;
122
123        // Check if user already exists
124        if ctx
125            .database
126            .get_user_by_email(&signup_req.email)
127            .await?
128            .is_some()
129        {
130            return Err(AuthError::conflict("A user with this email already exists"));
131        }
132
133        // Hash password
134        let password_hash = self.hash_password(&signup_req.password)?;
135
136        // Create user with password hash in metadata
137        let metadata = serde_json::json!({
138            "password_hash": password_hash,
139        });
140
141        let mut create_user = CreateUser::new()
142            .with_email(&signup_req.email)
143            .with_name(&signup_req.name);
144        if let Some(username) = signup_req.username {
145            create_user = create_user.with_username(username);
146        }
147        if let Some(display_username) = signup_req.display_username {
148            create_user.display_username = Some(display_username);
149        }
150        create_user.metadata = Some(metadata);
151
152        let user = ctx.database.create_user(create_user).await?;
153
154        // Create session
155        let session_manager =
156            better_auth_core::SessionManager::new(ctx.config.clone(), ctx.database.clone());
157        let session = session_manager.create_session(&user, None, None).await?;
158
159        let response = SignUpResponse {
160            token: Some(session.token().to_string()),
161            user,
162        };
163
164        // Create session cookie
165        let cookie_header = self.create_session_cookie(session.token(), ctx);
166
167        Ok(AuthResponse::json(200, &response)?.with_header("Set-Cookie", cookie_header))
168    }
169
170    async fn handle_sign_in<DB: DatabaseAdapter>(
171        &self,
172        req: &AuthRequest,
173        ctx: &AuthContext<DB>,
174    ) -> AuthResult<AuthResponse> {
175        let signin_req: SignInRequest = match better_auth_core::validate_request_body(req) {
176            Ok(v) => v,
177            Err(resp) => return Ok(resp),
178        };
179
180        // Get user by email
181        let user = ctx
182            .database
183            .get_user_by_email(&signin_req.email)
184            .await?
185            .ok_or(AuthError::InvalidCredentials)?;
186
187        // Verify password
188        let stored_hash = user
189            .metadata()
190            .get("password_hash")
191            .and_then(|v| v.as_str())
192            .ok_or(AuthError::InvalidCredentials)?;
193
194        self.verify_password(&signin_req.password, stored_hash)?;
195
196        // Check if 2FA is enabled
197        if user.two_factor_enabled() {
198            let pending_token = format!("2fa_{}", uuid::Uuid::new_v4());
199            ctx.database
200                .create_verification(CreateVerification {
201                    identifier: format!("2fa_pending:{}", pending_token),
202                    value: user.id().to_string(),
203                    expires_at: chrono::Utc::now() + chrono::Duration::minutes(5),
204                })
205                .await?;
206            return Ok(AuthResponse::json(
207                200,
208                &serde_json::json!({
209                    "twoFactorRedirect": true,
210                    "token": pending_token,
211                }),
212            )?);
213        }
214
215        // Create session
216        let session_manager =
217            better_auth_core::SessionManager::new(ctx.config.clone(), ctx.database.clone());
218        let session = session_manager.create_session(&user, None, None).await?;
219
220        let response = SignInResponse {
221            redirect: false,
222            token: session.token().to_string(),
223            url: None,
224            user,
225        };
226
227        // Create session cookie
228        let cookie_header = self.create_session_cookie(session.token(), ctx);
229
230        Ok(AuthResponse::json(200, &response)?.with_header("Set-Cookie", cookie_header))
231    }
232
233    async fn handle_sign_in_username<DB: DatabaseAdapter>(
234        &self,
235        req: &AuthRequest,
236        ctx: &AuthContext<DB>,
237    ) -> AuthResult<AuthResponse> {
238        let signin_req: SignInUsernameRequest = match better_auth_core::validate_request_body(req) {
239            Ok(v) => v,
240            Err(resp) => return Ok(resp),
241        };
242
243        // Get user by username
244        let user = ctx
245            .database
246            .get_user_by_username(&signin_req.username)
247            .await?
248            .ok_or(AuthError::InvalidCredentials)?;
249
250        // Verify password
251        let stored_hash = user
252            .metadata()
253            .get("password_hash")
254            .and_then(|v| v.as_str())
255            .ok_or(AuthError::InvalidCredentials)?;
256
257        self.verify_password(&signin_req.password, stored_hash)?;
258
259        // Check if 2FA is enabled
260        if user.two_factor_enabled() {
261            let pending_token = format!("2fa_{}", uuid::Uuid::new_v4());
262            ctx.database
263                .create_verification(CreateVerification {
264                    identifier: format!("2fa_pending:{}", pending_token),
265                    value: user.id().to_string(),
266                    expires_at: chrono::Utc::now() + chrono::Duration::minutes(5),
267                })
268                .await?;
269            return Ok(AuthResponse::json(
270                200,
271                &serde_json::json!({
272                    "twoFactorRedirect": true,
273                    "token": pending_token,
274                }),
275            )?);
276        }
277
278        // Create session
279        let session_manager =
280            better_auth_core::SessionManager::new(ctx.config.clone(), ctx.database.clone());
281        let session = session_manager.create_session(&user, None, None).await?;
282
283        let response = SignInResponse {
284            redirect: false,
285            token: session.token().to_string(),
286            url: None,
287            user,
288        };
289
290        // Create session cookie
291        let cookie_header = self.create_session_cookie(session.token(), ctx);
292
293        Ok(AuthResponse::json(200, &response)?.with_header("Set-Cookie", cookie_header))
294    }
295
296    fn validate_password<DB: DatabaseAdapter>(
297        &self,
298        password: &str,
299        ctx: &AuthContext<DB>,
300    ) -> AuthResult<()> {
301        if password.len() < ctx.config.password.min_length {
302            return Err(AuthError::bad_request(format!(
303                "Password must be at least {} characters long",
304                ctx.config.password.min_length
305            )));
306        }
307        Ok(())
308    }
309
310    fn hash_password(&self, password: &str) -> AuthResult<String> {
311        let salt = SaltString::generate(&mut OsRng);
312        let argon2 = Argon2::default();
313
314        let password_hash = argon2
315            .hash_password(password.as_bytes(), &salt)
316            .map_err(|e| AuthError::PasswordHash(format!("Failed to hash password: {}", e)))?;
317
318        Ok(password_hash.to_string())
319    }
320
321    fn create_session_cookie<DB: DatabaseAdapter>(
322        &self,
323        token: &str,
324        ctx: &AuthContext<DB>,
325    ) -> String {
326        let session_config = &ctx.config.session;
327        let secure = if session_config.cookie_secure {
328            "; Secure"
329        } else {
330            ""
331        };
332        let http_only = if session_config.cookie_http_only {
333            "; HttpOnly"
334        } else {
335            ""
336        };
337        let same_site = match session_config.cookie_same_site {
338            better_auth_core::config::SameSite::Strict => "; SameSite=Strict",
339            better_auth_core::config::SameSite::Lax => "; SameSite=Lax",
340            better_auth_core::config::SameSite::None => "; SameSite=None",
341        };
342
343        let expires = chrono::Utc::now() + session_config.expires_in;
344        let expires_str = expires.format("%a, %d %b %Y %H:%M:%S GMT");
345
346        format!(
347            "{}={}; Path=/; Expires={}{}{}{}",
348            session_config.cookie_name, token, expires_str, secure, http_only, same_site
349        )
350    }
351
352    fn verify_password(&self, password: &str, hash: &str) -> AuthResult<()> {
353        let parsed_hash = PasswordHash::new(hash)
354            .map_err(|e| AuthError::PasswordHash(format!("Invalid password hash: {}", e)))?;
355
356        let argon2 = Argon2::default();
357        argon2
358            .verify_password(password.as_bytes(), &parsed_hash)
359            .map_err(|_| AuthError::InvalidCredentials)?;
360
361        Ok(())
362    }
363}
364
365impl Default for EmailPasswordConfig {
366    fn default() -> Self {
367        Self {
368            enable_signup: true,
369            require_email_verification: false,
370            password_min_length: 8,
371        }
372    }
373}
374
375#[async_trait]
376impl<DB: DatabaseAdapter> AuthPlugin<DB> for EmailPasswordPlugin {
377    fn name(&self) -> &'static str {
378        "email-password"
379    }
380
381    fn routes(&self) -> Vec<AuthRoute> {
382        let mut routes = vec![
383            AuthRoute::post("/sign-in/email", "sign_in_email"),
384            AuthRoute::post("/sign-in/username", "sign_in_username"),
385        ];
386
387        if self.config.enable_signup {
388            routes.push(AuthRoute::post("/sign-up/email", "sign_up_email"));
389        }
390
391        routes
392    }
393
394    async fn on_request(
395        &self,
396        req: &AuthRequest,
397        ctx: &AuthContext<DB>,
398    ) -> AuthResult<Option<AuthResponse>> {
399        match (req.method(), req.path()) {
400            (HttpMethod::Post, "/sign-up/email") if self.config.enable_signup => {
401                Ok(Some(self.handle_sign_up(req, ctx).await?))
402            }
403            (HttpMethod::Post, "/sign-in/email") => Ok(Some(self.handle_sign_in(req, ctx).await?)),
404            (HttpMethod::Post, "/sign-in/username") => {
405                Ok(Some(self.handle_sign_in_username(req, ctx).await?))
406            }
407            _ => Ok(None),
408        }
409    }
410
411    async fn on_user_created(&self, user: &DB::User, _ctx: &AuthContext<DB>) -> AuthResult<()> {
412        if self.config.require_email_verification
413            && !user.email_verified()
414            && let Some(email) = user.email()
415        {
416            println!("Email verification required for user: {}", email);
417        }
418        Ok(())
419    }
420}