diff --git a/Cargo.lock b/Cargo.lock index f52e076..498fdc1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1765,12 +1765,14 @@ dependencies = [ "jsonwebtoken", "lettre", "log", + "rand 0.9.0", "redis", "serde", "serde_json", "surrealdb", "thiserror 2.0.12", "tokio", + "tower-http", "utoipa", "utoipa-swagger-ui", ] @@ -4143,6 +4145,20 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "403fa3b783d4b626a8ad51d766ab03cb6d2dbfc46b1c5d4448395e6628dc9697" +dependencies = [ + "bitflags", + "bytes", + "http", + "pin-project-lite", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.3" diff --git a/Cargo.toml b/Cargo.toml index 760f2bc..7dcce22 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,8 @@ lettre = { version = "0.11.12", features = ["tokio1-native-tls"] } surrealdb = { version = "2.2.1", features = ["protocol-http"] } thiserror = "2.0.11" anyhow = "1.0.97" +rand = "0.9.0" +tower-http = { version = "0.6.2", features = ["cors"] } [profile.release] lto = "fat" diff --git a/src/apps/mod.rs b/src/apps/mod.rs index a93e0db..be39529 100644 --- a/src/apps/mod.rs +++ b/src/apps/mod.rs @@ -1,5 +1,9 @@ -use crate::{AppState, RedisClient, SurrealClient}; -use axum::{Extension, Router}; +use crate::{AppState, Env, RedisClient, SurrealClient}; +use axum::{ + http::{header, HeaderValue, Method}, + Extension, Router, +}; +use tower_http::cors::CorsLayer; use utoipa_swagger_ui::SwaggerUi; pub mod v1; @@ -7,9 +11,34 @@ pub mod v2; pub async fn apps(surrealdb: SurrealClient, redisdb: RedisClient) -> Router { let state = AppState { surrealdb, redisdb }; + let env = Env::new(); + let cors_origins = match env.rust_env.as_str() { + "development" => vec!["http://localhost:5173"], + "production" => { + vec!["https://gacha.imphnen.dev", "https://imphnen.dev"] + } + _ => vec![ + "http://localhost:5173", + "https://gacha.imphnen.dev", + "https://imphnen.dev", + ], + }; + + let allowed_origins: Vec = cors_origins + .into_iter() + .filter_map(|origin| origin.parse::().ok()) + .collect(); + + let cors_middleware = CorsLayer::new() + .allow_origin(allowed_origins) + .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE]) + .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE]) + .allow_credentials(true); + Router::new() .nest("/v1", v1::routes().await) .nest("/v2", v2::routes().await) .merge(SwaggerUi::new("/docs").url("/openapi.json", v1::docs_router())) + .layer(cors_middleware) .layer(Extension(state)) } diff --git a/src/apps/v1/auth/auth_controller.rs b/src/apps/v1/auth/auth_controller.rs index 86cdae3..cc1308f 100644 --- a/src/apps/v1/auth/auth_controller.rs +++ b/src/apps/v1/auth/auth_controller.rs @@ -1,8 +1,10 @@ -use super::{AuthLoginRequestDto, AuthRegisterRequestDto, AuthService}; +use super::{ + AuthLoginRequestDto, AuthRegisterRequestDto, AuthResendOtpRequestDto, AuthService, + AuthVerifyEmailRequestDto, +}; use crate::{v1::AuthLoginResponsetDto, AppState}; -use axum::{response::IntoResponse, Extension, Json}; - use crate::{MessageResponseDto, ResponseSuccessDto}; +use axum::{response::IntoResponse, Extension, Json}; #[utoipa::path( post, @@ -10,7 +12,7 @@ use crate::{MessageResponseDto, ResponseSuccessDto}; request_body = AuthLoginRequestDto, responses( (status = 200, description = "Login successful", body = ResponseSuccessDto), - (status = 401, description = "Unauthorized", body = MessageResponseDto) + (status = 401, description = "Login failed", body = MessageResponseDto) ), tag = "Authentication" )] @@ -26,8 +28,8 @@ pub async fn post_login( path = "/v1/auth/register", request_body = AuthRegisterRequestDto, responses( - (status = 200, description = "Login successful", body = MessageResponseDto), - (status = 401, description = "Unauthorized", body = MessageResponseDto) + (status = 200, description = "Register successful", body = MessageResponseDto), + (status = 401, description = "Register failed", body = MessageResponseDto) ), tag = "Authentication" )] @@ -37,3 +39,54 @@ pub async fn post_register( ) -> impl IntoResponse { AuthService::mutation_register(payload, &state).await } + +#[utoipa::path( + post, + path = "/v1/auth/verify", + request_body = AuthVerifyEmailRequestDto, + responses( + (status = 200, description = "Verify email successful", body = MessageResponseDto), + (status = 401, description = "Verify email failed", body = MessageResponseDto) + ), + tag = "Authentication" +)] +pub async fn post_verify_email( + Extension(state): Extension, + Json(payload): Json, +) -> impl IntoResponse { + AuthService::mutation_verify_email(payload, &state).await +} + +#[utoipa::path( + post, + path = "/v1/auth/resend", + request_body = AuthResendOtpRequestDto, + responses( + (status = 200, description = "Resend otp successful", body = MessageResponseDto), + (status = 401, description = "Resend otp failed", body = MessageResponseDto) + ), + tag = "Authentication" +)] +pub async fn post_resend_otp( + Extension(state): Extension, + Json(payload): Json, +) -> impl IntoResponse { + AuthService::mutation_resend_otp(payload, &state).await +} + +#[utoipa::path( + post, + path = "/v1/auth/forgot", + request_body = AuthResendOtpRequestDto, + responses( + (status = 200, description = "Forgot password request successful", body = MessageResponseDto), + (status = 401, description = "Forgot password request failed", body = MessageResponseDto) + ), + tag = "Authentication" +)] +pub async fn post_forgot_password( + Extension(state): Extension, + Json(payload): Json, +) -> impl IntoResponse { + AuthService::mutation_forgot_password(payload, &state).await +} diff --git a/src/apps/v1/auth/auth_dto.rs b/src/apps/v1/auth/auth_dto.rs index 69c4216..f2e8363 100644 --- a/src/apps/v1/auth/auth_dto.rs +++ b/src/apps/v1/auth/auth_dto.rs @@ -34,6 +34,29 @@ pub struct AuthActiveInactiveRequestDto { pub email: String, } +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct AuthVerifyEmailRequestDto { + pub email: String, + pub otp: u32, +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct AuthResendOtpRequestDto { + pub email: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct AuthNewPasswordRequestDto { + pub token: String, + pub password: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct AuthSetNewPasswordRequestDto { + pub email: String, + pub password: String, +} + #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct AuthQueryByEmailResponseDto { pub email: String, diff --git a/src/apps/v1/auth/auth_repository.rs b/src/apps/v1/auth/auth_repository.rs index 7fa3d8c..152e5e7 100644 --- a/src/apps/v1/auth/auth_repository.rs +++ b/src/apps/v1/auth/auth_repository.rs @@ -1,9 +1,11 @@ -use super::{AuthActiveInactiveRequestDto, AuthRegisterRequestDto}; +use super::{ + AuthActiveInactiveRequestDto, AuthRegisterRequestDto, AuthSetNewPasswordRequestDto, +}; use crate::{ v1::{users_schema::UsersSchema, UsersItemDto}, AppState, RedisKeyEnum, ResourceEnum, }; -use anyhow::{bail, Result}; +use anyhow::{anyhow, bail, Result}; use redis::Commands; pub struct AuthRepository<'a> { @@ -45,12 +47,62 @@ impl<'a> AuthRepository<'a> { } } + pub fn query_get_stored_otp(&self, email: String) -> Result { + let redis_key = format!("{}:{}", RedisKeyEnum::Otp, email); + let mut conn = match self.state.redisdb.get_connection() { + Ok(conn) => conn, + Err(e) => { + return Err(anyhow::anyhow!("Failed to get Redis connection: {}", e)) + } + }; + let data: Option = match conn.get(&redis_key) { + Ok(data) => data, + Err(e) => return Err(anyhow::anyhow!("Failed to get data from Redis: {}", e)), + }; + match data { + Some(otp_str) => match otp_str.parse::() { + Ok(otp) => Ok(otp), + Err(e) => Err(anyhow::anyhow!("Failed to parse OTP as u64: {}", e)), + }, + None => Err(anyhow::anyhow!("No stored OTP found")), + } + } + + pub fn query_store_otp(&self, email: String, otp: u32) -> Result { + let redis_key: String = format!("{}:{}", RedisKeyEnum::Otp, email); + let mut conn = match self.state.redisdb.get_connection() { + Ok(conn) => conn, + Err(e) => return Err(anyhow!("Failed to get Redis connection: {}", e)), + }; + let otp_str: String = otp.to_string(); + match conn.set_ex::<_, _, ()>(&redis_key, &otp_str, 300) { + Ok(_) => Ok("Success store otp".to_string()), + Err(e) => Err(anyhow!("Failed to store OTP in Redis: {}", e)), + } + } + + pub fn query_delete_stored_otp(&self, email: String) -> Result { + let redis_key = format!("{}:{}", RedisKeyEnum::Otp, email); + let mut conn = match self.state.redisdb.get_connection() { + Ok(conn) => conn, + Err(e) => return Err(anyhow!("Failed to get Redis connection: {}", e)), + }; + match conn.del::<_, ()>(&redis_key) { + Ok(_) => Ok("Successfully deleted OTP".to_string()), + Err(e) => Err(anyhow!("Failed to delete OTP from Redis: {}", e)), + } + } + pub async fn query_user_by_email(&self, email: String) -> Result { let db = &self.state.surrealdb; - let result = db.select((ResourceEnum::Users.to_string(), email)).await?; + let result = db + .select((ResourceEnum::Users.to_string(), email.clone())) + .await?; match result { Some(response) => Ok(response), - None => bail!("User not found"), + None => { + bail!("User not found") + } } } @@ -79,13 +131,34 @@ impl<'a> AuthRepository<'a> { data: AuthActiveInactiveRequestDto, ) -> Result { let db = &self.state.surrealdb; - let record: Option = db + let record: Option = db .update((ResourceEnum::Users.to_string(), &data.email)) - .content(data) + .merge(AuthActiveInactiveRequestDto { + email: data.email.clone(), + is_active: data.is_active.clone(), + }) .await?; match record { Some(_) => Ok("Success update user".into()), None => bail!("Failed to update user"), } } + + pub async fn query_update_password_user( + &self, + data: AuthSetNewPasswordRequestDto, + ) -> Result { + let db = &self.state.surrealdb; + let record: Option = db + .update((ResourceEnum::Users.to_string(), &data.email)) + .merge(AuthSetNewPasswordRequestDto { + email: data.email.clone(), + password: data.password.clone(), + }) + .await?; + match record { + Some(_) => Ok("Success update password user".into()), + None => bail!("Failed to update password user"), + } + } } diff --git a/src/apps/v1/auth/auth_service.rs b/src/apps/v1/auth/auth_service.rs index b15913a..c1981a0 100644 --- a/src/apps/v1/auth/auth_service.rs +++ b/src/apps/v1/auth/auth_service.rs @@ -1,12 +1,14 @@ use axum::{http::StatusCode, response::Response}; use super::{ - AuthLoginRequestDto, AuthLoginResponsetDto, AuthRegisterRequestDto, - AuthRepository, TokenDto, + AuthActiveInactiveRequestDto, AuthLoginRequestDto, AuthLoginResponsetDto, + AuthRegisterRequestDto, AuthRepository, AuthResendOtpRequestDto, + AuthVerifyEmailRequestDto, TokenDto, }; use crate::{ - common_response, encode_access_token, encode_refresh_token, hash_password, - success_response, v1::UsersItemDto, verify_password, AppState, ResponseSuccessDto, + common_response, encode_access_token, encode_refresh_token, generate_otp, + hash_password, send_email, success_response, v1::UsersItemDto, verify_password, + AppState, Env, ResponseSuccessDto, }; pub struct AuthService; @@ -17,40 +19,65 @@ impl AuthService { state: &AppState, ) -> Response { let repository = AuthRepository::new(state); + match repository.query_user_by_email(payload.email.clone()).await { Ok(user) => { let is_password_correct = verify_password(&payload.password, &user.password).unwrap_or(false); - if is_password_correct { - common_response(StatusCode::BAD_REQUEST, "Email or password not correct"); + if !is_password_correct { + return common_response( + StatusCode::BAD_REQUEST, + "Email or password not correct", + ); } - let access_token = encode_access_token(payload.email.clone()); - let refresh_token = encode_refresh_token(payload.email.clone()); + if !user.is_active { + return common_response( + StatusCode::BAD_REQUEST, + "Account not active, please verify your email", + ); + } + + let access_token = match encode_access_token(payload.email.clone()) { + Ok(token) => token, + Err(_) => { + return common_response( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to generate access token", + ) + } + }; + + let refresh_token = match encode_refresh_token(payload.email.clone()) { + Ok(token) => token, + Err(_) => { + return common_response( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to generate refresh token", + ) + } + }; let response = ResponseSuccessDto { data: AuthLoginResponsetDto { user: UsersItemDto { fullname: user.fullname.clone(), email: user.email.clone(), - is_active: user.is_active.clone(), + is_active: user.is_active, }, token: TokenDto { - access_token: access_token.unwrap(), - refresh_token: refresh_token.unwrap(), + access_token, + refresh_token, }, }, }; - if !repository - .query_store_user_data(AuthRegisterRequestDto { - fullname: user.fullname, - password: user.password, - email: user.email, - }) - .is_ok() - { + if let Err(_) = repository.query_store_user_data(AuthRegisterRequestDto { + fullname: user.fullname, + password: user.password, + email: user.email, + }) { return common_response(StatusCode::BAD_REQUEST, "Failed to store data"); } @@ -89,6 +116,16 @@ impl AuthService { fullname: payload.fullname, }; + let otp = generate_otp::OtpManager::generate_otp(); + + repository + .query_store_otp(new_user.email.clone(), otp.clone()) + .unwrap(); + + let message = format!("your otp code is {}", otp); + + send_email(&new_user.email.clone(), "OTP Verification", &message).unwrap(); + match repository.query_create_user(new_user).await { Ok(_) => common_response(StatusCode::CREATED, "Registration successful"), Err(err) => { @@ -96,4 +133,98 @@ impl AuthService { } } } + + pub async fn mutation_resend_otp( + payload: AuthResendOtpRequestDto, + state: &AppState, + ) -> Response { + let repository = AuthRepository::new(state); + let otp = generate_otp::OtpManager::generate_otp(); + let message = format!("Your OTP code is {}", otp); + match repository.query_store_otp(payload.email.clone(), otp) { + Ok(_) => match send_email(&payload.email, "OTP Verification", &message) { + Ok(_) => common_response(StatusCode::OK, "OTP resent successfully"), + Err(err) => common_response(StatusCode::BAD_REQUEST, &err.to_string()), + }, + Err(err) => common_response(StatusCode::BAD_REQUEST, &err.to_string()), + } + } + + pub async fn mutation_forgot_password( + payload: AuthResendOtpRequestDto, + state: &AppState, + ) -> Response { + let repository = AuthRepository::new(state); + if repository + .query_user_by_email(payload.email.clone()) + .await + .is_err() + { + return common_response(StatusCode::BAD_REQUEST, "User not found"); + } + let token = match encode_access_token(payload.email.clone()) { + Ok(token) => token, + Err(_) => { + return common_response( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to generate access token", + ) + } + }; + let env = Env::new(); + let fe_url = env.fe_url; + let message = format!( + "You have requested a password reset. Please click the link below to continue: {}/auth/reset-password?token={}", + fe_url, token + ); + + match send_email(&payload.email, "Reset Password Request", &message) { + Ok(_) => common_response(StatusCode::OK, "Reset Password request send"), + Err(err) => common_response(StatusCode::BAD_REQUEST, &err.to_string()), + } + } + + pub async fn mutation_verify_email( + payload: AuthVerifyEmailRequestDto, + state: &AppState, + ) -> Response { + let repository = AuthRepository::new(state); + match repository.query_get_stored_otp(payload.email.clone()) { + Ok(stored_otp) => { + let user_otp = payload.otp; + let is_otp_valid = stored_otp == user_otp; + if is_otp_valid { + match repository + .query_active_inactive_user(AuthActiveInactiveRequestDto { + email: payload.email.clone(), + is_active: true, + }) + .await + { + Ok(_) => { + if let Err(e) = + repository.query_delete_stored_otp(payload.email.clone()) + { + return common_response( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("Failed to delete OTP: {}", e), + ); + } + common_response(StatusCode::OK, "Email verified successfully") + } + Err(err) => common_response(StatusCode::BAD_REQUEST, &err.to_string()), + } + } else { + if let Err(e) = repository.query_delete_stored_otp(payload.email.clone()) { + return common_response( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("Failed to delete OTP: {}", e), + ); + } + common_response(StatusCode::BAD_REQUEST, "Failed to verify OTP") + } + } + Err(err) => common_response(StatusCode::BAD_REQUEST, &err.to_string()), + } + } } diff --git a/src/apps/v1/auth/mod.rs b/src/apps/v1/auth/mod.rs index b189546..c8a538a 100644 --- a/src/apps/v1/auth/mod.rs +++ b/src/apps/v1/auth/mod.rs @@ -14,4 +14,7 @@ pub fn auth_router() -> Router { Router::new() .route("/login", post(auth_controller::post_login)) .route("/register", post(auth_controller::post_register)) + .route("/verify", post(auth_controller::post_verify_email)) + .route("/resend", post(auth_controller::post_resend_otp)) + .route("/forgot", post(auth_controller::post_forgot_password)) } diff --git a/src/apps/v1/docs/docs_controller.rs b/src/apps/v1/docs/docs_controller.rs index 64ed4ec..84998c4 100644 --- a/src/apps/v1/docs/docs_controller.rs +++ b/src/apps/v1/docs/docs_controller.rs @@ -1,8 +1,8 @@ use crate::{ v1::{ auth, gacha, AuthLoginRequestDto, AuthLoginResponsetDto, - GachaCreateClaimRequestDto, GachaCreateItemRequestDto, - GachaCreateRollRequestDto, + AuthResendOtpRequestDto, AuthVerifyEmailRequestDto, GachaCreateClaimRequestDto, + GachaCreateItemRequestDto, GachaCreateRollRequestDto, }, MessageResponseDto, MetaRequestDto, MetaResponseDto, ResponseSuccessDto, }; @@ -17,6 +17,8 @@ use utoipa::{ paths( auth::auth_controller::post_login, auth::auth_controller::post_register, + auth::auth_controller::post_verify_email, + auth::auth_controller::post_resend_otp, gacha::gacha_controller::post_create_gacha_claim, gacha::gacha_controller::post_create_gacha_item, gacha::gacha_controller::post_create_gacha_roll @@ -28,6 +30,8 @@ use utoipa::{ MessageResponseDto, AuthLoginRequestDto, AuthLoginResponsetDto, + AuthVerifyEmailRequestDto, + AuthResendOtpRequestDto, ResponseSuccessDto, GachaCreateClaimRequestDto, GachaCreateItemRequestDto, diff --git a/src/utils/generate_otp.rs b/src/utils/generate_otp.rs new file mode 100644 index 0000000..0769d72 --- /dev/null +++ b/src/utils/generate_otp.rs @@ -0,0 +1,13 @@ +use rand::{rng, Rng}; + +pub struct OtpManager; + +impl OtpManager { + pub fn generate_otp() -> u32 { + rng().random_range(100_000..1_000_000) + } + + pub fn validate_otp(stored_otp: u32, user_otp: u32) -> bool { + stored_otp == user_otp + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index d6c76d6..3aa445a 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,5 +1,7 @@ pub mod extract_email; +pub mod generate_otp; pub mod response_format; pub use extract_email::*; +pub use generate_otp::*; pub use response_format::*;