diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 1c503c2..b9586a0 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -428,6 +428,7 @@ dependencies = [ "log", "openidconnect", "serde", + "sqlx", ] [[package]] diff --git a/backend/feature/auth/Cargo.toml b/backend/feature/auth/Cargo.toml index 26ffa18..27362a0 100644 --- a/backend/feature/auth/Cargo.toml +++ b/backend/feature/auth/Cargo.toml @@ -10,3 +10,4 @@ async-trait.workspace = true log.workspace = true openidconnect.workspace = true serde.workspace = true +sqlx.workspace = true diff --git a/backend/feature/auth/src/adapter/delivery/user_response_dto.rs b/backend/feature/auth/src/adapter/delivery/user_response_dto.rs index 975ab8c..2726c57 100644 --- a/backend/feature/auth/src/adapter/delivery/user_response_dto.rs +++ b/backend/feature/auth/src/adapter/delivery/user_response_dto.rs @@ -4,7 +4,7 @@ use crate::domain::entity::user::User; #[derive(Serialize)] pub struct UserResponseDto { - pub source_id: String, + pub id: i32, pub displayed_name: String, pub email: String, } @@ -12,7 +12,7 @@ pub struct UserResponseDto { impl From for UserResponseDto { fn from(user: User) -> Self { UserResponseDto { - source_id: user.source_id, + id: user.id, displayed_name: user.displayed_name, email: user.email, } diff --git a/backend/feature/auth/src/adapter/gateway.rs b/backend/feature/auth/src/adapter/gateway.rs index d813de5..9a26c24 100644 --- a/backend/feature/auth/src/adapter/gateway.rs +++ b/backend/feature/auth/src/adapter/gateway.rs @@ -1,3 +1,5 @@ -pub mod oidc_claims_response_dto; pub mod auth_oidc_service; pub mod auth_repository_impl; +pub mod oidc_claims_response_dto; +pub mod user_db_service; +pub mod user_mapper; diff --git a/backend/feature/auth/src/adapter/gateway/auth_repository_impl.rs b/backend/feature/auth/src/adapter/gateway/auth_repository_impl.rs index 533da3a..c480ae9 100644 --- a/backend/feature/auth/src/adapter/gateway/auth_repository_impl.rs +++ b/backend/feature/auth/src/adapter/gateway/auth_repository_impl.rs @@ -3,7 +3,9 @@ use std::sync::Arc; use async_trait::async_trait; use crate::{ - adapter::gateway::auth_oidc_service::AuthOidcService, + adapter::gateway::{ + auth_oidc_service::AuthOidcService, user_db_service::UserDbService, user_mapper::UserMapper, + }, application::{ error::auth_error::AuthError, gateway::auth_repository::AuthRepository, use_case::get_auth_url_use_case::AuthUrl, @@ -12,12 +14,19 @@ use crate::{ }; pub struct AuthRepositoryImpl { + user_db_service: Arc, auth_oidc_service: Arc, } impl AuthRepositoryImpl { - pub fn new(auth_oidc_service: Arc) -> Self { - Self { auth_oidc_service } + pub fn new( + user_db_service: Arc, + auth_oidc_service: Arc, + ) -> Self { + Self { + user_db_service, + auth_oidc_service, + } } } @@ -37,4 +46,21 @@ impl AuthRepository for AuthRepositoryImpl { .await .map(|dto| dto.into_entity()) } + + async fn get_user_by_source_id( + &self, + issuer: &str, + source_id: &str, + ) -> Result { + self.user_db_service + .get_user_by_source_id(issuer, source_id) + .await + .map(|mapper| mapper.into_entity()) + } + + async fn save_user(&self, user: User) -> Result { + self.user_db_service + .create_user(UserMapper::from(user)) + .await + } } diff --git a/backend/feature/auth/src/adapter/gateway/oidc_claims_response_dto.rs b/backend/feature/auth/src/adapter/gateway/oidc_claims_response_dto.rs index 94c17b7..3d36876 100644 --- a/backend/feature/auth/src/adapter/gateway/oidc_claims_response_dto.rs +++ b/backend/feature/auth/src/adapter/gateway/oidc_claims_response_dto.rs @@ -2,6 +2,7 @@ use crate::domain::entity::user::User; pub struct OidcClaimsResponseDto { pub sub: String, + pub issuer: String, pub preferred_username: Option, pub email: Option, } @@ -9,6 +10,8 @@ pub struct OidcClaimsResponseDto { impl OidcClaimsResponseDto { pub fn into_entity(self) -> User { User { + id: -1, + issuer: self.issuer, source_id: self.sub, displayed_name: self.preferred_username.unwrap_or_default(), email: self.email.unwrap_or_default(), diff --git a/backend/feature/auth/src/adapter/gateway/user_db_service.rs b/backend/feature/auth/src/adapter/gateway/user_db_service.rs new file mode 100644 index 0000000..93eff18 --- /dev/null +++ b/backend/feature/auth/src/adapter/gateway/user_db_service.rs @@ -0,0 +1,14 @@ +use async_trait::async_trait; + +use crate::{adapter::gateway::user_mapper::UserMapper, application::error::auth_error::AuthError}; + +#[async_trait] +pub trait UserDbService: Send + Sync { + async fn get_user_by_source_id( + &self, + issuer: &str, + source_id: &str, + ) -> Result; + + async fn create_user(&self, user: UserMapper) -> Result; +} diff --git a/backend/feature/auth/src/adapter/gateway/user_mapper.rs b/backend/feature/auth/src/adapter/gateway/user_mapper.rs new file mode 100644 index 0000000..31a01d0 --- /dev/null +++ b/backend/feature/auth/src/adapter/gateway/user_mapper.rs @@ -0,0 +1,33 @@ +use crate::domain::entity::user::User; + +pub struct UserMapper { + pub id: i32, + pub issuer: String, + pub source_id: String, + pub displayed_name: String, + pub email: String, +} + +impl From for UserMapper { + fn from(user: User) -> Self { + Self { + id: user.id, + issuer: user.issuer, + source_id: user.source_id, + displayed_name: user.displayed_name, + email: user.email, + } + } +} + +impl UserMapper { + pub fn into_entity(self) -> User { + User { + id: self.id, + issuer: self.issuer, + source_id: self.source_id, + displayed_name: self.displayed_name, + email: self.email, + } + } +} diff --git a/backend/feature/auth/src/application/error/auth_error.rs b/backend/feature/auth/src/application/error/auth_error.rs index 820a405..9fa2375 100644 --- a/backend/feature/auth/src/application/error/auth_error.rs +++ b/backend/feature/auth/src/application/error/auth_error.rs @@ -1,8 +1,10 @@ #[derive(Debug, PartialEq)] pub enum AuthError { + DatabaseError(String), OidcError(String), InvalidState, InvalidNonce, InvalidAuthCode, InvalidIdToken, + UserNotFound, } diff --git a/backend/feature/auth/src/application/gateway/auth_repository.rs b/backend/feature/auth/src/application/gateway/auth_repository.rs index 98a12f5..c88567c 100644 --- a/backend/feature/auth/src/application/gateway/auth_repository.rs +++ b/backend/feature/auth/src/application/gateway/auth_repository.rs @@ -8,6 +8,12 @@ use crate::{ #[async_trait] pub trait AuthRepository: Send + Sync { fn get_auth_url(&self) -> Result; + async fn exchange_auth_code(&self, code: &str, expected_nonce: &str) -> Result; + + async fn get_user_by_source_id(&self, issuer: &str, source_id: &str) + -> Result; + + async fn save_user(&self, user: User) -> Result; } diff --git a/backend/feature/auth/src/application/use_case/exchange_auth_code_use_case.rs b/backend/feature/auth/src/application/use_case/exchange_auth_code_use_case.rs index 65e90d1..90990e6 100644 --- a/backend/feature/auth/src/application/use_case/exchange_auth_code_use_case.rs +++ b/backend/feature/auth/src/application/use_case/exchange_auth_code_use_case.rs @@ -41,8 +41,24 @@ impl ExchangeAuthCodeUseCase for ExchangeAuthCodeUseCaseImpl { return Err(AuthError::InvalidState); } - self.auth_repository + let mut logged_in_user = self + .auth_repository .exchange_auth_code(code, expected_nonce) - .await + .await?; + + let saved_user = self + .auth_repository + .get_user_by_source_id(&logged_in_user.issuer, &logged_in_user.source_id) + .await; + + if saved_user.err() == Some(AuthError::UserNotFound) { + let id = self + .auth_repository + .save_user(logged_in_user.clone()) + .await?; + logged_in_user.id = id; + } + + Ok(logged_in_user) } } diff --git a/backend/feature/auth/src/domain/entity/user.rs b/backend/feature/auth/src/domain/entity/user.rs index 2cd83ca..a03bc1e 100644 --- a/backend/feature/auth/src/domain/entity/user.rs +++ b/backend/feature/auth/src/domain/entity/user.rs @@ -1,4 +1,7 @@ +#[derive(Clone)] pub struct User { + pub id: i32, + pub issuer: String, pub source_id: String, pub displayed_name: String, pub email: String, diff --git a/backend/feature/auth/src/framework.rs b/backend/feature/auth/src/framework.rs index 39c6581..5dfd9bf 100644 --- a/backend/feature/auth/src/framework.rs +++ b/backend/feature/auth/src/framework.rs @@ -1,2 +1,3 @@ +pub mod db; pub mod oidc; pub mod web; diff --git a/backend/feature/auth/src/framework/db.rs b/backend/feature/auth/src/framework/db.rs new file mode 100644 index 0000000..1737d13 --- /dev/null +++ b/backend/feature/auth/src/framework/db.rs @@ -0,0 +1,2 @@ +pub mod user_db_service_impl; +pub mod user_record; diff --git a/backend/feature/auth/src/framework/db/user_db_service_impl.rs b/backend/feature/auth/src/framework/db/user_db_service_impl.rs new file mode 100644 index 0000000..1430229 --- /dev/null +++ b/backend/feature/auth/src/framework/db/user_db_service_impl.rs @@ -0,0 +1,65 @@ +use async_trait::async_trait; +use sqlx::{Pool, Postgres}; + +use crate::{ + adapter::gateway::{user_db_service::UserDbService, user_mapper::UserMapper}, + application::error::auth_error::AuthError, + framework::db::user_record::UserRecord, +}; + +pub struct UserDbServiceImpl { + db_pool: Pool, +} + +impl UserDbServiceImpl { + pub fn new(db_pool: Pool) -> Self { + Self { db_pool } + } +} + +#[async_trait] +impl UserDbService for UserDbServiceImpl { + async fn get_user_by_source_id( + &self, + issuer: &str, + source_id: &str, + ) -> Result { + let record = sqlx::query_as!( + UserRecord, + r#" + SELECT id, issuer, source_id, displayed_name, email + FROM "user" + WHERE issuer = $1 AND source_id = $2 + "#, + issuer, + source_id + ) + .fetch_optional(&self.db_pool) + .await + .map_err(|e| AuthError::DatabaseError(e.to_string()))?; + + match record { + Some(record) => Ok(record.into_mapper()), + None => Err(AuthError::UserNotFound), + } + } + + async fn create_user(&self, user: UserMapper) -> Result { + let id = sqlx::query_scalar!( + r#" + INSERT INTO "user" (issuer, source_id, displayed_name, email) + VALUES ($1, $2, $3, $4) + RETURNING id + "#, + user.issuer, + user.source_id, + user.displayed_name, + user.email + ) + .fetch_one(&self.db_pool) + .await + .map_err(|e| AuthError::DatabaseError(e.to_string()))?; + + Ok(id) + } +} diff --git a/backend/feature/auth/src/framework/db/user_record.rs b/backend/feature/auth/src/framework/db/user_record.rs new file mode 100644 index 0000000..7f296d8 --- /dev/null +++ b/backend/feature/auth/src/framework/db/user_record.rs @@ -0,0 +1,24 @@ +use sqlx::FromRow; + +use crate::adapter::gateway::user_mapper::UserMapper; + +#[derive(FromRow)] +pub struct UserRecord { + pub id: i32, + pub issuer: String, + pub source_id: String, + pub displayed_name: String, + pub email: String, +} + +impl UserRecord { + pub fn into_mapper(self) -> UserMapper { + UserMapper { + id: self.id, + issuer: self.issuer, + source_id: self.source_id, + displayed_name: self.displayed_name, + email: self.email, + } + } +} diff --git a/backend/feature/auth/src/framework/oidc/auth_oidc_service_impl.rs b/backend/feature/auth/src/framework/oidc/auth_oidc_service_impl.rs index d60f7da..d4a85dd 100644 --- a/backend/feature/auth/src/framework/oidc/auth_oidc_service_impl.rs +++ b/backend/feature/auth/src/framework/oidc/auth_oidc_service_impl.rs @@ -93,6 +93,8 @@ impl AuthOidcService for AuthOidcServiceImpl { ) .map_err(|_| AuthError::InvalidIdToken)?; + let issuer = claims.issuer().to_string(); + let preferred_username = claims .preferred_username() .map(|username| username.to_string()); @@ -101,6 +103,7 @@ impl AuthOidcService for AuthOidcServiceImpl { Ok(OidcClaimsResponseDto { sub: claims.subject().to_string(), + issuer: issuer, preferred_username: preferred_username, email: email, }) diff --git a/backend/feature/auth/src/framework/web.rs b/backend/feature/auth/src/framework/web.rs index 60c8eb8..8853d00 100644 --- a/backend/feature/auth/src/framework/web.rs +++ b/backend/feature/auth/src/framework/web.rs @@ -1 +1,3 @@ pub mod auth_web_routes; + +mod constants; diff --git a/backend/feature/auth/src/framework/web/auth_web_routes.rs b/backend/feature/auth/src/framework/web/auth_web_routes.rs index c56dda3..f0ab70d 100644 --- a/backend/feature/auth/src/framework/web/auth_web_routes.rs +++ b/backend/feature/auth/src/framework/web/auth_web_routes.rs @@ -6,12 +6,11 @@ use crate::{ auth_controller::AuthController, oidc_callback_query_dto::OidcCallbackQueryDto, }, application::error::auth_error::AuthError, + framework::web::constants::{ + SESSION_KEY_AUTH_NONCE, SESSION_KEY_AUTH_STATE, SESSION_KEY_USER_ID, + }, }; -const SESSION_KEY_AUTH_STATE: &str = "auth_state"; -const SESSION_KEY_AUTH_NONCE: &str = "auth_nonce"; -const SESSION_KEY_USER: &str = "user"; - pub fn configure_auth_routes(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("/auth") @@ -29,11 +28,11 @@ async fn oidc_login_handler( match result { Ok(auth_url) => { - if let Err(e) = session.insert(SESSION_KEY_AUTH_STATE, auth_url.state) { + if let Err(e) = session.insert::(SESSION_KEY_AUTH_STATE, auth_url.state) { log::error!("{e:?}"); return HttpResponse::InternalServerError().finish(); } - if let Err(e) = session.insert(SESSION_KEY_AUTH_NONCE, auth_url.nonce) { + if let Err(e) = session.insert::(SESSION_KEY_AUTH_NONCE, auth_url.nonce) { log::error!("{e:?}"); return HttpResponse::InternalServerError().finish(); } @@ -53,12 +52,12 @@ async fn oidc_callback_handler( query: web::Query, session: Session, ) -> impl Responder { - let expected_state: String = match session.get(SESSION_KEY_AUTH_STATE) { + let expected_state = match session.get::(SESSION_KEY_AUTH_STATE) { Ok(Some(state)) => state, _ => return HttpResponse::BadRequest().finish(), }; - let expected_nonce: String = match session.get(SESSION_KEY_AUTH_NONCE) { + let expected_nonce = match session.get::(SESSION_KEY_AUTH_NONCE) { Ok(Some(nonce)) => nonce, _ => return HttpResponse::BadRequest().finish(), }; @@ -71,7 +70,7 @@ async fn oidc_callback_handler( session.remove(SESSION_KEY_AUTH_NONCE); match result { Ok(user) => { - if let Err(e) = session.insert(SESSION_KEY_USER, user) { + if let Err(e) = session.insert::(SESSION_KEY_USER_ID, user.id) { log::error!("{e:?}"); return HttpResponse::InternalServerError().finish(); } @@ -95,7 +94,7 @@ async fn oidc_callback_handler( async fn logout_handler(session: Session) -> impl Responder { session.remove(SESSION_KEY_AUTH_STATE); session.remove(SESSION_KEY_AUTH_NONCE); - session.remove(SESSION_KEY_USER); + session.remove(SESSION_KEY_USER_ID); HttpResponse::Found() .append_header((header::LOCATION, "/")) .finish() diff --git a/backend/feature/auth/src/framework/web/constants.rs b/backend/feature/auth/src/framework/web/constants.rs new file mode 100644 index 0000000..346177d --- /dev/null +++ b/backend/feature/auth/src/framework/web/constants.rs @@ -0,0 +1,3 @@ +pub const SESSION_KEY_AUTH_STATE: &str = "auth_state"; +pub const SESSION_KEY_AUTH_NONCE: &str = "auth_nonce"; +pub const SESSION_KEY_USER_ID: &str = "user_id"; diff --git a/backend/migrations/20250725231740_v0.3.0.sql b/backend/migrations/20250725231740_v0.3.0.sql index 8186a66..4b7a6a0 100644 --- a/backend/migrations/20250725231740_v0.3.0.sql +++ b/backend/migrations/20250725231740_v0.3.0.sql @@ -6,7 +6,25 @@ CREATE TABLE "image" ( "updated_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); +CREATE TABLE "user" ( + "id" SERIAL PRIMARY KEY NOT NULL, + "issuer" VARCHAR(100) NOT NULL, + "source_id" VARCHAR(100) NOT NULL, + "displayed_name" VARCHAR(100) NOT NULL, + "email" VARCHAR(100) NOT NULL, + "created_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE UNIQUE INDEX "user_source_id_issuer_key" ON "user" ("source_id", "issuer"); +CREATE INDEX "user_email_key" ON "user" HASH ("email"); + CREATE TRIGGER "update_image_updated_time" BEFORE UPDATE ON "image" FOR EACH ROW EXECUTE FUNCTION update_updated_time_column(); + +CREATE TRIGGER "update_user_updated_time" +BEFORE UPDATE ON "user" +FOR EACH ROW +EXECUTE FUNCTION update_updated_time_column(); diff --git a/backend/server/src/container.rs b/backend/server/src/container.rs index f9a04b2..50be621 100644 --- a/backend/server/src/container.rs +++ b/backend/server/src/container.rs @@ -9,7 +9,10 @@ use auth::{ exchange_auth_code_use_case::ExchangeAuthCodeUseCaseImpl, get_auth_url_use_case::LoginUseCaseImpl, }, - framework::oidc::auth_oidc_service_impl::AuthOidcServiceImpl, + framework::{ + db::user_db_service_impl::UserDbServiceImpl, + oidc::auth_oidc_service_impl::AuthOidcServiceImpl, + }, }; use image::{ adapter::{ @@ -60,7 +63,8 @@ impl Container { oidc_configuration.redirect_url.clone(), http_client, )); - let auth_repository = Arc::new(AuthRepositoryImpl::new(auth_oidc_service)); + let user_db_service = Arc::new(UserDbServiceImpl::new(db_pool.clone())); + let auth_repository = Arc::new(AuthRepositoryImpl::new(user_db_service, auth_oidc_service)); let get_auth_url_use_case = Arc::new(LoginUseCaseImpl::new(auth_repository.clone())); let exchange_auth_code_use_case = Arc::new(ExchangeAuthCodeUseCaseImpl::new(auth_repository.clone()));