Compare commits

...

3 Commits

Author SHA1 Message Date
0d6810f3d5 NO-ISSUE Remove .sqlx from gitignore (#99)
All checks were successful
Frontend CI / build (push) Successful in 1m9s
### Description

- `.sqlx` should be check into version control system because it is required when rust compiling if there is no available online sql server.

### Package Changes

_No response_

### Screenshots

_No response_

### Reference

__NO_ISSUE__

### Checklist

- [x] A milestone is set
- [ ] The related issuse has been linked to this branch

Reviewed-on: #99
Co-authored-by: SquidSpirit <squid@squidspirit.com>
Co-committed-by: SquidSpirit <squid@squidspirit.com>
2025-08-01 15:17:41 +08:00
c6661f3222 BLOG-95 Seperate SQL migration files (#98)
All checks were successful
Frontend CI / build (push) Successful in 1m10s
### Description

- In beta environment, `v0.3.0` migration has been run, a manual revertion is required; in real environment, there is nothing to do, but to do #97 and remove migration record for `v0.1.1` manually.

### Package Changes

_No response_

### Screenshots

_No response_

### Reference

Resolves #95

### Checklist

- [x] A milestone is set
- [x] The related issuse has been linked to this branch

Reviewed-on: #98
Co-authored-by: SquidSpirit <squid@squidspirit.com>
Co-committed-by: SquidSpirit <squid@squidspirit.com>
2025-08-01 15:02:29 +08:00
9c88b4bb55 BLOG-94 Create user in DB when first login through OIDC (#96)
All checks were successful
Frontend CI / build (push) Successful in 1m8s
### Description

This PR introduces the functionality to persist user information in the database. When a user logs in via OIDC for the first time, a new user record is created. Subsequent logins will retrieve the existing user data from the database.

This change ensures that users have a persistent identity within our system, identified by their unique combination of OIDC issuer and subject ID.

#### Key Changes

* **User Persistence Logic**:
    * In `ExchangeAuthCodeUseCase`, after successfully exchanging the authorization code, the logic now checks if the user exists in our database using their `issuer` and `source_id`.
    * If the user is not found (`AuthError::UserNotFound`), a new record is created in the `user` table.
    * The `User` entity returned by the use case now contains the internal database `id`.

* **Database Integration in Auth Feature**:
    * Introduced a new `UserDbService` trait and its `sqlx`-based implementation, `UserDbServiceImpl`, to handle database operations for users.
    * The `AuthRepository` is extended to include methods for querying (`get_user_by_source_id`) and saving (`save_user`) users, delegating the calls to the new `UserDbService`.
    * The dependency injection container in `server/src/container.rs` has been updated to provide the `UserDbServiceImpl` to the `AuthRepositoryImpl`.

* **Domain and Data Model Updates**:
    * The `User` domain entity now includes `id` (the database primary key) and `issuer` (from OIDC claims) to uniquely identify a user across different identity providers.
    * The `UserResponseDto` now exposes the internal `id` instead of the `source_id`.

* **Session Management**:
    * The user's session now stores the database `user_id` (`i32`) instead of the entire user object. This is more efficient and secure.
    * Session keys have been centralized into a `constants.rs` file for better maintainability.

#### Database Changes

* A new database migration has been added to create the `user` table.
* The table includes columns for `id`, `issuer`, `source_id`, `displayed_name`, and `email`.
* A **`UNIQUE` index** has been created on `(source_id, issuer)` to guarantee that each user from a specific identity provider is stored only once.

#### Refactoring

* Minor refactoring in the `image` feature to change `id: Option<i32>` to `id: i32` for consistency with the new `User` entity model.

### Package Changes

_No response_

### Screenshots

_No response_

### Reference

Resolves #94

### Checklist

- [x] A milestone is set
- [x] The related issuse has been linked to this branch

Reviewed-on: #96
Co-authored-by: SquidSpirit <squid@squidspirit.com>
Co-committed-by: SquidSpirit <squid@squidspirit.com>
2025-08-01 13:24:08 +08:00
36 changed files with 428 additions and 27 deletions

1
backend/.gitignore vendored
View File

@ -1,3 +1,2 @@
.env
/.sqlx
/target

View File

@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, mime_type\n FROM image\n WHERE id = $1 AND deleted_time IS NULL\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "mime_type",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Int4"
]
},
"nullable": [
false,
false
]
},
"hash": "1926140fd0232511d302cd514f41af1e619a1c68b94e18cdc53234c9de701390"
}

View File

@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO image (mime_type)\n VALUES ($1)\n RETURNING id\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Varchar"
]
},
"nullable": [
false
]
},
"hash": "715922e4ffa6881f23ea890ebf77abd86937c3f4fe606572156a29d4441028e9"
}

View File

@ -0,0 +1,47 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, issuer, source_id, displayed_name, email\n FROM \"user\"\n WHERE issuer = $1 AND source_id = $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "issuer",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "source_id",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "displayed_name",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "email",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "9c3ae9a539390e3b0493d325439fdc73cb2925bdb17330a21db4341e5822291b"
}

View File

@ -0,0 +1,25 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO \"user\" (issuer, source_id, displayed_name, email)\n VALUES ($1, $2, $3, $4)\n RETURNING id\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Varchar"
]
},
"nullable": [
false
]
},
"hash": "e9741186ea464ef1ba3598223ad9f042ec876cbdc4e2d9eca787ff1de598551c"
}

1
backend/Cargo.lock generated
View File

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

View File

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

View File

@ -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<User> for UserResponseDto {
fn from(user: User) -> Self {
UserResponseDto {
source_id: user.source_id,
id: user.id,
displayed_name: user.displayed_name,
email: user.email,
}

View File

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

View File

@ -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_db_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<dyn UserDbService>,
auth_oidc_service: Arc<dyn AuthOidcService>,
}
impl AuthRepositoryImpl {
pub fn new(auth_oidc_service: Arc<dyn AuthOidcService>) -> Self {
Self { auth_oidc_service }
pub fn new(
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
.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 sub: String,
pub issuer: String,
pub preferred_username: Option<String>,
pub email: Option<String>,
}
@ -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(),

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

@ -0,0 +1,14 @@
use async_trait::async_trait;
use crate::{adapter::gateway::user_db_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

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

View File

@ -8,6 +8,12 @@ use crate::{
#[async_trait]
pub trait AuthRepository: Send + Sync {
fn get_auth_url(&self) -> Result<AuthUrl, AuthError>;
async fn exchange_auth_code(&self, code: &str, expected_nonce: &str)
-> 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,32 @@ 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_result = self
.auth_repository
.get_user_by_source_id(&logged_in_user.issuer, &logged_in_user.source_id)
.await;
match saved_user_result {
Ok(user) => {
logged_in_user.id = user.id;
}
Err(AuthError::UserNotFound) => {
let id = self
.auth_repository
.save_user(logged_in_user.clone())
.await?;
logged_in_user.id = id;
}
Err(e) => {
return Err(e);
}
};
Ok(logged_in_user)
}
}

View File

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

View File

@ -1,2 +1,3 @@
pub mod db;
pub mod oidc;
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_db_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_db_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)?;
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,
})

View File

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

View File

@ -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::<String>(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::<String>(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<OidcCallbackQueryDto>,
session: Session,
) -> 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,
_ => 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,
_ => 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::<i32>(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()

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

@ -8,7 +8,7 @@ pub struct ImageRequestDto {
impl ImageRequestDto {
pub fn into_entity(self) -> Image {
Image {
id: None,
id: -1,
mime_type: self.mime_type,
data: self.data,
}

View File

@ -1,7 +1,7 @@
use crate::domain::entity::image::Image;
pub struct ImageDbMapper {
pub id: Option<i32>,
pub id: i32,
pub mime_type: String,
}

View File

@ -1,5 +1,5 @@
pub struct Image {
pub id: Option<i32>,
pub id: i32,
pub mime_type: String,
pub data: Vec<u8>,
}

View File

@ -54,7 +54,7 @@ impl ImageDbService for ImageDbServiceImpl {
match image_record {
Ok(record) => match record {
Some(record) => Ok(ImageDbMapper {
id: Some(record.id),
id: record.id,
mime_type: record.mime_type,
}),
None => Err(ImageError::NotFound),

View File

@ -0,0 +1,6 @@
CREATE OR REPLACE FUNCTION update_updated_time_column() RETURNS TRIGGER AS $$
BEGIN
NEW.updated_time = CURRENT_TIMESTAMP;
return NEW;
END;
$$ LANGUAGE 'plpgsql';

View File

@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS "post" (
"id" SERIAL PRIMARY KEY NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL,
"preview_image_url" TEXT NOT NULL,
"content" TEXT NOT NULL,
"published_time" TIMESTAMP,
"deleted_time" TIMESTAMP,
"created_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE OR REPLACE TRIGGER "update_post_updated_time"
BEFORE UPDATE ON "post"
FOR EACH ROW
EXECUTE FUNCTION update_updated_time_column();

View File

@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS "label" (
"id" SERIAL PRIMARY KEY NOT NULL,
"name" TEXT NOT NULL,
"color" BIGINT NOT NULL CHECK ("color" >= 0 AND "color" <= 4294967295),
"deleted_time" TIMESTAMP,
"created_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE OR REPLACE TRIGGER "update_label_updated_time"
BEFORE UPDATE ON "label"
FOR EACH ROW
EXECUTE FUNCTION update_updated_time_column();

View File

@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS "post_label" (
"post_id" INTEGER NOT NULL,
"label_id" INTEGER NOT NULL,
PRIMARY KEY ("post_id", "label_id"),
FOREIGN KEY ("post_id") REFERENCES "post" ("id") ON DELETE CASCADE,
FOREIGN KEY ("label_id") REFERENCES "label" ("id") ON DELETE CASCADE
);

View File

@ -1,4 +1,4 @@
CREATE TABLE "image" (
CREATE TABLE IF NOT EXISTS "image" (
"id" SERIAL PRIMARY KEY NOT NULL,
"mime_type" VARCHAR(100) NOT NULL,
"deleted_time" TIMESTAMP,
@ -6,7 +6,7 @@ CREATE TABLE "image" (
"updated_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TRIGGER "update_image_updated_time"
CREATE OR REPLACE TRIGGER "update_image_updated_time"
BEFORE UPDATE ON "image"
FOR EACH ROW
EXECUTE FUNCTION update_updated_time_column();

View File

@ -0,0 +1,20 @@
CREATE TABLE IF NOT EXISTS "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 IF NOT EXISTS "idx_user_source_id_issuer"
ON "user" ("source_id", "issuer");
CREATE INDEX IF NOT EXISTS "idx_user_email"
ON "user" ("email");
CREATE OR REPLACE 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,
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()));