BLOG-94 Create user in DB when first login through OIDC #96

Merged
squid merged 4 commits from BLOG-94_create_user_when_first_login into main 2025-08-01 13:24:09 +08:00
22 changed files with 248 additions and 20 deletions
Showing only changes of commit e8f7f96677 - Show all commits

1
backend/Cargo.lock generated
View File

@ -428,6 +428,7 @@ dependencies = [
"log", "log",
"openidconnect", "openidconnect",
"serde", "serde",
"sqlx",
] ]
[[package]] [[package]]

View File

@ -10,3 +10,4 @@ async-trait.workspace = true
log.workspace = true log.workspace = true
openidconnect.workspace = true openidconnect.workspace = true
serde.workspace = true serde.workspace = true
sqlx.workspace = true

View File

@ -4,7 +4,7 @@ use crate::domain::entity::user::User;
#[derive(Serialize)] #[derive(Serialize)]
pub struct UserResponseDto { pub struct UserResponseDto {
pub source_id: String, pub id: i32,
pub displayed_name: String, pub displayed_name: String,
pub email: String, pub email: String,
} }
@ -12,7 +12,7 @@ pub struct UserResponseDto {
impl From<User> for UserResponseDto { impl From<User> for UserResponseDto {
fn from(user: User) -> Self { fn from(user: User) -> Self {
UserResponseDto { UserResponseDto {
source_id: user.source_id, id: user.id,
displayed_name: user.displayed_name, displayed_name: user.displayed_name,
email: user.email, email: user.email,
} }

View File

@ -1,3 +1,5 @@
pub mod oidc_claims_response_dto;
pub mod auth_oidc_service; pub mod auth_oidc_service;
pub mod auth_repository_impl; pub mod auth_repository_impl;
pub mod oidc_claims_response_dto;
pub mod user_db_service;
pub mod user_mapper;

View File

@ -3,7 +3,9 @@ use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use crate::{ use crate::{
adapter::gateway::auth_oidc_service::AuthOidcService, adapter::gateway::{
auth_oidc_service::AuthOidcService, user_db_service::UserDbService, user_mapper::UserMapper,
},
application::{ application::{
error::auth_error::AuthError, gateway::auth_repository::AuthRepository, error::auth_error::AuthError, gateway::auth_repository::AuthRepository,
use_case::get_auth_url_use_case::AuthUrl, use_case::get_auth_url_use_case::AuthUrl,
@ -12,12 +14,19 @@ use crate::{
}; };
pub struct AuthRepositoryImpl { pub struct AuthRepositoryImpl {
user_db_service: Arc<dyn UserDbService>,
auth_oidc_service: Arc<dyn AuthOidcService>, auth_oidc_service: Arc<dyn AuthOidcService>,
} }
impl AuthRepositoryImpl { impl AuthRepositoryImpl {
pub fn new(auth_oidc_service: Arc<dyn AuthOidcService>) -> Self { pub fn new(
Self { auth_oidc_service } user_db_service: Arc<dyn UserDbService>,
auth_oidc_service: Arc<dyn AuthOidcService>,
) -> Self {
Self {
user_db_service,
auth_oidc_service,
}
} }
} }
@ -37,4 +46,21 @@ impl AuthRepository for AuthRepositoryImpl {
.await .await
.map(|dto| dto.into_entity()) .map(|dto| dto.into_entity())
} }
async fn get_user_by_source_id(
&self,
issuer: &str,
source_id: &str,
) -> Result<User, AuthError> {
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<i32, AuthError> {
self.user_db_service
.create_user(UserMapper::from(user))
.await
}
} }

View File

@ -2,6 +2,7 @@ use crate::domain::entity::user::User;
pub struct OidcClaimsResponseDto { pub struct OidcClaimsResponseDto {
pub sub: String, pub sub: String,
pub issuer: String,
pub preferred_username: Option<String>, pub preferred_username: Option<String>,
pub email: Option<String>, pub email: Option<String>,
} }
@ -9,6 +10,8 @@ pub struct OidcClaimsResponseDto {
impl OidcClaimsResponseDto { impl OidcClaimsResponseDto {
pub fn into_entity(self) -> User { pub fn into_entity(self) -> User {
User { User {
id: -1,
issuer: self.issuer,
source_id: self.sub, source_id: self.sub,
displayed_name: self.preferred_username.unwrap_or_default(), displayed_name: self.preferred_username.unwrap_or_default(),
email: self.email.unwrap_or_default(), email: self.email.unwrap_or_default(),

View File

@ -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<UserMapper, AuthError>;
async fn create_user(&self, user: UserMapper) -> Result<i32, AuthError>;
}

View File

@ -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<User> 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,
}
}
}

View File

@ -1,8 +1,10 @@
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq)]
pub enum AuthError { pub enum AuthError {
DatabaseError(String),
OidcError(String), OidcError(String),
InvalidState, InvalidState,
InvalidNonce, InvalidNonce,
InvalidAuthCode, InvalidAuthCode,
InvalidIdToken, InvalidIdToken,
UserNotFound,
} }

View File

@ -8,6 +8,12 @@ use crate::{
#[async_trait] #[async_trait]
pub trait AuthRepository: Send + Sync { pub trait AuthRepository: Send + Sync {
fn get_auth_url(&self) -> Result<AuthUrl, AuthError>; fn get_auth_url(&self) -> Result<AuthUrl, AuthError>;
async fn exchange_auth_code(&self, code: &str, expected_nonce: &str) async fn exchange_auth_code(&self, code: &str, expected_nonce: &str)
-> Result<User, AuthError>; -> Result<User, AuthError>;
async fn get_user_by_source_id(&self, issuer: &str, source_id: &str)
-> Result<User, AuthError>;
async fn save_user(&self, user: User) -> Result<i32, AuthError>;
} }

View File

@ -41,8 +41,24 @@ impl ExchangeAuthCodeUseCase for ExchangeAuthCodeUseCaseImpl {
return Err(AuthError::InvalidState); return Err(AuthError::InvalidState);
} }
self.auth_repository let mut logged_in_user = self
.auth_repository
.exchange_auth_code(code, expected_nonce) .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)
} }
} }

View File

@ -1,4 +1,7 @@
#[derive(Clone)]
pub struct User { pub struct User {
pub id: i32,
pub issuer: String,
pub source_id: String, pub source_id: String,
pub displayed_name: String, pub displayed_name: String,
pub email: String, pub email: String,

View File

@ -1,2 +1,3 @@
pub mod db;
pub mod oidc; pub mod oidc;
pub mod web; pub mod web;

View File

@ -0,0 +1,2 @@
pub mod user_db_service_impl;
pub mod user_record;

View File

@ -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<Postgres>,
}
impl UserDbServiceImpl {
pub fn new(db_pool: Pool<Postgres>) -> Self {
Self { db_pool }
}
}
#[async_trait]
impl UserDbService for UserDbServiceImpl {
async fn get_user_by_source_id(
&self,
issuer: &str,
source_id: &str,
) -> Result<UserMapper, AuthError> {
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<i32, AuthError> {
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)
}
}

View File

@ -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,
}
}
}

View File

@ -93,6 +93,8 @@ impl AuthOidcService for AuthOidcServiceImpl {
) )
.map_err(|_| AuthError::InvalidIdToken)?; .map_err(|_| AuthError::InvalidIdToken)?;
let issuer = claims.issuer().to_string();
let preferred_username = claims let preferred_username = claims
.preferred_username() .preferred_username()
.map(|username| username.to_string()); .map(|username| username.to_string());
@ -101,6 +103,7 @@ impl AuthOidcService for AuthOidcServiceImpl {
Ok(OidcClaimsResponseDto { Ok(OidcClaimsResponseDto {
sub: claims.subject().to_string(), sub: claims.subject().to_string(),
issuer: issuer,
preferred_username: preferred_username, preferred_username: preferred_username,
email: email, email: email,
}) })

View File

@ -1 +1,3 @@
pub mod auth_web_routes; pub mod auth_web_routes;
mod constants;

View File

@ -6,12 +6,11 @@ use crate::{
auth_controller::AuthController, oidc_callback_query_dto::OidcCallbackQueryDto, auth_controller::AuthController, oidc_callback_query_dto::OidcCallbackQueryDto,
}, },
application::error::auth_error::AuthError, 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) { pub fn configure_auth_routes(cfg: &mut web::ServiceConfig) {
cfg.service( cfg.service(
web::scope("/auth") web::scope("/auth")
@ -29,11 +28,11 @@ async fn oidc_login_handler(
match result { match result {
Ok(auth_url) => { Ok(auth_url) => {
if let Err(e) = session.insert(SESSION_KEY_AUTH_STATE, auth_url.state) { if let Err(e) = session.insert::<String>(SESSION_KEY_AUTH_STATE, auth_url.state) {
log::error!("{e:?}"); log::error!("{e:?}");
return HttpResponse::InternalServerError().finish(); return HttpResponse::InternalServerError().finish();
} }
if let Err(e) = session.insert(SESSION_KEY_AUTH_NONCE, auth_url.nonce) { if let Err(e) = session.insert::<String>(SESSION_KEY_AUTH_NONCE, auth_url.nonce) {
log::error!("{e:?}"); log::error!("{e:?}");
return HttpResponse::InternalServerError().finish(); return HttpResponse::InternalServerError().finish();
} }
@ -53,12 +52,12 @@ async fn oidc_callback_handler(
query: web::Query<OidcCallbackQueryDto>, query: web::Query<OidcCallbackQueryDto>,
session: Session, session: Session,
) -> impl Responder { ) -> impl Responder {
let expected_state: String = match session.get(SESSION_KEY_AUTH_STATE) { let expected_state = match session.get::<String>(SESSION_KEY_AUTH_STATE) {
Ok(Some(state)) => state, Ok(Some(state)) => state,
_ => return HttpResponse::BadRequest().finish(), _ => return HttpResponse::BadRequest().finish(),
}; };
let expected_nonce: String = match session.get(SESSION_KEY_AUTH_NONCE) { let expected_nonce = match session.get::<String>(SESSION_KEY_AUTH_NONCE) {
Ok(Some(nonce)) => nonce, Ok(Some(nonce)) => nonce,
_ => return HttpResponse::BadRequest().finish(), _ => return HttpResponse::BadRequest().finish(),
}; };
@ -71,7 +70,7 @@ async fn oidc_callback_handler(
session.remove(SESSION_KEY_AUTH_NONCE); session.remove(SESSION_KEY_AUTH_NONCE);
match result { match result {
Ok(user) => { Ok(user) => {
if let Err(e) = session.insert(SESSION_KEY_USER, user) { if let Err(e) = session.insert::<i32>(SESSION_KEY_USER_ID, user.id) {
log::error!("{e:?}"); log::error!("{e:?}");
return HttpResponse::InternalServerError().finish(); return HttpResponse::InternalServerError().finish();
} }
@ -95,7 +94,7 @@ async fn oidc_callback_handler(
async fn logout_handler(session: Session) -> impl Responder { async fn logout_handler(session: Session) -> impl Responder {
session.remove(SESSION_KEY_AUTH_STATE); session.remove(SESSION_KEY_AUTH_STATE);
session.remove(SESSION_KEY_AUTH_NONCE); session.remove(SESSION_KEY_AUTH_NONCE);
session.remove(SESSION_KEY_USER); session.remove(SESSION_KEY_USER_ID);
HttpResponse::Found() HttpResponse::Found()
.append_header((header::LOCATION, "/")) .append_header((header::LOCATION, "/"))
.finish() .finish()

View File

@ -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";

View File

@ -6,7 +6,25 @@ CREATE TABLE "image" (
"updated_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP "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" CREATE TRIGGER "update_image_updated_time"
BEFORE UPDATE ON "image" BEFORE UPDATE ON "image"
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION update_updated_time_column(); 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();

View File

@ -9,7 +9,10 @@ use auth::{
exchange_auth_code_use_case::ExchangeAuthCodeUseCaseImpl, exchange_auth_code_use_case::ExchangeAuthCodeUseCaseImpl,
get_auth_url_use_case::LoginUseCaseImpl, 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::{ use image::{
adapter::{ adapter::{
@ -60,7 +63,8 @@ impl Container {
oidc_configuration.redirect_url.clone(), oidc_configuration.redirect_url.clone(),
http_client, 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 get_auth_url_use_case = Arc::new(LoginUseCaseImpl::new(auth_repository.clone()));
let exchange_auth_code_use_case = let exchange_auth_code_use_case =
Arc::new(ExchangeAuthCodeUseCaseImpl::new(auth_repository.clone())); Arc::new(ExchangeAuthCodeUseCaseImpl::new(auth_repository.clone()));