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",
"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"
+2
View File
@@ -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"
+31 -2
View File
@@ -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<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()
.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))
}
+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 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<AuthLoginResponsetDto>),
(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<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,
}
#[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,
+79 -6
View File
@@ -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<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> {
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<String> {
let db = &self.state.surrealdb;
let record: Option<UsersItemDto> = db
let record: Option<AuthActiveInactiveRequestDto> = 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<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 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()),
}
}
}
+3
View File
@@ -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))
}
+6 -2
View File
@@ -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<AuthLoginResponsetDto>,
GachaCreateClaimRequestDto,
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 generate_otp;
pub mod response_format;
pub use extract_email::*;
pub use generate_otp::*;
pub use response_format::*;