refactor: centralize auth system across all modules

All modules now use the main IAM JWT (ACCESS_TOKEN_SECRET) for authentication,
removing three separate auth systems (hackathon Supabase, hackathon JWT, QR JWT).

Changes:
- hackathon: replace HackathonJwtService with decode_access_token() from imphnen-libs
  - remove entire src/auth/ (Supabase signup/login/GitHub/forgot-reset)
  - remove common/hackathon_jwt.rs, common/supabase_client.rs
  - remove Supabase from HackathonConfig (JWT, GitHub OAuth, Supabase anon/service keys)
  - replace Supabase Storage with MinioService from imphnen-libs
  - all route jwt params removed; hackathon_router takes MinioService instead
- qr: replace QrJwtService with decode_access_token() from imphnen-libs
  - remove entire src/auth/ (register/login/Google OAuth/refresh)
  - remove common/qr_jwt.rs, src/config.rs
  - qr_auth_middleware now lazy-upserts users into QR DB on first access
  - qr_router(pool) — no config needed
- gateway: create MinioService once and pass to hackathon_router; qr_router simplified

Users now register/login via /v1/auth/* and use the same JWT for all endpoints.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
maulanasdqn
2026-04-02 16:33:02 +07:00
co-authored by Claude Sonnet 4.6
parent 4bba182ea3
commit 2ae43b3bcc
47 changed files with 78 additions and 1139 deletions
+1 -5
View File
@@ -5,19 +5,15 @@ edition = "2024"
[dependencies]
imphnen-utils.workspace = true
imphnen-libs.workspace = true
axum.workspace = true
axum-extra.workspace = true
async-trait.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
jsonwebtoken.workspace = true
bcrypt.workspace = true
chrono.workspace = true
uuid.workspace = true
sqlx.workspace = true
reqwest.workspace = true
oauth2.workspace = true
tracing.workspace = true
utoipa.workspace = true
image.workspace = true
@@ -1,154 +0,0 @@
use std::sync::Arc;
use uuid::Uuid;
use sqlx::PgPool;
use async_trait::async_trait;
use imphnen_utils::errors::AppError;
use crate::common::qr_jwt::QrJwtService;
use crate::config::QrConfig;
use super::super::domain::service::{QrAuthService, AuthTokens, QrUserData};
pub struct QrAuthServiceImpl {
pool: Arc<PgPool>,
jwt: Arc<QrJwtService>,
config: Arc<QrConfig>,
}
impl QrAuthServiceImpl {
pub fn new(pool: Arc<PgPool>, jwt: Arc<QrJwtService>, config: Arc<QrConfig>) -> Self {
Self { pool, jwt, config }
}
async fn find_user_by_id(&self, id: Uuid) -> Result<QrUserData, AppError> {
sqlx::query_as::<_, QrUserData>(
"SELECT id, email, name, role, provider, created_at, updated_at FROM users WHERE id = $1"
)
.bind(id)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?
.ok_or_else(|| AppError::NotFoundError("User not found".to_string()))
}
async fn find_user_by_email(&self, email: &str) -> Result<Option<serde_json::Value>, AppError> {
sqlx::query_scalar::<_, serde_json::Value>(
"SELECT row_to_json(u) FROM (SELECT id, email, name, role, provider, password FROM users WHERE email = $1) u"
)
.bind(email)
.fetch_optional(self.pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))
}
fn make_tokens(&self, user_id: Uuid, role: &str) -> Result<AuthTokens, AppError> {
Ok(AuthTokens {
access_token: self.jwt.generate_token(user_id, role)?,
refresh_token: self.jwt.generate_refresh_token(user_id, role)?,
})
}
}
#[async_trait]
impl QrAuthService for QrAuthServiceImpl {
async fn register(&self, email: String, password: String, name: String) -> Result<(AuthTokens, QrUserData), AppError> {
let existing = self.find_user_by_email(&email).await?;
if existing.is_some() {
return Err(AppError::ConflictError("Email already registered".to_string()));
}
let hashed = bcrypt::hash(&password, 10)
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
let user = sqlx::query_as::<_, QrUserData>(
"INSERT INTO users (email, password, name, role, provider) VALUES ($1, $2, $3, 'user', 'local') RETURNING id, email, name, role, provider, created_at, updated_at"
)
.bind(&email)
.bind(&hashed)
.bind(&name)
.fetch_one(self.pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
let tokens = self.make_tokens(user.id, &user.role)?;
Ok((tokens, user))
}
async fn login(&self, email: String, password: String) -> Result<(AuthTokens, QrUserData), AppError> {
let row = self.find_user_by_email(&email).await?
.ok_or_else(|| AppError::AuthenticationError("Invalid credentials".to_string()))?;
let provider = row["provider"].as_str().unwrap_or("local");
if provider != "local" {
return Err(AppError::AuthenticationError("Account uses social login".to_string()));
}
let stored_hash = row["password"].as_str()
.ok_or_else(|| AppError::AuthenticationError("Invalid credentials".to_string()))?;
let valid = bcrypt::verify(&password, stored_hash)
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
if !valid {
return Err(AppError::AuthenticationError("Invalid credentials".to_string()));
}
let user_id: Uuid = row["id"].as_str()
.and_then(|s| Uuid::parse_str(s).ok())
.ok_or_else(|| AppError::InternalServerError("Invalid user ID".to_string()))?;
let user = self.find_user_by_id(user_id).await?;
let tokens = self.make_tokens(user.id, &user.role)?;
Ok((tokens, user))
}
async fn google_callback(&self, code: String) -> Result<(AuthTokens, QrUserData), AppError> {
let http = reqwest::Client::new();
let token_res: serde_json::Value = http
.post("https://oauth2.googleapis.com/token")
.form(&[
("code", code.as_str()),
("client_id", self.config.google_client_id.as_str()),
("client_secret", self.config.google_client_secret.as_str()),
("redirect_uri", self.config.google_redirect_url.as_str()),
("grant_type", "authorization_code"),
])
.send()
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?
.json()
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
if token_res.get("error").is_some() {
return Err(AppError::BadRequestError("Google OAuth error".to_string()));
}
let access_token = token_res["access_token"].as_str()
.ok_or_else(|| AppError::InternalServerError("Missing access token from Google".to_string()))?;
let google_user: serde_json::Value = http
.get("https://www.googleapis.com/oauth2/v2/userinfo")
.header("Authorization", format!("Bearer {}", access_token))
.send()
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?
.json()
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
let email = google_user["email"].as_str()
.ok_or_else(|| AppError::InternalServerError("Missing email from Google".to_string()))?;
let name = google_user["name"].as_str().unwrap_or(email);
let provider_id = google_user["id"].as_str().unwrap_or("");
let user = sqlx::query_as::<_, QrUserData>(
"INSERT INTO users (email, name, role, provider, provider_id) VALUES ($1, $2, 'user', 'google', $3)
ON CONFLICT (email) DO UPDATE SET provider_id = EXCLUDED.provider_id, updated_at = NOW()
RETURNING id, email, name, role, provider, created_at, updated_at"
)
.bind(email)
.bind(name)
.bind(provider_id)
.fetch_one(self.pool.as_ref())
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
let tokens = self.make_tokens(user.id, &user.role)?;
Ok((tokens, user))
}
async fn refresh_token(&self, refresh_token: String) -> Result<AuthTokens, AppError> {
let claims = self.jwt.verify_token(&refresh_token)?;
let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::AuthenticationError("Invalid token subject".to_string()))?;
let user = self.find_user_by_id(user_id).await?;
Ok(AuthTokens {
access_token: self.jwt.generate_token(user.id, &user.role)?,
refresh_token,
})
}
}
-1
View File
@@ -1 +0,0 @@
pub mod auth_service;
-1
View File
@@ -1 +0,0 @@
pub mod service;
-30
View File
@@ -1,30 +0,0 @@
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use imphnen_utils::errors::AppError;
#[derive(Debug, Serialize, Deserialize)]
pub struct AuthTokens {
pub access_token: String,
pub refresh_token: String,
}
#[derive(Debug, Serialize, Deserialize, ToSchema, sqlx::FromRow)]
pub struct QrUserData {
pub id: Uuid,
pub email: String,
pub name: String,
pub role: String,
pub provider: String,
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
pub updated_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[async_trait]
pub trait QrAuthService: Send + Sync {
async fn register(&self, email: String, password: String, name: String) -> Result<(AuthTokens, QrUserData), AppError>;
async fn login(&self, email: String, password: String) -> Result<(AuthTokens, QrUserData), AppError>;
async fn google_callback(&self, code: String) -> Result<(AuthTokens, QrUserData), AppError>;
async fn refresh_token(&self, refresh_token: String) -> Result<AuthTokens, AppError>;
}
@@ -1,34 +0,0 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::auth::domain::service::QrUserData;
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct RegisterRequest {
pub email: String,
pub password: String,
pub name: String,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct LoginRequest {
pub email: String,
pub password: String,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct RefreshRequest {
pub refresh_token: String,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct AuthResponse {
pub access_token: String,
pub refresh_token: String,
pub user: QrUserData,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct TokensResponse {
pub access_token: String,
pub refresh_token: String,
}
@@ -1,72 +0,0 @@
use axum::{Extension, Json, response::IntoResponse};
use axum::extract::Query;
use std::sync::Arc;
use serde::Deserialize;
use imphnen_utils::response_format::ApiSuccess;
use imphnen_utils::errors::AppError;
use crate::auth::domain::service::QrAuthService;
use crate::config::QrConfig;
use super::dto::{RegisterRequest, LoginRequest, RefreshRequest, AuthResponse, TokensResponse};
pub async fn register_handler(
Extension(service): Extension<Arc<dyn QrAuthService>>,
Json(body): Json<RegisterRequest>,
) -> Result<axum::response::Response, AppError> {
let (tokens, user) = service.register(body.email, body.password, body.name).await?;
Ok(ApiSuccess(AuthResponse {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
user,
}).into_response())
}
pub async fn login_handler(
Extension(service): Extension<Arc<dyn QrAuthService>>,
Json(body): Json<LoginRequest>,
) -> Result<axum::response::Response, AppError> {
let (tokens, user) = service.login(body.email, body.password).await?;
Ok(ApiSuccess(AuthResponse {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
user,
}).into_response())
}
pub async fn google_redirect_handler(
Extension(config): Extension<Arc<QrConfig>>,
) -> Result<axum::response::Response, AppError> {
let url = format!(
"https://accounts.google.com/o/oauth2/v2/auth?client_id={}&redirect_uri={}&response_type=code&scope=email+profile",
config.google_client_id,
config.google_redirect_url,
);
Ok(axum::response::Redirect::temporary(&url).into_response())
}
#[derive(Debug, Deserialize)]
pub struct GoogleCallbackQuery {
pub code: String,
}
pub async fn google_callback_handler(
Extension(service): Extension<Arc<dyn QrAuthService>>,
Query(params): Query<GoogleCallbackQuery>,
) -> Result<axum::response::Response, AppError> {
let (tokens, user) = service.google_callback(params.code).await?;
Ok(ApiSuccess(AuthResponse {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
user,
}).into_response())
}
pub async fn refresh_handler(
Extension(service): Extension<Arc<dyn QrAuthService>>,
Json(body): Json<RefreshRequest>,
) -> Result<axum::response::Response, AppError> {
let tokens = service.refresh_token(body.refresh_token).await?;
Ok(ApiSuccess(TokensResponse {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
}).into_response())
}
@@ -1,3 +0,0 @@
pub mod dto;
pub mod handlers;
pub mod routes;
@@ -1,29 +0,0 @@
use axum::{routing::{get, post}, Extension, Router};
use sqlx::PgPool;
use std::sync::Arc;
use crate::auth::application::auth_service::QrAuthServiceImpl;
use crate::auth::domain::service::QrAuthService;
use crate::common::qr_jwt::QrJwtService;
use crate::config::QrConfig;
use super::handlers::{
register_handler,
login_handler,
google_redirect_handler,
google_callback_handler,
refresh_handler,
};
pub fn qr_auth_routes(pool: Arc<PgPool>, jwt: Arc<QrJwtService>, config: Arc<QrConfig>) -> Router {
let service: Arc<dyn QrAuthService> = Arc::new(
QrAuthServiceImpl::new(pool, jwt, config.clone())
);
Router::new()
.route("/auth/register", post(register_handler))
.route("/auth/login", post(login_handler))
.route("/auth/google", get(google_redirect_handler))
.route("/auth/google/callback", get(google_callback_handler))
.route("/auth/refresh", post(refresh_handler))
.layer(Extension(service))
.layer(Extension(config))
}
@@ -1,2 +0,0 @@
pub mod http;
pub mod persistence;
@@ -1 +0,0 @@
-3
View File
@@ -1,3 +0,0 @@
pub mod domain;
pub mod application;
pub mod infrastructure;
@@ -18,11 +18,10 @@ use crate::{
persistence::postgres_campaign_repository::PostgresCampaignRepository,
},
},
common::qr_jwt::QrJwtService,
middleware::qr_auth::qr_auth_middleware,
};
pub fn qr_campaigns_routes(pool: Arc<PgPool>, jwt: Arc<QrJwtService>) -> Router {
pub fn qr_campaigns_routes(pool: Arc<PgPool>) -> Router {
let repo: Arc<dyn CampaignRepository> = Arc::new(PostgresCampaignRepository::new(pool.clone()));
let service: Arc<dyn QrCampaignService> = Arc::new(QrCampaignServiceImpl::new(repo));
@@ -32,7 +31,6 @@ pub fn qr_campaigns_routes(pool: Arc<PgPool>, jwt: Arc<QrJwtService>) -> Router
.route("/campaigns/:id", delete(delete_campaign_handler))
.route("/campaigns/process-image", post(process_image_handler))
.layer(Extension(service))
.layer(Extension(jwt.clone()))
.layer(Extension(pool))
.layer(from_fn(qr_auth_middleware))
}
+1 -1
View File
@@ -1 +1 @@
pub mod qr_jwt;
-59
View File
@@ -1,59 +0,0 @@
use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use imphnen_utils::errors::AppError;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct QrClaims {
pub sub: String,
pub role: String,
pub exp: usize,
}
#[derive(Clone)]
pub struct QrJwtService {
encoding_key: EncodingKey,
decoding_key: DecodingKey,
expiry_minutes: i64,
refresh_expiry_days: i64,
}
impl QrJwtService {
pub fn new(secret: &str, expiry_minutes: i64, refresh_expiry_days: i64) -> Self {
Self {
encoding_key: EncodingKey::from_secret(secret.as_bytes()),
decoding_key: DecodingKey::from_secret(secret.as_bytes()),
expiry_minutes,
refresh_expiry_days,
}
}
pub fn generate_token(&self, user_id: Uuid, role: &str) -> Result<String, AppError> {
let exp = (Utc::now() + Duration::minutes(self.expiry_minutes)).timestamp() as usize;
let claims = QrClaims {
sub: user_id.to_string(),
role: role.to_string(),
exp,
};
encode(&Header::default(), &claims, &self.encoding_key)
.map_err(|e| AppError::InternalServerError(e.to_string()))
}
pub fn generate_refresh_token(&self, user_id: Uuid, role: &str) -> Result<String, AppError> {
let exp = (Utc::now() + Duration::days(self.refresh_expiry_days)).timestamp() as usize;
let claims = QrClaims {
sub: user_id.to_string(),
role: role.to_string(),
exp,
};
encode(&Header::default(), &claims, &self.encoding_key)
.map_err(|e| AppError::InternalServerError(e.to_string()))
}
pub fn verify_token(&self, token: &str) -> Result<QrClaims, AppError> {
decode::<QrClaims>(token, &self.decoding_key, &Validation::default())
.map(|d| d.claims)
.map_err(|_| AppError::AuthenticationError("Invalid or expired token".to_string()))
}
}
-30
View File
@@ -1,30 +0,0 @@
use std::env;
#[derive(Debug, Clone)]
pub struct QrConfig {
pub jwt_secret: String,
pub jwt_expiry_minutes: i64,
pub refresh_expiry_days: i64,
pub google_client_id: String,
pub google_client_secret: String,
pub google_redirect_url: String,
}
impl QrConfig {
pub fn from_env() -> Self {
Self {
jwt_secret: env::var("QR_JWT_SECRET").expect("QR_JWT_SECRET must be set"),
jwt_expiry_minutes: env::var("QR_JWT_EXPIRY_MINUTES")
.unwrap_or_else(|_| "15".to_string())
.parse()
.unwrap_or(15),
refresh_expiry_days: env::var("QR_JWT_REFRESH_EXPIRY_DAYS")
.unwrap_or_else(|_| "7".to_string())
.parse()
.unwrap_or(7),
google_client_id: env::var("QR_GOOGLE_CLIENT_ID").unwrap_or_default(),
google_client_secret: env::var("QR_GOOGLE_CLIENT_SECRET").unwrap_or_default(),
google_redirect_url: env::var("QR_GOOGLE_REDIRECT_URL").unwrap_or_default(),
}
}
}
+3 -15
View File
@@ -1,26 +1,14 @@
pub mod config;
pub mod common;
pub mod middleware;
pub mod auth;
pub mod users;
pub mod campaigns;
pub use config::QrConfig;
use axum::Router;
use sqlx::PgPool;
use std::sync::Arc;
use common::qr_jwt::QrJwtService;
pub fn qr_router(pool: Arc<PgPool>, config: Arc<QrConfig>) -> Router {
let jwt = Arc::new(QrJwtService::new(
&config.jwt_secret,
config.jwt_expiry_minutes,
config.refresh_expiry_days,
));
pub fn qr_router(pool: Arc<PgPool>) -> Router {
Router::new()
.merge(auth::infrastructure::http::routes::qr_auth_routes(pool.clone(), jwt.clone(), config.clone()))
.merge(users::infrastructure::http::routes::qr_users_routes(pool.clone(), jwt.clone()))
.merge(campaigns::infrastructure::http::routes::qr_campaigns_routes(pool.clone(), jwt.clone()))
.merge(users::infrastructure::http::routes::qr_users_routes(pool.clone()))
.merge(campaigns::infrastructure::http::routes::qr_campaigns_routes(pool))
}
+21 -5
View File
@@ -1,9 +1,10 @@
use axum::{body::Body, extract::Request, middleware::Next, response::{IntoResponse, Response}};
use axum::http::StatusCode;
use sqlx::PgPool;
use std::sync::Arc;
use uuid::Uuid;
use serde::{Deserialize, Serialize};
use crate::common::qr_jwt::QrJwtService;
use imphnen_libs::decode_access_token;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QrAuthUser {
@@ -12,7 +13,7 @@ pub struct QrAuthUser {
}
pub async fn qr_auth_middleware(
axum::Extension(jwt_service): axum::Extension<Arc<QrJwtService>>,
axum::Extension(pool): axum::Extension<Arc<PgPool>>,
mut request: Request<Body>,
next: Next,
) -> Result<Response, Response> {
@@ -26,14 +27,29 @@ pub async fn qr_auth_middleware(
(StatusCode::UNAUTHORIZED, "Invalid Authorization header format").into_response()
})?;
let claims = jwt_service.verify_token(token).map_err(|_| {
let token_data = decode_access_token(token).map_err(|_| {
(StatusCode::UNAUTHORIZED, "Invalid or expired token").into_response()
})?;
let user_id = Uuid::parse_str(&claims.sub).map_err(|_| {
let user_id = Uuid::parse_str(&token_data.claims.user_id).map_err(|_| {
(StatusCode::UNAUTHORIZED, "Invalid user ID in token").into_response()
})?;
request.extensions_mut().insert(QrAuthUser { user_id, role: claims.role });
let _ = sqlx::query(
"INSERT INTO users (id, email, name, role, provider) VALUES ($1, $2, $2, 'user', 'external') ON CONFLICT (id) DO NOTHING"
)
.bind(user_id)
.bind(&token_data.claims.sub)
.execute(pool.as_ref())
.await;
let role: String = sqlx::query_scalar("SELECT role FROM users WHERE id = $1")
.bind(user_id)
.fetch_optional(pool.as_ref())
.await
.unwrap_or(None)
.unwrap_or_else(|| "user".to_string());
request.extensions_mut().insert(QrAuthUser { user_id, role });
Ok(next.run(request).await)
}
@@ -7,7 +7,6 @@ use sqlx::PgPool;
use std::sync::Arc;
use crate::{
common::qr_jwt::QrJwtService,
middleware::qr_auth::qr_auth_middleware,
users::{
application::user_service::QrUserServiceImpl,
@@ -22,7 +21,7 @@ use crate::{
},
};
pub fn qr_users_routes(pool: Arc<PgPool>, jwt: Arc<QrJwtService>) -> Router {
pub fn qr_users_routes(pool: Arc<PgPool>) -> Router {
let repo: Arc<dyn UserRepository> = Arc::new(PostgresUserRepository::new(pool.clone()));
let service: Arc<dyn QrUserService> = Arc::new(QrUserServiceImpl::new(repo));
@@ -32,7 +31,6 @@ pub fn qr_users_routes(pool: Arc<PgPool>, jwt: Arc<QrJwtService>) -> Router {
.route("/users/:id/role", put(update_role_handler))
.route("/users/:id", delete(delete_user_handler))
.layer(Extension(service))
.layer(Extension(jwt.clone()))
.layer(Extension(pool))
.layer(from_fn(qr_auth_middleware))
}