feat: auth functionality

This commit is contained in:
Maulana Sodiqin
2025-03-19 23:17:50 +07:00
parent 11d6249007
commit eb6cfd51ae
11 changed files with 384 additions and 35 deletions
Generated
+16
View File
@@ -1765,12 +1765,14 @@ dependencies = [
"jsonwebtoken", "jsonwebtoken",
"lettre", "lettre",
"log", "log",
"rand 0.9.0",
"redis", "redis",
"serde", "serde",
"serde_json", "serde_json",
"surrealdb", "surrealdb",
"thiserror 2.0.12", "thiserror 2.0.12",
"tokio", "tokio",
"tower-http",
"utoipa", "utoipa",
"utoipa-swagger-ui", "utoipa-swagger-ui",
] ]
@@ -4143,6 +4145,20 @@ dependencies = [
"tracing", "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]] [[package]]
name = "tower-layer" name = "tower-layer"
version = "0.3.3" version = "0.3.3"
+2
View File
@@ -19,6 +19,8 @@ lettre = { version = "0.11.12", features = ["tokio1-native-tls"] }
surrealdb = { version = "2.2.1", features = ["protocol-http"] } surrealdb = { version = "2.2.1", features = ["protocol-http"] }
thiserror = "2.0.11" thiserror = "2.0.11"
anyhow = "1.0.97" anyhow = "1.0.97"
rand = "0.9.0"
tower-http = { version = "0.6.2", features = ["cors"] }
[profile.release] [profile.release]
lto = "fat" lto = "fat"
+31 -2
View File
@@ -1,5 +1,9 @@
use crate::{AppState, RedisClient, SurrealClient}; use crate::{AppState, Env, RedisClient, SurrealClient};
use axum::{Extension, Router}; use axum::{
http::{header, HeaderValue, Method},
Extension, Router,
};
use tower_http::cors::CorsLayer;
use utoipa_swagger_ui::SwaggerUi; use utoipa_swagger_ui::SwaggerUi;
pub mod v1; pub mod v1;
@@ -7,9 +11,34 @@ pub mod v2;
pub async fn apps(surrealdb: SurrealClient, redisdb: RedisClient) -> Router { pub async fn apps(surrealdb: SurrealClient, redisdb: RedisClient) -> Router {
let state = AppState { surrealdb, redisdb }; 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<HeaderValue> = cors_origins
.into_iter()
.filter_map(|origin| origin.parse::<HeaderValue>().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() Router::new()
.nest("/v1", v1::routes().await) .nest("/v1", v1::routes().await)
.nest("/v2", v2::routes().await) .nest("/v2", v2::routes().await)
.merge(SwaggerUi::new("/docs").url("/openapi.json", v1::docs_router())) .merge(SwaggerUi::new("/docs").url("/openapi.json", v1::docs_router()))
.layer(cors_middleware)
.layer(Extension(state)) .layer(Extension(state))
} }
+59 -6
View File
@@ -1,8 +1,10 @@
use super::{AuthLoginRequestDto, AuthRegisterRequestDto, AuthService}; use super::{
AuthLoginRequestDto, AuthRegisterRequestDto, AuthResendOtpRequestDto, AuthService,
AuthVerifyEmailRequestDto,
};
use crate::{v1::AuthLoginResponsetDto, AppState}; use crate::{v1::AuthLoginResponsetDto, AppState};
use axum::{response::IntoResponse, Extension, Json};
use crate::{MessageResponseDto, ResponseSuccessDto}; use crate::{MessageResponseDto, ResponseSuccessDto};
use axum::{response::IntoResponse, Extension, Json};
#[utoipa::path( #[utoipa::path(
post, post,
@@ -10,7 +12,7 @@ use crate::{MessageResponseDto, ResponseSuccessDto};
request_body = AuthLoginRequestDto, request_body = AuthLoginRequestDto,
responses( responses(
(status = 200, description = "Login successful", body = ResponseSuccessDto<AuthLoginResponsetDto>), (status = 200, description = "Login successful", body = ResponseSuccessDto<AuthLoginResponsetDto>),
(status = 401, description = "Unauthorized", body = MessageResponseDto) (status = 401, description = "Login failed", body = MessageResponseDto)
), ),
tag = "Authentication" tag = "Authentication"
)] )]
@@ -26,8 +28,8 @@ pub async fn post_login(
path = "/v1/auth/register", path = "/v1/auth/register",
request_body = AuthRegisterRequestDto, request_body = AuthRegisterRequestDto,
responses( responses(
(status = 200, description = "Login successful", body = MessageResponseDto), (status = 200, description = "Register successful", body = MessageResponseDto),
(status = 401, description = "Unauthorized", body = MessageResponseDto) (status = 401, description = "Register failed", body = MessageResponseDto)
), ),
tag = "Authentication" tag = "Authentication"
)] )]
@@ -37,3 +39,54 @@ pub async fn post_register(
) -> impl IntoResponse { ) -> impl IntoResponse {
AuthService::mutation_register(payload, &state).await 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<AppState>,
Json(payload): Json<AuthVerifyEmailRequestDto>,
) -> 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<AppState>,
Json(payload): Json<AuthResendOtpRequestDto>,
) -> 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<AppState>,
Json(payload): Json<AuthResendOtpRequestDto>,
) -> impl IntoResponse {
AuthService::mutation_forgot_password(payload, &state).await
}
+23
View File
@@ -34,6 +34,29 @@ pub struct AuthActiveInactiveRequestDto {
pub email: String, 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)] #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct AuthQueryByEmailResponseDto { pub struct AuthQueryByEmailResponseDto {
pub email: String, pub email: String,
+79 -6
View File
@@ -1,9 +1,11 @@
use super::{AuthActiveInactiveRequestDto, AuthRegisterRequestDto}; use super::{
AuthActiveInactiveRequestDto, AuthRegisterRequestDto, AuthSetNewPasswordRequestDto,
};
use crate::{ use crate::{
v1::{users_schema::UsersSchema, UsersItemDto}, v1::{users_schema::UsersSchema, UsersItemDto},
AppState, RedisKeyEnum, ResourceEnum, AppState, RedisKeyEnum, ResourceEnum,
}; };
use anyhow::{bail, Result}; use anyhow::{anyhow, bail, Result};
use redis::Commands; use redis::Commands;
pub struct AuthRepository<'a> { pub struct AuthRepository<'a> {
@@ -45,12 +47,62 @@ impl<'a> AuthRepository<'a> {
} }
} }
pub fn query_get_stored_otp(&self, email: String) -> Result<u32> {
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<String> = 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::<u32>() {
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<String> {
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<String> {
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<UsersSchema> { pub async fn query_user_by_email(&self, email: String) -> Result<UsersSchema> {
let db = &self.state.surrealdb; 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 { match result {
Some(response) => Ok(response), Some(response) => Ok(response),
None => bail!("User not found"), None => {
bail!("User not found")
}
} }
} }
@@ -79,13 +131,34 @@ impl<'a> AuthRepository<'a> {
data: AuthActiveInactiveRequestDto, data: AuthActiveInactiveRequestDto,
) -> Result<String> { ) -> Result<String> {
let db = &self.state.surrealdb; let db = &self.state.surrealdb;
let record: Option<UsersItemDto> = db let record: Option<AuthActiveInactiveRequestDto> = db
.update((ResourceEnum::Users.to_string(), &data.email)) .update((ResourceEnum::Users.to_string(), &data.email))
.content(data) .merge(AuthActiveInactiveRequestDto {
email: data.email.clone(),
is_active: data.is_active.clone(),
})
.await?; .await?;
match record { match record {
Some(_) => Ok("Success update user".into()), Some(_) => Ok("Success update user".into()),
None => bail!("Failed to update user"), None => bail!("Failed to update user"),
} }
} }
pub async fn query_update_password_user(
&self,
data: AuthSetNewPasswordRequestDto,
) -> Result<String> {
let db = &self.state.surrealdb;
let record: Option<AuthSetNewPasswordRequestDto> = 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"),
}
}
} }
+150 -19
View File
@@ -1,12 +1,14 @@
use axum::{http::StatusCode, response::Response}; use axum::{http::StatusCode, response::Response};
use super::{ use super::{
AuthLoginRequestDto, AuthLoginResponsetDto, AuthRegisterRequestDto, AuthActiveInactiveRequestDto, AuthLoginRequestDto, AuthLoginResponsetDto,
AuthRepository, TokenDto, AuthRegisterRequestDto, AuthRepository, AuthResendOtpRequestDto,
AuthVerifyEmailRequestDto, TokenDto,
}; };
use crate::{ use crate::{
common_response, encode_access_token, encode_refresh_token, hash_password, common_response, encode_access_token, encode_refresh_token, generate_otp,
success_response, v1::UsersItemDto, verify_password, AppState, ResponseSuccessDto, hash_password, send_email, success_response, v1::UsersItemDto, verify_password,
AppState, Env, ResponseSuccessDto,
}; };
pub struct AuthService; pub struct AuthService;
@@ -17,40 +19,65 @@ impl AuthService {
state: &AppState, state: &AppState,
) -> Response { ) -> Response {
let repository = AuthRepository::new(state); let repository = AuthRepository::new(state);
match repository.query_user_by_email(payload.email.clone()).await { match repository.query_user_by_email(payload.email.clone()).await {
Ok(user) => { Ok(user) => {
let is_password_correct = let is_password_correct =
verify_password(&payload.password, &user.password).unwrap_or(false); verify_password(&payload.password, &user.password).unwrap_or(false);
if is_password_correct { if !is_password_correct {
common_response(StatusCode::BAD_REQUEST, "Email or password not correct"); return common_response(
StatusCode::BAD_REQUEST,
"Email or password not correct",
);
} }
let access_token = encode_access_token(payload.email.clone()); if !user.is_active {
let refresh_token = encode_refresh_token(payload.email.clone()); 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 { let response = ResponseSuccessDto {
data: AuthLoginResponsetDto { data: AuthLoginResponsetDto {
user: UsersItemDto { user: UsersItemDto {
fullname: user.fullname.clone(), fullname: user.fullname.clone(),
email: user.email.clone(), email: user.email.clone(),
is_active: user.is_active.clone(), is_active: user.is_active,
}, },
token: TokenDto { token: TokenDto {
access_token: access_token.unwrap(), access_token,
refresh_token: refresh_token.unwrap(), refresh_token,
}, },
}, },
}; };
if !repository if let Err(_) = repository.query_store_user_data(AuthRegisterRequestDto {
.query_store_user_data(AuthRegisterRequestDto { fullname: user.fullname,
fullname: user.fullname, password: user.password,
password: user.password, email: user.email,
email: user.email, }) {
})
.is_ok()
{
return common_response(StatusCode::BAD_REQUEST, "Failed to store data"); return common_response(StatusCode::BAD_REQUEST, "Failed to store data");
} }
@@ -89,6 +116,16 @@ impl AuthService {
fullname: payload.fullname, 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 { match repository.query_create_user(new_user).await {
Ok(_) => common_response(StatusCode::CREATED, "Registration successful"), Ok(_) => common_response(StatusCode::CREATED, "Registration successful"),
Err(err) => { 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()),
}
}
} }
+3
View File
@@ -14,4 +14,7 @@ pub fn auth_router() -> Router {
Router::new() Router::new()
.route("/login", post(auth_controller::post_login)) .route("/login", post(auth_controller::post_login))
.route("/register", post(auth_controller::post_register)) .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))
} }
+6 -2
View File
@@ -1,8 +1,8 @@
use crate::{ use crate::{
v1::{ v1::{
auth, gacha, AuthLoginRequestDto, AuthLoginResponsetDto, auth, gacha, AuthLoginRequestDto, AuthLoginResponsetDto,
GachaCreateClaimRequestDto, GachaCreateItemRequestDto, AuthResendOtpRequestDto, AuthVerifyEmailRequestDto, GachaCreateClaimRequestDto,
GachaCreateRollRequestDto, GachaCreateItemRequestDto, GachaCreateRollRequestDto,
}, },
MessageResponseDto, MetaRequestDto, MetaResponseDto, ResponseSuccessDto, MessageResponseDto, MetaRequestDto, MetaResponseDto, ResponseSuccessDto,
}; };
@@ -17,6 +17,8 @@ use utoipa::{
paths( paths(
auth::auth_controller::post_login, auth::auth_controller::post_login,
auth::auth_controller::post_register, 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_claim,
gacha::gacha_controller::post_create_gacha_item, gacha::gacha_controller::post_create_gacha_item,
gacha::gacha_controller::post_create_gacha_roll gacha::gacha_controller::post_create_gacha_roll
@@ -28,6 +30,8 @@ use utoipa::{
MessageResponseDto, MessageResponseDto,
AuthLoginRequestDto, AuthLoginRequestDto,
AuthLoginResponsetDto, AuthLoginResponsetDto,
AuthVerifyEmailRequestDto,
AuthResendOtpRequestDto,
ResponseSuccessDto<AuthLoginResponsetDto>, ResponseSuccessDto<AuthLoginResponsetDto>,
GachaCreateClaimRequestDto, GachaCreateClaimRequestDto,
GachaCreateItemRequestDto, GachaCreateItemRequestDto,
+13
View File
@@ -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
}
}
+2
View File
@@ -1,5 +1,7 @@
pub mod extract_email; pub mod extract_email;
pub mod generate_otp;
pub mod response_format; pub mod response_format;
pub use extract_email::*; pub use extract_email::*;
pub use generate_otp::*;
pub use response_format::*; pub use response_format::*;