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
13pub 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 self.validate_password(&signup_req.password, ctx)?;
122
123 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 let password_hash = self.hash_password(&signup_req.password)?;
135
136 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 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 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 let user = ctx
182 .database
183 .get_user_by_email(&signin_req.email)
184 .await?
185 .ok_or(AuthError::InvalidCredentials)?;
186
187 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 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 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 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 let user = ctx
245 .database
246 .get_user_by_username(&signin_req.username)
247 .await?
248 .ok_or(AuthError::InvalidCredentials)?;
249
250 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 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 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 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}