feat: Add axum-extra dependency and implement typed headers

feat: Define unique index on users table for email and fix minor syntax error

refactor: Update permissions_guard to use claims from JWT and improve user retrieval

refactor: Modify user-related service methods to accept user details directly

fix: Update token generation functions to include user ID and permissions

test: Update Google OAuth flow tests to reflect changes in token generation
This commit is contained in:
MythEclipse
2025-08-14 22:46:42 +07:00
parent 89ef48a57e
commit 199d0c885e
17 changed files with 305 additions and 234 deletions
+1
View File
@@ -31,6 +31,7 @@ log.workspace = true
once_cell.workspace = true
tracing.workspace = true
uuid.workspace = true
axum-extra.workspace = true
[dev-dependencies]
dotenvy.workspace = true
+50 -24
View File
@@ -9,7 +9,7 @@ use crate::{
decode_refresh_token, encode_access_token, encode_refresh_token,
encode_reset_password_token, extract_email_token, generate_otp, get_iso_date,
hash_password, make_thing, send_email, success_response, validate_request,
verify_password,
verify_password, surrealdb_init_ws, surrealdb_init_mem,
};
use axum::{http::StatusCode, response::Response};
use surrealdb::Uuid;
@@ -87,7 +87,8 @@ impl AuthServiceTrait for AuthService {
);
}
let access_token = match encode_access_token(payload.email.clone()) {
let permissions: Vec<String> = user.role.permissions.iter().map(|p| p.name.clone()).collect();
let access_token = match encode_access_token(payload.email.clone(), user.id.id.to_raw(), permissions) {
Ok(token) => token,
Err(_e) => {
error!(
@@ -101,7 +102,8 @@ impl AuthServiceTrait for AuthService {
}
};
let refresh_token = match encode_refresh_token(payload.email.clone()) {
let permissions: Vec<String> = user.role.permissions.iter().map(|p| p.name.clone()).collect();
let refresh_token = match encode_refresh_token(payload.email.clone(), user.id.id.to_raw(), permissions) {
Ok(token) => token,
Err(_e) => {
error!(
@@ -182,7 +184,8 @@ impl AuthServiceTrait for AuthService {
);
}
let access_token = match encode_access_token(payload.email.clone()) {
let permissions: Vec<String> = user.role.permissions.iter().map(|p| p.name.clone()).collect();
let access_token = match encode_access_token(payload.email.clone(), user.id.id.to_raw(), permissions) {
Ok(token) => token,
Err(_e) => {
error!(
@@ -196,7 +199,8 @@ impl AuthServiceTrait for AuthService {
}
};
let refresh_token = match encode_refresh_token(payload.email.clone()) {
let permissions: Vec<String> = user.role.permissions.iter().map(|p| p.name.clone()).collect();
let refresh_token = match encode_refresh_token(payload.email.clone(), user.id.id.to_raw(), permissions) {
Ok(token) => token,
Err(_e) => {
error!(
@@ -374,32 +378,54 @@ impl AuthServiceTrait for AuthService {
}
}
async fn mutation_refresh_token(
payload: AuthRefreshTokenRequestDto,
) -> Response {
async fn mutation_refresh_token(payload: AuthRefreshTokenRequestDto) -> Response {
if let Err((status, message)) = validate_request(&payload) {
return common_response(status, &message);
}
let email = match decode_refresh_token(&payload.refresh_token) {
Ok(token) => token.claims.sub,
let surrealdb_ws = match surrealdb_init_ws().await {
Ok(db) => db,
Err(e) => {
error!("Failed to initialize websocket database: {}", e);
return common_response(StatusCode::INTERNAL_SERVER_ERROR, "Database initialization error");
}
};
let surrealdb_mem = match surrealdb_init_mem().await {
Ok(db) => db,
Err(e) => {
error!("Failed to initialize memory database: {}", e);
return common_response(StatusCode::INTERNAL_SERVER_ERROR, "Database initialization error");
}
};
let state = AppState { surrealdb_ws, surrealdb_mem };
let user_repo = UsersRepository::new(&state);
let user = match decode_refresh_token(&payload.refresh_token) {
Ok(token_data) => {
match user_repo.query_user_by_email(token_data.claims.sub.clone()).await {
Ok(user) => user,
Err(_) => return common_response(StatusCode::UNAUTHORIZED, "User not found"),
}
},
Err(_e) => {
return common_response(StatusCode::UNAUTHORIZED, "Invalid refresh token");
}
};
let access_token = match encode_access_token(email.clone()) {
let permissions: Vec<String> = user.role.permissions.iter().map(|p| p.name.clone()).collect();
let access_token = match encode_access_token(user.email.clone(), user.id.id.to_raw(), permissions.clone()) {
Ok(token) => token,
Err(_e) => {
error!("Failed to generate access token for {}: {}", email, _e);
error!("Failed to generate access token for {}: {}", user.email, _e);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to generate access token",
);
}
};
let refresh_token = match encode_refresh_token(email.clone()) {
let refresh_token = match encode_refresh_token(user.email.clone(), user.id.id.to_raw(), permissions) {
Ok(token) => token,
Err(_e) => {
error!("Failed to generate refresh token for {}: {}", email, _e);
error!("Failed to generate refresh token for {}: {}", user.email, _e);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to generate refresh token",
@@ -440,7 +466,8 @@ impl AuthServiceTrait for AuthService {
);
}
};
let token = match encode_reset_password_token(user.email.clone()) {
let permissions: Vec<String> = user.role.permissions.iter().map(|p| p.name.clone()).collect();
let token = match encode_reset_password_token(user.email.clone(), user.id.id.to_raw(), permissions) {
Ok(token) => token,
Err(_e) => {
error!(
@@ -525,32 +552,31 @@ impl AuthServiceTrait for AuthService {
return common_response(status, &message);
}
let repo = UsersRepository::new(state);
let user_repo = UsersRepository::new(state);
let email = match extract_email_token(payload.token.clone()) {
Some(token) => token,
Some(email) => email,
None => {
return common_response(StatusCode::BAD_REQUEST, "Invalid or missing token");
}
};
let user = match user_repo.query_user_by_email(email).await {
Ok(user) => user,
Err(_) => return common_response(StatusCode::BAD_REQUEST, "User not found"),
};
let password = match hash_password(&payload.password) {
Ok(p) => p,
Err(_e) => {
error!("Failed to hash new password for {}: {}", email, _e);
error!("Failed to hash new password for {}: {}", user.email, _e);
return common_response(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to hash password",
);
}
};
let user = match repo.query_user_by_email(email.clone()).await {
Ok(user) if !user.is_deleted => user,
_ => {
return common_response(StatusCode::NOT_FOUND, "User not found");
}
};
let patch = UsersSchema {
id: user.id.clone(),
password,
..Default::default()
..UsersSchema::from(user.clone())
};
match repo.query_update_user(patch).await {
Ok(msg) => common_response(StatusCode::OK, &msg),
@@ -310,13 +310,15 @@ where
}
};
let access_token = encode_access_token(user.email.clone())
let permissions: Vec<String> = user.role.permissions.iter().map(|p| p.name.clone()).collect();
let access_token = encode_access_token(user.email.clone(), user.id.clone(), permissions)
.map_err(|e| {
error!("Failed to generate access token for {}: {:?}", user.email, e);
Error::Auth("Failed to generate access token".to_string())
})?;
let refresh_token = encode_refresh_token(user.email.clone())
let permissions: Vec<String> = user.role.permissions.iter().map(|p| p.name.clone()).collect();
let refresh_token = encode_refresh_token(user.email.clone(), user.id.clone(), permissions)
.map_err(|e| {
error!("Failed to generate refresh token for {}: {:?}", user.email, e);
Error::Auth("Failed to generate refresh token".to_string())
@@ -41,13 +41,13 @@ pub async fn get_permission_list(
Query(meta): Query<MetaRequestDto>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
headers,
Extension(state),
vec![PermissionsEnum::ReadListPermissions],
)
.await
{
Ok(_) => PermissionsService::get_permission_list(&state, meta).await,
Ok((_user, state)) => PermissionsService::get_permission_list(&state, meta).await,
Err(response) => response,
}
}
@@ -70,13 +70,13 @@ pub async fn get_permission_by_id(
Path(id): Path<String>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
headers,
Extension(state),
vec![PermissionsEnum::ReadDetailPermissions],
)
.await
{
Ok(_) => PermissionsService::get_permission_by_id(&state, id).await,
Ok((_user, state)) => PermissionsService::get_permission_by_id(&state, id).await,
Err(response) => response,
}
}
@@ -99,13 +99,13 @@ pub async fn post_create_permission(
Json(payload): Json<PermissionsRequestDto>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
headers,
Extension(state),
vec![PermissionsEnum::CreatePermissions],
)
.await
{
Ok(_) => PermissionsService::create_role(&state, payload).await,
Ok((_user, state)) => PermissionsService::create_role(&state, payload).await,
Err(response) => response,
}
}
@@ -129,13 +129,13 @@ pub async fn put_update_permission(
Json(payload): Json<PermissionsUpdateRequestDto>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
headers,
Extension(state),
vec![PermissionsEnum::UpdatePermissions],
)
.await
{
Ok(_) => PermissionsService::update_permission(&state, payload, id).await,
Ok((_user, state)) => PermissionsService::update_permission(&state, payload, id).await,
Err(response) => response,
}
}
@@ -157,13 +157,13 @@ pub async fn delete_permission(
Path(id): Path<String>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
headers,
Extension(state),
vec![PermissionsEnum::DeletePermissions],
)
.await
{
Ok(_) => PermissionsService::delete_permission(&state, id).await,
Ok((_user, state)) => PermissionsService::delete_permission(&state, id).await,
Err(response) => response,
}
}
@@ -1,49 +1,41 @@
use super::PermissionsEnum;
use crate::{AppState, AuthRepository, common_response, extract_email, extract_email_async, UsersDetailQueryDto};
use crate::{AppState, common_response, decode_access_token, UsersDetailQueryDto, UsersRepository};
use axum::{
http::{HeaderMap, StatusCode},
response::Response,
response::Response, Extension,
};
use axum_extra::headers::{authorization::Bearer, Authorization, HeaderMapExt};
use imphnen_utils::make_thing;
pub async fn permissions_guard(
headers: &HeaderMap,
state: AppState,
headers: HeaderMap,
Extension(state): Extension<AppState>,
required_permissions: Vec<PermissionsEnum>,
) -> Result<UsersDetailQueryDto, Response> {
let auth_repo = AuthRepository::new(&state);
// Try synchronous email extraction first (for internal JWT tokens)
let email = match extract_email(headers) {
Some(email) => email,
None => {
// If sync extraction fails, try async (for Google tokens)
match extract_email_async(headers).await {
Some(email) => email,
None => {
return Err(common_response(
StatusCode::UNAUTHORIZED,
"Invalid or missing authorization token",
));
}
}
}
};
let raw_user = auth_repo
.query_get_stored_user(email.clone())
.await
) -> Result<(UsersDetailQueryDto, AppState), Response> {
let auth_header = headers
.typed_get::<Authorization<Bearer>>()
.ok_or_else(|| {
common_response(
StatusCode::UNAUTHORIZED,
"Invalid or missing authorization token",
)
})?;
let token = auth_header.token();
let claims = decode_access_token(token)
.map_err(|_| {
common_response(
StatusCode::UNAUTHORIZED,
"User session expired or not found",
"Invalid or expired token",
)
})?;
let role_permissions: Vec<String> =
raw_user.role.permissions.iter().map(|perm| perm.name.clone()).collect();
})?
.claims;
// Use permissions from JWT for the check
for required in &required_permissions {
let required_str = required.to_string();
if !role_permissions.contains(&required_str) {
if !claims.permissions.contains(&required_str) {
eprintln!(" MISSING REQUIRED PERMISSION: {required_str}");
return Err(common_response(
StatusCode::FORBIDDEN,
@@ -51,5 +43,18 @@ pub async fn permissions_guard(
));
}
}
Ok(raw_user)
// Fetch full user details from the database using user_id from JWT
let user_repo = UsersRepository::new(&state);
let user_id_thing = make_thing("app_users", &claims.user_id);
let raw_user = user_repo.query_user_by_id(&user_id_thing)
.await
.map_err(|_| {
common_response(
StatusCode::INTERNAL_SERVER_ERROR, // Changed to internal server error as user ID should be valid from JWT
"Failed to retrieve user details",
)
})?;
Ok((raw_user, state))
}
+15 -15
View File
@@ -39,13 +39,13 @@ pub async fn get_role_list(
Query(meta): Query<MetaRequestDto>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
headers,
Extension(state),
vec![PermissionsEnum::ReadListRoles],
)
.await
{
Ok(_) => RolesService::get_role_list(&state, meta).await,
Ok((_user, state)) => RolesService::get_role_list(&state, meta).await,
Err(response) => response,
}
}
@@ -68,13 +68,13 @@ pub async fn get_role_by_id(
Path(id): Path<String>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
headers,
Extension(state),
vec![PermissionsEnum::ReadDetailRoles],
)
.await
{
Ok(_) => RolesService::get_role_by_id(&state, id).await,
Ok((_user, state)) => RolesService::get_role_by_id(&state, id).await,
Err(response) => response,
}
}
@@ -97,13 +97,13 @@ pub async fn post_create_role(
Json(payload): Json<RolesRequestCreateDto>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
headers,
Extension(state),
vec![PermissionsEnum::CreateRoles],
)
.await
{
Ok(_) => RolesService::create_role(&state, payload).await,
Ok((_user, state)) => RolesService::create_role(&state, payload).await,
Err(response) => response,
}
}
@@ -127,13 +127,13 @@ pub async fn put_update_role(
Json(payload): Json<RolesRequestUpdateDto>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
headers,
Extension(state),
vec![PermissionsEnum::UpdateRoles],
)
.await
{
Ok(_) => RolesService::update_role(&state, id, payload).await,
Ok((_user, state)) => RolesService::update_role(&state, id, payload).await,
Err(response) => response,
}
}
@@ -155,13 +155,13 @@ pub async fn delete_role(
Path(id): Path<String>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
headers,
Extension(state),
vec![PermissionsEnum::DeleteRoles],
)
.await
{
Ok(_) => RolesService::delete_role(&state, id).await,
Ok((_user, state)) => RolesService::delete_role(&state, id).await,
Err(response) => response,
}
}
+48 -48
View File
@@ -49,30 +49,30 @@ pub async fn get_user_list(
axum::extract::Query(meta): axum::extract::Query<MetaRequestDto>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
headers,
Extension(state),
vec![PermissionsEnum::ReadListUsers],
)
.await
{
Ok(_) => UsersService::get_user_list(&state, meta).await,
Ok((_user, state)) => UsersService::get_user_list(&state, meta).await,
Err(response) => response,
}
}
#[utoipa::path(
get,
security(
("Bearer" = [])
),
path = "/v1/users/detail/{id}",
params(
("id" = String, Path, description = "User ID")
),
responses(
(status = 200, description = "Get user by ID", body = ResponseSuccessDto<UsersDetailItemDto>)
),
tag = "Users"
get,
security(
("Bearer" = [])
),
path = "/v1/users/detail/{id}",
params(
("id" = String, Path, description = "User ID")
),
responses(
(status = 200, description = "Get user by ID", body = ResponseSuccessDto<UsersDetailItemDto>)
),
tag = "Users"
)]
pub async fn get_user_by_id(
headers: HeaderMap,
@@ -80,34 +80,34 @@ pub async fn get_user_by_id(
Path(id): Path<String>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
headers,
Extension(state),
vec![PermissionsEnum::ReadDetailUsers],
)
.await
{
Ok(_) => UsersService::get_user_by_id(&state, id).await,
Ok((_user, state)) => UsersService::get_user_by_id(&state, id).await,
Err(response) => response,
}
}
#[utoipa::path(
get,
security(
("Bearer" = [])
),
path = "/v1/users/me",
responses(
(status = 200, description = "Get user by ID", body = ResponseSuccessDto<UsersDetailItemDto>)
),
tag = "Users"
get,
security(
("Bearer" = [])
),
path = "/v1/users/me",
responses(
(status = 200, description = "Get user by ID", body = ResponseSuccessDto<UsersDetailItemDto>)
),
tag = "Users"
)]
pub async fn get_user_me(
Extension(state): Extension<AppState>,
headers: HeaderMap,
Extension(state): Extension<AppState>,
) -> impl IntoResponse {
match permissions_guard(&headers, state.clone(), vec![]).await {
Ok(_) => UsersService::get_user_me(headers, &state).await,
match permissions_guard(headers, Extension(state), vec![]).await {
Ok((user, state)) => UsersService::get_user_me(user, &state).await,
Err(response) => response,
}
}
@@ -130,13 +130,13 @@ pub async fn post_create_user(
Json(payload): Json<UsersCreateRequestDto>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
headers,
Extension(state),
vec![PermissionsEnum::CreateUsers],
)
.await
{
Ok(_) => UsersService::create_user(&state, payload).await,
Ok((_user, state)) => UsersService::create_user(&state, payload).await,
Err(response) => response,
}
}
@@ -163,13 +163,13 @@ pub async fn put_update_user(
Json(payload): Json<UsersUpdateRequestDto>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
headers,
Extension(state),
vec![PermissionsEnum::UpdateUsers],
)
.await
{
Ok(_) => UsersService::update_user(&state, id, payload).await,
Ok((_user, state)) => UsersService::update_user(&state, id, payload).await,
Err(response) => response,
}
}
@@ -191,8 +191,8 @@ pub async fn put_update_user_me(
Extension(state): Extension<AppState>,
Json(payload): Json<UsersUpdateRequestDto>,
) -> impl IntoResponse {
match permissions_guard(&headers, state.clone(), vec![]).await {
Ok(_) => UsersService::update_user_me(headers, &state, payload).await,
match permissions_guard(headers.clone(), Extension(state), vec![]).await {
Ok((_user, state)) => UsersService::update_user_me(headers, &state, payload).await,
Err(response) => response,
}
}
@@ -219,13 +219,13 @@ pub async fn patch_user_active_status(
Json(payload): Json<UsersActiveInactiveRequestDto>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
headers,
Extension(state),
vec![PermissionsEnum::ActivateUsers],
)
.await
{
Ok(_) => UsersService::set_user_active_status(&state, id, payload).await,
Ok((_user, state)) => UsersService::set_user_active_status(&state, id, payload).await,
Err(response) => response,
}
}
@@ -247,13 +247,13 @@ pub async fn delete_user(
Path(id): Path<String>,
) -> impl IntoResponse {
match permissions_guard(
&headers,
state.clone(),
headers,
Extension(state),
vec![PermissionsEnum::DeleteUsers],
)
.await
{
Ok(_) => UsersService::delete_user(&state, id).await,
Ok((_user, state)) => UsersService::delete_user(&state, id).await,
Err(response) => response,
}
}
@@ -284,15 +284,15 @@ pub async fn upload_file(
) -> impl IntoResponse {
// Check authentication first
match permissions_guard(
&headers,
state.clone(),
headers,
Extension(state),
vec![], // No specific permission needed, just authentication
)
.await
{
Ok(user) => {
Ok((user, state)) => {
// Extract user ID from user data
let user_id = user.id.to_string();
let user_id = user.id.id.to_string(); // Use user.id.id to get the actual ID
// Process upload - don't use match here since it returns Response directly
UsersService::upload_file(&state, user_id, multipart).await
+7 -24
View File
@@ -7,7 +7,7 @@ use crate::{
};
use crate::{
ResponseSuccessDto, common_response, extract_email, extract_email_async, success_list_response,
success_response, validate_request,
success_response, validate_request, UsersDetailQueryDto,
};
use axum::http::HeaderMap;
use axum::{http::StatusCode, response::Response, extract::Multipart};
@@ -24,7 +24,7 @@ use serde_json::json;
pub trait UsersServiceTrait: Send + Sync + 'static {
async fn get_user_list(state: &AppState, meta: MetaRequestDto) -> Response;
async fn get_user_by_id(state: &AppState, id: String) -> Response;
async fn get_user_me(headers: HeaderMap, state: &AppState) -> Response;
async fn get_user_me(user: UsersDetailQueryDto, state: &AppState) -> Response;
async fn create_user(state: &AppState, new_user: UsersCreateRequestDto) -> Response;
async fn update_user(state: &AppState, id: String, user: UsersUpdateRequestDto) -> Response;
async fn update_user_me(headers: HeaderMap, state: &AppState, user: UsersUpdateRequestDto) -> Response;
@@ -76,28 +76,11 @@ impl UsersServiceTrait for UsersService {
}
}
async fn get_user_me(headers: HeaderMap, state: &AppState) -> Response {
let repo = UsersRepository::new(state);
// Try synchronous email extraction first (for internal JWT tokens)
let email = match extract_email(&headers) {
Some(email) => email,
None => {
// If sync extraction fails, try async (for Google tokens)
match extract_email_async(&headers).await {
Some(email) => email,
None => return common_response(StatusCode::UNAUTHORIZED, "Invalid token"),
}
}
};
match repo.query_user_by_email(email).await {
Ok(user) if !user.is_deleted => success_response(ResponseSuccessDto {
data: UserDto::from(&user), // Corrected to use UserDto::from by reference
}),
Ok(_) => common_response(StatusCode::NOT_FOUND, "User not found"),
Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()),
}
async fn get_user_me(user: UsersDetailQueryDto, _state: &AppState) -> Response {
// User data is already provided by the permissions_guard
success_response(ResponseSuccessDto {
data: UserDto::from(&user),
})
}
async fn create_user(