diff --git a/.sqlx/query-23697d21dc4aa7a71027ac5671d415f3ead15394a18414911475fb946a00dd01.json b/.sqlx/query-d37119628f436d151a5559e692a3d6a7a6bc3c8226631ce6a0bcd0c7f1c0a8c5.json similarity index 84% rename from .sqlx/query-23697d21dc4aa7a71027ac5671d415f3ead15394a18414911475fb946a00dd01.json rename to .sqlx/query-d37119628f436d151a5559e692a3d6a7a6bc3c8226631ce6a0bcd0c7f1c0a8c5.json index a7b32c45..e0af2d96 100644 --- a/.sqlx/query-23697d21dc4aa7a71027ac5671d415f3ead15394a18414911475fb946a00dd01.json +++ b/.sqlx/query-d37119628f436d151a5559e692a3d6a7a6bc3c8226631ce6a0bcd0c7f1c0a8c5.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT * FROM public.af_workspace WHERE owner_uid = $1\n ", + "query": "\n SELECT * FROM public.af_workspace WHERE owner_uid = (\n SELECT uid FROM public.af_user WHERE uuid = $1\n )\n ", "describe": { "columns": [ { @@ -41,7 +41,7 @@ ], "parameters": { "Left": [ - "Int8" + "Uuid" ] }, "nullable": [ @@ -54,5 +54,5 @@ true ] }, - "hash": "23697d21dc4aa7a71027ac5671d415f3ead15394a18414911475fb946a00dd01" + "hash": "d37119628f436d151a5559e692a3d6a7a6bc3c8226631ce6a0bcd0c7f1c0a8c5" } diff --git a/Cargo.lock b/Cargo.lock index e2b60ec3..a49fdcb3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -161,7 +161,7 @@ dependencies = [ "futures-core", "futures-util", "mio", - "socket2 0.5.3", + "socket2 0.5.4", "tokio", "tracing", ] @@ -263,7 +263,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "smallvec", - "socket2 0.5.3", + "socket2 0.5.4", "time", "url", ] @@ -477,6 +477,7 @@ dependencies = [ "tracing-log", "tracing-subscriber", "unicode-segmentation", + "uuid", "validator", ] @@ -824,6 +825,7 @@ dependencies = [ "reqwest", "serde_json", "shared_entity", + "storage", ] [[package]] @@ -3105,9 +3107,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2538b18701741680e0322a2302176d3253a35388e2e62f172f64f4f16605f877" +checksum = "4031e820eb552adee9295814c0ced9e5cf38ddf1e8b7d566d6de8e2538ea989e" dependencies = [ "libc", "windows-sys", @@ -3371,6 +3373,7 @@ dependencies = [ "thiserror", "tokio", "tracing", + "uuid", "validator", ] @@ -3537,7 +3540,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.5.3", + "socket2 0.5.4", "tokio-macros", "windows-sys", ] @@ -3856,6 +3859,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79daa5ed5740825c40b389c5e50312b9c86df53fccd33f281df655642b43869d" dependencies = [ "getrandom 0.2.10", + "serde", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 38c77cfa..6c8e471c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,7 @@ tracing-bunyan-formatter = "0.3.6" tracing-actix-web = "0.7" tracing-log = "0.1.1" sqlx = { version = "0.7", default-features = false, features = ["runtime-tokio-rustls", "macros", "postgres", "uuid", "chrono", "migrate"] } +uuid = { version= "1.4.1", features = ["serde", "v4"] } #Local crate token = { path = "libs/token" } diff --git a/libs/client-api/Cargo.toml b/libs/client-api/Cargo.toml index c68f3e2e..8cd57597 100644 --- a/libs/client-api/Cargo.toml +++ b/libs/client-api/Cargo.toml @@ -12,3 +12,4 @@ gotrue = { path = "../gotrue" } infra = { path = "../infra" } serde_json = "1.0.105" shared_entity = { path = "../shared-entity" } +storage = { path = "../storage" } diff --git a/libs/client-api/src/http.rs b/libs/client-api/src/http.rs index e0902c01..3d39d2df 100644 --- a/libs/client-api/src/http.rs +++ b/libs/client-api/src/http.rs @@ -8,6 +8,9 @@ use gotrue::models::{AccessTokenResponse, User}; use shared_entity::error::AppError; use shared_entity::server_error::ErrorCode; +use storage::entities::AfUserProfileView; +use storage::entities::AfWorkspace; +use storage::entities::AfWorkspaces; pub struct Client { http_client: reqwest::Client, @@ -28,6 +31,32 @@ impl Client { self.token.as_ref() } + pub async fn profile(&self) -> Result { + let url = format!("{}/api/user/profile", self.base_url); + let resp = self + .http_client_with_auth(Method::GET, &url)? + .send() + .await?; + let profile = AppResponse::::from_response(resp) + .await? + .into_data()? + .ok_or::(ErrorCode::MissingPayload.into())?; + Ok(profile) + } + + pub async fn workspaces(&mut self) -> Result { + let url = format!("{}/api/user/workspaces", self.base_url); + let resp = self + .http_client_with_auth(Method::GET, &url)? + .send() + .await?; + let workspaces = AppResponse::>::from_response(resp) + .await? + .into_data()? + .ok_or::(ErrorCode::MissingPayload.into())?; + Ok(AfWorkspaces(workspaces)) + } + pub async fn sign_in_password(&mut self, email: &str, password: &str) -> Result<(), AppError> { let url = format!("{}/api/user/sign_in/password", self.base_url); let payload = serde_json::json!({ @@ -72,7 +101,7 @@ impl Client { let new_user = AppResponse::::from_response(resp) .await? .into_data()? - .ok_or::(ErrorCode::Unhandled.into())?; + .ok_or::(ErrorCode::Unhandled.into())?; if let Some(t) = self.token.as_mut() { t.user = new_user; } diff --git a/libs/shared-entity/src/server_error.rs b/libs/shared-entity/src/server_error.rs index c8a86237..3371912d 100644 --- a/libs/shared-entity/src/server_error.rs +++ b/libs/shared-entity/src/server_error.rs @@ -24,6 +24,9 @@ pub enum ErrorCode { #[error("OAuth authentication error.")] OAuthError = 1003, + + #[error("Missing Payload")] + MissingPayload = 1004, } /// Implements conversion from `anyhow::Error` to `ErrorCode`. diff --git a/libs/storage/Cargo.toml b/libs/storage/Cargo.toml index 629c969b..8e7fb242 100644 --- a/libs/storage/Cargo.toml +++ b/libs/storage/Cargo.toml @@ -16,9 +16,11 @@ serde = { version = "1.0.130", features = ["derive"] } serde_json = "1.0.68" thiserror = "1.0.47" secrecy = { version = "0.8", features = ["serde"] } + sqlx = { version = "0.7", default-features = false, features = ["postgres", "chrono", "uuid", "macros", "runtime-tokio-rustls"] } tracing = { version = "0.1.37" } validator = { version = "0.16", features = ["validator_derive", "derive"] } +uuid = { version = "1.4.1", features = ["serde", "v4"] } [features] -default = [] \ No newline at end of file +default = [] diff --git a/libs/storage/src/entities.rs b/libs/storage/src/entities.rs index fa01443b..692458c0 100644 --- a/libs/storage/src/entities.rs +++ b/libs/storage/src/entities.rs @@ -53,7 +53,7 @@ pub struct QueryCollabParams { pub collab_type: CollabType, } -#[derive(Debug, Clone, sqlx::FromRow)] +#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize)] pub struct AfWorkspace { pub workspace_id: uuid::Uuid, pub database_storage_id: Option, @@ -64,21 +64,23 @@ pub struct AfWorkspace { pub workspace_name: Option, } -#[derive(Debug, sqlx::FromRow)] +#[derive(Debug, sqlx::FromRow, Deserialize, Serialize)] pub struct AfUserProfileView { - pub uid: Option, // Made this field nullable based on the error - pub uuid: Option, // Made this field nullable based on the error - pub email: Option, // Made this field nullable based on the error - pub password: Option, // Made this field nullable based on the error - pub name: Option, // Made this field nullable based on the error - pub encryption_sign: Option, // Made this field nullable based on the error + pub uid: Option, + pub uuid: Option, + pub email: Option, + pub password: Option, + pub name: Option, + pub encryption_sign: Option, pub deleted_at: Option>, pub updated_at: Option>, pub created_at: Option>, pub latest_workspace_id: Option, } +#[derive(Debug, sqlx::FromRow, Deserialize, Serialize)] pub struct AfWorkspaces(pub Vec); + impl AfWorkspaces { pub fn get_latest(&self, profile: AfUserProfileView) -> Option { match profile.latest_workspace_id { diff --git a/libs/storage/src/workspace.rs b/libs/storage/src/workspace.rs index 58990d13..17b38f59 100644 --- a/libs/storage/src/workspace.rs +++ b/libs/storage/src/workspace.rs @@ -26,14 +26,16 @@ pub async fn create_user_if_not_exists( pub async fn select_all_workspaces_owned( pool: &PgPool, - owner_uid: i64, + owner_uuid: &Uuid, ) -> Result, sqlx::Error> { sqlx::query_as!( AfWorkspace, r#" - SELECT * FROM public.af_workspace WHERE owner_uid = $1 + SELECT * FROM public.af_workspace WHERE owner_uid = ( + SELECT uid FROM public.af_user WHERE uuid = $1 + ) "#, - owner_uid + owner_uuid ) .fetch_all(pool) .await @@ -41,7 +43,7 @@ pub async fn select_all_workspaces_owned( pub async fn select_user_profile_view_by_uuid( pool: &PgPool, - gotrue_uuid: Uuid, + user_uuid: &Uuid, ) -> Result, sqlx::Error> { sqlx::query_as!( AfUserProfileView, @@ -49,7 +51,7 @@ pub async fn select_user_profile_view_by_uuid( SELECT * FROM public.af_user_profile_view WHERE uuid = $1 "#, - gotrue_uuid + user_uuid ) .fetch_optional(pool) .await diff --git a/src/api/user.rs b/src/api/user.rs index 356d012a..5039751d 100644 --- a/src/api/user.rs +++ b/src/api/user.rs @@ -9,8 +9,9 @@ use crate::domain::{UserEmail, UserName, UserPassword}; use crate::state::State; use gotrue::models::{AccessTokenResponse, User}; use shared_entity::data::{AppResponse, JsonAppResponse}; +use storage::entities::{AfUserProfileView, AfWorkspaces}; -use crate::component::auth::jwt::Authorization; +use crate::component::auth::jwt::{Authorization, UserUuid}; use actix_web::web::{Data, Json}; use actix_web::HttpRequest; use actix_web::Result; @@ -25,6 +26,9 @@ pub fn user_scope() -> Scope { .service(web::resource("/sign_out").route(web::post().to(sign_out_handler))) .service(web::resource("/update").route(web::post().to(update_handler))) + .service(web::resource("/workspaces").route(web::get().to(workspaces_handler))) + .service(web::resource("/profile").route(web::get().to(profile_handler))) + // native .service(web::resource("/login").route(web::post().to(login_handler))) .service(web::resource("/logout").route(web::get().to(logout_handler))) @@ -32,6 +36,22 @@ pub fn user_scope() -> Scope { .service(web::resource("/password").route(web::post().to(change_password_handler))) } +async fn profile_handler( + uuid: UserUuid, + state: Data, +) -> Result> { + let profile = biz::user::user_profile(&state.pg_pool, &uuid.0).await?; + Ok(AppResponse::Ok().with_data(profile).into()) +} + +async fn workspaces_handler( + uuid: UserUuid, + state: Data, +) -> Result> { + let workspaces = biz::user::user_workspaces(&state.pg_pool, &uuid.0).await?; + Ok(AppResponse::Ok().with_data(workspaces).into()) +} + async fn update_handler( auth: Authorization, req: Json, @@ -71,7 +91,13 @@ async fn sign_up_handler( req: Json, state: Data, ) -> Result> { - biz::user::sign_up(&state.gotrue_client, &req.email, &req.password).await?; + biz::user::sign_up( + &state.pg_pool, + &state.gotrue_client, + &req.email, + &req.password, + ) + .await?; Ok(AppResponse::Ok().into()) } diff --git a/src/biz/user.rs b/src/biz/user.rs index c1f8ec37..20c4c612 100644 --- a/src/biz/user.rs +++ b/src/biz/user.rs @@ -8,15 +8,41 @@ use gotrue::{ }; use shared_entity::{error::AppError, server_error}; +use storage::entities::{AfUserProfileView, AfWorkspaces}; use validator::validate_email; use crate::domain::validate_password; use sqlx::{types::uuid, PgPool}; -pub async fn sign_up(gotrue_client: &Client, email: &str, password: &str) -> Result<(), AppError> { +pub async fn user_workspaces( + pg_pool: &PgPool, + uuid: &uuid::Uuid, +) -> Result { + let workspaces = storage::workspace::select_all_workspaces_owned(pg_pool, uuid).await?; + Ok(AfWorkspaces(workspaces)) +} + +pub async fn user_profile( + pg_pool: &PgPool, + uuid: &uuid::Uuid, +) -> Result { + let profile = storage::workspace::select_user_profile_view_by_uuid(pg_pool, uuid) + .await? + .ok_or(sqlx::Error::RowNotFound)?; + Ok(profile) +} + +pub async fn sign_up( + pg_pool: &PgPool, + gotrue_client: &Client, + email: &str, + password: &str, +) -> Result<(), AppError> { validate_email_password(email, password)?; let user = gotrue_client.sign_up(email, password).await??; - tracing::info!("user sign up: {:?}", user); + if user.confirmed_at.is_some() { + storage::workspace::create_user_if_not_exists(pg_pool, uuid::Uuid::from_str(&user.id)?).await?; + } Ok(()) } diff --git a/src/component/auth/jwt.rs b/src/component/auth/jwt.rs index f697d188..25b2e291 100644 --- a/src/component/auth/jwt.rs +++ b/src/component/auth/jwt.rs @@ -10,80 +10,119 @@ lazy_static::lazy_static! { pub static ref VALIDATION: Validation = Validation::new(Algorithm::HS256); } +#[derive(Debug, Serialize, Deserialize)] +pub struct UserUuid(pub uuid::Uuid); + +impl FromRequest for UserUuid { + type Error = actix_web::Error; + + type Future = std::future::Ready>; + + fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future { + let auth = get_auth_from_request(req); + match auth { + Ok(auth) => { + let sub = auth.claims.sub.ok_or(actix_web::error::ErrorUnauthorized( + "Invalid Authorization header, missing sub(uuid)", + )); + match sub { + Ok(sub) => std::future::ready(Ok(UserUuid(uuid::Uuid::parse_str(&sub).unwrap()))), + Err(e) => std::future::ready(Err(e)), + } + }, + Err(e) => std::future::ready(Err(e)), + } + } +} + #[derive(Debug, Serialize, Deserialize)] pub struct Authorization { pub token: String, pub claims: GoTrueJWTClaims, } +impl Authorization { + pub fn uuid(&self) -> Option { + self.claims.sub.clone() + } +} + impl FromRequest for Authorization { type Error = actix_web::Error; type Future = std::future::Ready>; fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future { - let state = req.app_data::>().unwrap(); - let bearer = req.headers().get("Authorization"); - match bearer { - None => std::future::ready(Err(actix_web::error::ErrorUnauthorized( - "No Authorization header", - ))), - Some(bearer) => { - let bearer = bearer.to_str(); - match bearer { - Err(e) => std::future::ready(Err(actix_web::error::ErrorUnauthorized(e))), - Ok(bearer) => { - let pair_opt = bearer.split_once("Bearer "); // Authorization: Bearer - match pair_opt { - None => std::future::ready(Err(actix_web::error::ErrorUnauthorized( - "Invalid Authorization header, missing Bearer", - ))), - Some(pair) => { - match GoTrueJWTClaims::verify( - pair.1, - state.config.gotrue.jwt_secret.expose_secret().as_bytes(), - ) { - Err(e) => std::future::ready(Err(actix_web::error::ErrorUnauthorized(e))), - Ok(t) => std::future::ready(Ok(Authorization { - token: pair.1.to_string(), - claims: t, - })), - } - }, - } - }, - } - }, + let auth = get_auth_from_request(req); + match auth { + Ok(auth) => std::future::ready(Ok(auth)), + Err(e) => std::future::ready(Err(e)), } } } +fn get_auth_from_request(req: &HttpRequest) -> Result { + let state = req.app_data::>().unwrap(); + let bearer = req.headers().get("Authorization"); + match bearer { + None => Err(actix_web::error::ErrorUnauthorized( + "No Authorization header", + )), + Some(bearer) => { + let bearer = bearer.to_str(); + match bearer { + Err(e) => Err(actix_web::error::ErrorUnauthorized(e)), + Ok(bearer) => { + let pair_opt = bearer.split_once("Bearer "); // Authorization: Bearer + match pair_opt { + None => Err(actix_web::error::ErrorUnauthorized( + "Invalid Authorization header, missing Bearer", + )), + Some(pair) => { + match GoTrueJWTClaims::verify( + pair.1, + state.config.gotrue.jwt_secret.expose_secret().as_bytes(), + ) { + Err(e) => Err(actix_web::error::ErrorUnauthorized(e)), + Ok(t) => Ok(Authorization { + token: pair.1.to_string(), + claims: t, + }), + } + }, + } + }, + } + }, + } +} + #[derive(Debug, Serialize, Deserialize)] pub struct GoTrueJWTClaims { // JWT standard claims - aud: Option, - exp: Option, - jti: Option, - iat: Option, - iss: Option, - nbf: Option, - sub: Option, + pub aud: Option, + pub exp: Option, + pub jti: Option, + pub iat: Option, + pub iss: Option, + pub nbf: Option, + pub sub: Option, - email: String, - phone: String, - app_metadata: serde_json::Value, - user_metadata: serde_json::Value, - role: String, - aal: Option, - amr: Option>, - session_id: Option, + pub email: String, + pub phone: String, + pub app_metadata: serde_json::Value, + pub user_metadata: serde_json::Value, + pub role: String, + pub aal: Option, + pub amr: Option>, + pub session_id: Option, } #[derive(Debug, Serialize, Deserialize)] -struct Amr { - method: String, - timestamp: u64, - provider: Option, +pub struct Amr { + pub method: String, + pub timestamp: u64, + pub provider: Option, } impl GoTrueJWTClaims { diff --git a/tests/client/sign_in.rs b/tests/client/sign_in.rs index d8fca88a..0d457ff6 100644 --- a/tests/client/sign_in.rs +++ b/tests/client/sign_in.rs @@ -58,5 +58,9 @@ async fn sign_in_success() { let token = c.token().unwrap(); assert!(token.user.confirmed_at.is_some()); - // TODO: check that workspace is created for user + let workspaces = c.workspaces().await.unwrap(); + assert!(!workspaces.0.is_empty()); + let profile = c.profile().await.unwrap(); + let latest_workspace = workspaces.get_latest(profile); + assert!(latest_workspace.is_some()); }