From e26d87f4fa18999c6bcfbcf32cfa85adab11acdd Mon Sep 17 00:00:00 2001 From: rtkay123 Date: Sat, 26 Jul 2025 19:24:38 +0200 Subject: feat(auth): create user call --- Cargo.lock | 130 ++++++------ Cargo.toml | 1 + crates/auth-service/Cargo.toml | 42 ++++ crates/auth-service/auth.toml | 40 ++++ .../migrations/20250723100947_user.sql | 20 ++ .../migrations/20250723121223_oauth_account.sql | 7 + .../migrations/20250725160900_session.sql | 8 + .../migrations/20250725161014_token.sql | 9 + crates/auth-service/src/auth.rs | 12 ++ crates/auth-service/src/client.rs | 6 + crates/auth-service/src/client/discord.rs | 30 +++ crates/auth-service/src/cnfg.rs | 26 +++ crates/auth-service/src/error.rs | 26 +++ crates/auth-service/src/main.rs | 141 ++++++++++++ crates/auth-service/src/server.rs | 37 ++++ .../src/server/csrf_token_validation.rs | 40 ++++ crates/auth-service/src/server/grpc.rs | 2 + crates/auth-service/src/server/grpc/auth.rs | 50 +++++ crates/auth-service/src/server/grpc/interceptor.rs | 17 ++ crates/auth-service/src/server/routes.rs | 62 ++++++ .../auth-service/src/server/routes/authorised.rs | 236 +++++++++++++++++++++ crates/auth-service/src/server/routes/discord.rs | 10 + .../src/server/routes/discord/discord_auth.rs | 58 +++++ crates/auth-service/src/state.rs | 85 ++++++++ crates/auth/Cargo.toml | 42 ---- crates/auth/auth.toml | 34 --- crates/auth/migrations/20250723100947_user.sql | 20 -- .../migrations/20250723121223_oauth_account.sql | 7 - crates/auth/migrations/20250725160900_session.sql | 8 - crates/auth/migrations/20250725161014_token.sql | 9 - crates/auth/src/auth.rs | 12 -- crates/auth/src/client.rs | 6 - crates/auth/src/client/discord.rs | 30 --- crates/auth/src/cnfg.rs | 26 --- crates/auth/src/error.rs | 26 --- crates/auth/src/main.rs | 139 ------------ crates/auth/src/server.rs | 37 ---- crates/auth/src/server/csrf_token_validation.rs | 40 ---- crates/auth/src/server/grpc.rs | 2 - crates/auth/src/server/grpc/auth.rs | 50 ----- crates/auth/src/server/grpc/interceptor.rs | 17 -- crates/auth/src/server/routes.rs | 62 ------ crates/auth/src/server/routes/authorised.rs | 232 -------------------- crates/auth/src/server/routes/discord.rs | 10 - .../auth/src/server/routes/discord/discord_auth.rs | 58 ----- crates/auth/src/state.rs | 85 -------- crates/profile-service/Cargo.toml | 18 +- .../migrations/20250726161947_profile.sql | 32 +++ crates/profile-service/profile.toml | 37 ++++ crates/profile-service/src/cnfg.rs | 8 + crates/profile-service/src/main.rs | 111 +++++++++- crates/profile-service/src/server.rs | 2 + crates/profile-service/src/server/interceptor.rs | 11 + crates/profile-service/src/server/manager.rs | 45 ++++ crates/profile-service/src/state.rs | 42 ++++ crates/sellershut/src/server/activities/follow.rs | 2 +- crates/sellershut/src/server/routes.rs | 2 +- .../src/server/routes/users/followers.rs | 2 +- .../src/server/routes/users/get_outbox.rs | 2 +- .../sellershut/src/server/routes/users/get_user.rs | 2 +- .../src/server/routes/users/webfinger.rs | 2 +- lib/sellershut-core/build.rs | 4 +- lib/sellershut-core/proto/profile/profile.proto | 2 - lib/sellershut-core/src/lib.rs | 4 +- lib/sellershut-core/src/profile.rs | 3 +- 65 files changed, 1330 insertions(+), 1048 deletions(-) create mode 100644 crates/auth-service/Cargo.toml create mode 100644 crates/auth-service/auth.toml create mode 100644 crates/auth-service/migrations/20250723100947_user.sql create mode 100644 crates/auth-service/migrations/20250723121223_oauth_account.sql create mode 100644 crates/auth-service/migrations/20250725160900_session.sql create mode 100644 crates/auth-service/migrations/20250725161014_token.sql create mode 100644 crates/auth-service/src/auth.rs create mode 100644 crates/auth-service/src/client.rs create mode 100644 crates/auth-service/src/client/discord.rs create mode 100644 crates/auth-service/src/cnfg.rs create mode 100644 crates/auth-service/src/error.rs create mode 100644 crates/auth-service/src/main.rs create mode 100644 crates/auth-service/src/server.rs create mode 100644 crates/auth-service/src/server/csrf_token_validation.rs create mode 100644 crates/auth-service/src/server/grpc.rs create mode 100644 crates/auth-service/src/server/grpc/auth.rs create mode 100644 crates/auth-service/src/server/grpc/interceptor.rs create mode 100644 crates/auth-service/src/server/routes.rs create mode 100644 crates/auth-service/src/server/routes/authorised.rs create mode 100644 crates/auth-service/src/server/routes/discord.rs create mode 100644 crates/auth-service/src/server/routes/discord/discord_auth.rs create mode 100644 crates/auth-service/src/state.rs delete mode 100644 crates/auth/Cargo.toml delete mode 100644 crates/auth/auth.toml delete mode 100644 crates/auth/migrations/20250723100947_user.sql delete mode 100644 crates/auth/migrations/20250723121223_oauth_account.sql delete mode 100644 crates/auth/migrations/20250725160900_session.sql delete mode 100644 crates/auth/migrations/20250725161014_token.sql delete mode 100644 crates/auth/src/auth.rs delete mode 100644 crates/auth/src/client.rs delete mode 100644 crates/auth/src/client/discord.rs delete mode 100644 crates/auth/src/cnfg.rs delete mode 100644 crates/auth/src/error.rs delete mode 100644 crates/auth/src/main.rs delete mode 100644 crates/auth/src/server.rs delete mode 100644 crates/auth/src/server/csrf_token_validation.rs delete mode 100644 crates/auth/src/server/grpc.rs delete mode 100644 crates/auth/src/server/grpc/auth.rs delete mode 100644 crates/auth/src/server/grpc/interceptor.rs delete mode 100644 crates/auth/src/server/routes.rs delete mode 100644 crates/auth/src/server/routes/authorised.rs delete mode 100644 crates/auth/src/server/routes/discord.rs delete mode 100644 crates/auth/src/server/routes/discord/discord_auth.rs delete mode 100644 crates/auth/src/state.rs create mode 100644 crates/profile-service/migrations/20250726161947_profile.sql create mode 100644 crates/profile-service/profile.toml create mode 100644 crates/profile-service/src/cnfg.rs create mode 100644 crates/profile-service/src/server.rs create mode 100644 crates/profile-service/src/server/interceptor.rs create mode 100644 crates/profile-service/src/server/manager.rs create mode 100644 crates/profile-service/src/state.rs diff --git a/Cargo.lock b/Cargo.lock index 198988b..23a8702 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -196,41 +196,6 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" -[[package]] -name = "auth-service" -version = "0.1.0" -dependencies = [ - "anyhow", - "axum", - "axum-extra", - "base64", - "clap", - "config", - "futures-util", - "jsonwebtoken", - "nanoid", - "oauth2", - "reqwest", - "sellershut-core", - "serde", - "serde_json", - "sqlx", - "stack-up", - "time", - "tokio", - "tonic", - "tonic-reflection", - "tower", - "tower-http", - "tower-sessions", - "tower-sessions-core", - "tower-sessions-moka-store", - "tower-sessions-sqlx-store", - "tracing", - "url", - "uuid", -] - [[package]] name = "autocfg" version = "1.5.0" @@ -2105,41 +2070,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "profile-service" -version = "0.1.0" -dependencies = [ - "anyhow", - "axum", - "axum-extra", - "base64", - "clap", - "config", - "futures-util", - "jsonwebtoken", - "nanoid", - "oauth2", - "reqwest", - "sellershut-core", - "serde", - "serde_json", - "sqlx", - "stack-up", - "time", - "tokio", - "tonic", - "tonic-reflection", - "tower", - "tower-http", - "tower-sessions", - "tower-sessions-core", - "tower-sessions-moka-store", - "tower-sessions-sqlx-store", - "tracing", - "url", - "uuid", -] - [[package]] name = "prost" version = "0.13.5" @@ -2670,6 +2600,41 @@ dependencies = [ "uuid", ] +[[package]] +name = "sellershut-auth" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "axum-extra", + "base64", + "clap", + "config", + "futures-util", + "jsonwebtoken", + "nanoid", + "oauth2", + "reqwest", + "sellershut-core", + "serde", + "serde_json", + "sqlx", + "stack-up", + "time", + "tokio", + "tonic", + "tonic-reflection", + "tower", + "tower-http", + "tower-sessions", + "tower-sessions-core", + "tower-sessions-moka-store", + "tower-sessions-sqlx-store", + "tracing", + "url", + "uuid", +] + [[package]] name = "sellershut-core" version = "0.1.0" @@ -2683,6 +2648,31 @@ dependencies = [ "tonic-types", ] +[[package]] +name = "sellershut-profiles" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "clap", + "config", + "futures-util", + "nanoid", + "prost", + "sellershut-core", + "serde", + "serde_json", + "sqlx", + "stack-up", + "time", + "tokio", + "tonic", + "tonic-reflection", + "tracing", + "url", + "uuid", +] + [[package]] name = "semver" version = "1.0.26" diff --git a/Cargo.toml b/Cargo.toml index 95fa20b..62229f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ clap = "4.5.41" config = { version = "0.15.13", default-features = false } futures-util = { version = "0.3.31", default-features = false } nanoid = "0.4.0" +prost = "0.13.5" reqwest = { version = "0.12.22", default-features = false } sellershut-core = { path = "lib/sellershut-core" } serde = "1.0.219" diff --git a/crates/auth-service/Cargo.toml b/crates/auth-service/Cargo.toml new file mode 100644 index 0000000..bbbb10d --- /dev/null +++ b/crates/auth-service/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "sellershut-auth" +version = "0.1.0" +edition = "2024" +license.workspace = true +homepage.workspace = true +documentation.workspace = true +description.workspace = true + +[dependencies] +anyhow.workspace = true +axum = { workspace = true, features = ["macros"] } +axum-extra = { version = "0.10.1", features = ["typed-header"] } +base64.workspace = true +clap = { workspace = true, features = ["derive"] } +config = { workspace = true, features = ["convert-case", "toml"] } +futures-util.workspace = true +jsonwebtoken = "9.3.1" +nanoid.workspace = true +oauth2 = "5.0.0" +reqwest = { workspace = true, features = ["json", "rustls-tls"] } +sellershut-core = { workspace = true, features = ["auth", "serde"] } +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +sqlx = { workspace = true, features = ["macros", "migrate", "runtime-tokio", "time", "tls-rustls", "uuid"] } +time = { workspace = true, features = ["parsing", "serde"] } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal"] } +tonic.workspace = true +tonic-reflection = "0.13.0" +tower = { workspace = true, features = ["steer", "util"] } +tower-http = { workspace = true, features = ["map-request-body", "trace", "util"] } +tower-sessions = "0.14.0" +tower-sessions-core = { version = "0.14.0", features = ["deletion-task"] } +tower-sessions-moka-store = "0.15.0" +tower-sessions-sqlx-store = { version = "0.15.0", features = ["postgres"] } +tracing.workspace = true +url.workspace = true +uuid = { workspace = true, features = ["serde", "v7"] } + +[dependencies.stack-up] +workspace = true +features = ["api", "postgres", "tracing"] diff --git a/crates/auth-service/auth.toml b/crates/auth-service/auth.toml new file mode 100644 index 0000000..8febe90 --- /dev/null +++ b/crates/auth-service/auth.toml @@ -0,0 +1,40 @@ +[application] +env = "development" +port = 1304 + +[nats] +hosts = ["nats://localhost:4222"] + +[misc] +profile-endpoint = "http://localhost:1610" + +[misc.oauth] +session-lifespan = 3600 # seconds +jwt-encoding-key = "secret" + +[misc.oauth.discord] +# query param for provider +redirect-url = "http://127.0.0.1:1304/auth/authorised?provider=discord" +#client-id = "" +#client-secret = "" +#auth-url = "" + + +[monitoring] +log-level = "auth_service=trace,info" + +[database] +pool_size = 100 +port = 5432 +name = "auth" +host = "localhost" +password = "password" +user = "postgres" + +[cache] +dsn = "redis://localhost:6379" +pooled = true +type = "non-clustered" # clustered, non-clustered or sentinel +max-connections = 100 + +# vim:ft=toml diff --git a/crates/auth-service/migrations/20250723100947_user.sql b/crates/auth-service/migrations/20250723100947_user.sql new file mode 100644 index 0000000..b5566fe --- /dev/null +++ b/crates/auth-service/migrations/20250723100947_user.sql @@ -0,0 +1,20 @@ +-- Add migration script here +create table auth_user ( + id uuid primary key, + email text unique not null, + updated_at timestamptz not null default now(), + created_at timestamptz not null default now() +); + +create or replace function set_updated_at() +returns trigger as $$ +begin + new.updated_at := now(); + return new; +end; +$$ language plpgsql; + +create trigger trigger_set_updated_at +before update on auth_user +for each row +execute function set_updated_at(); diff --git a/crates/auth-service/migrations/20250723121223_oauth_account.sql b/crates/auth-service/migrations/20250723121223_oauth_account.sql new file mode 100644 index 0000000..826fbcc --- /dev/null +++ b/crates/auth-service/migrations/20250723121223_oauth_account.sql @@ -0,0 +1,7 @@ +CREATE TABLE oauth_account ( + provider_id text not null, + provider_user_id text not null, + user_id uuid not null, + primary key (provider_id, provider_user_id), + foreign key (user_id) references auth_user(id) +); diff --git a/crates/auth-service/migrations/20250725160900_session.sql b/crates/auth-service/migrations/20250725160900_session.sql new file mode 100644 index 0000000..c5e76dc --- /dev/null +++ b/crates/auth-service/migrations/20250725160900_session.sql @@ -0,0 +1,8 @@ +-- Add migration script here +create schema if not exists "tower_sessions"; + +create table "tower_sessions"."session" ( + id text primary key not null, + data bytea not null, + expiry_date timestamptz not null +); diff --git a/crates/auth-service/migrations/20250725161014_token.sql b/crates/auth-service/migrations/20250725161014_token.sql new file mode 100644 index 0000000..68f476c --- /dev/null +++ b/crates/auth-service/migrations/20250725161014_token.sql @@ -0,0 +1,9 @@ +-- Add migration script here +create table token ( + user_id uuid not null, + token text not null, + session_id text not null, + primary key (user_id, session_id), + foreign key (session_id) references "tower_sessions"."session"(id) on delete cascade, + foreign key (user_id) references auth_user(id) on delete cascade +); diff --git a/crates/auth-service/src/auth.rs b/crates/auth-service/src/auth.rs new file mode 100644 index 0000000..04cb60a --- /dev/null +++ b/crates/auth-service/src/auth.rs @@ -0,0 +1,12 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Claims { + pub iss: String, + pub sub: Uuid, + pub exp: i64, + pub iat: i64, + pub sid: String, + pub aud: String, +} diff --git a/crates/auth-service/src/client.rs b/crates/auth-service/src/client.rs new file mode 100644 index 0000000..5aa4de0 --- /dev/null +++ b/crates/auth-service/src/client.rs @@ -0,0 +1,6 @@ +use oauth2::{EndpointNotSet, EndpointSet, basic::BasicClient}; + +pub mod discord; + +pub type OauthClient = + BasicClient; diff --git a/crates/auth-service/src/client/discord.rs b/crates/auth-service/src/client/discord.rs new file mode 100644 index 0000000..9217684 --- /dev/null +++ b/crates/auth-service/src/client/discord.rs @@ -0,0 +1,30 @@ +use crate::{client::OauthClient, cnfg::OauthCredentials, error::AppError}; +use anyhow::Context; +use oauth2::{AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl, basic::BasicClient}; + +pub fn discord_client(config: &OauthCredentials) -> Result { + let auth_url = config.auth_url.clone().unwrap_or_else(|| { + "https://discord.com/api/oauth2/authorize?response_type=code".to_string() + }); + + let token_url = config + .token_url + .clone() + .unwrap_or_else(|| "https://discord.com/api/oauth2/token".to_string()); + + let c = BasicClient::new(ClientId::new(config.client_id.to_owned())) + .set_client_secret(ClientSecret::new(config.client_secret.to_owned())) + .set_auth_uri( + AuthUrl::new(auth_url).context("failed to create new auth server url [discord]")?, + ) + .set_redirect_uri( + RedirectUrl::new(config.redirect_url.to_owned()) + .context("failed to create new redirect URL [discord]")?, + ) + .set_token_uri( + TokenUrl::new(token_url) + .context("failed to create new token endpoint URL [discord]")?, + ); + + Ok(c) +} diff --git a/crates/auth-service/src/cnfg.rs b/crates/auth-service/src/cnfg.rs new file mode 100644 index 0000000..9b765a5 --- /dev/null +++ b/crates/auth-service/src/cnfg.rs @@ -0,0 +1,26 @@ +use serde::Deserialize; + +#[derive(Deserialize, Clone)] +#[serde(rename_all = "kebab-case")] +pub struct LocalConfig { + pub oauth: OauthConfig, + pub profile_endpoint: String, +} + +#[derive(Deserialize, Clone)] +#[serde(rename_all = "kebab-case")] +pub struct OauthConfig { + pub discord: OauthCredentials, + pub session_lifespan: u64, + pub jwt_encoding_key: String, +} + +#[derive(Deserialize, Clone)] +#[serde(rename_all = "kebab-case")] +pub struct OauthCredentials { + pub client_id: String, + pub client_secret: String, + pub redirect_url: String, + pub auth_url: Option, + pub token_url: Option, +} diff --git a/crates/auth-service/src/error.rs b/crates/auth-service/src/error.rs new file mode 100644 index 0000000..730f99a --- /dev/null +++ b/crates/auth-service/src/error.rs @@ -0,0 +1,26 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, +}; + +#[derive(Debug)] +pub struct AppError(anyhow::Error); + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Something went wrong: {}", self.0), + ) + .into_response() + } +} + +impl From for AppError +where + E: Into, +{ + fn from(err: E) -> Self { + Self(err.into()) + } +} diff --git a/crates/auth-service/src/main.rs b/crates/auth-service/src/main.rs new file mode 100644 index 0000000..50544fc --- /dev/null +++ b/crates/auth-service/src/main.rs @@ -0,0 +1,141 @@ +mod auth; +mod client; +mod cnfg; +mod error; +mod server; +mod state; + +use std::net::{Ipv6Addr, SocketAddr}; + +use clap::Parser; +use reqwest::header::CONTENT_TYPE; +use sellershut_core::auth::{AUTH_FILE_DESCRIPTOR_SET, auth_server::AuthServer}; +use stack_up::{Configuration, tracing::Tracing}; +use tokio::{signal, task::AbortHandle}; +use tonic::service::Routes; +use tower::{make::Shared, steer::Steer}; +use tracing::{info, trace}; + +use crate::{ + error::AppError, + server::grpc::interceptor::MyInterceptor, + state::{AppState, Services}, +}; + +/// sellershut-auth +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +struct Args { + /// Path to config file + #[arg(short, long)] + config_file: Option, +} + +#[tokio::main] +async fn main() -> Result<(), AppError> { + let args = Args::parse(); + let config = include_str!("../auth.toml"); + + let mut config = config::Config::builder() + .add_source(config::File::from_str(config, config::FileFormat::Toml)) + .add_source( + config::Environment::with_prefix("APP") + .separator("__") + .convert_case(config::Case::Kebab), + ); + + if let Some(cf) = args.config_file.as_ref().and_then(|v| v.to_str()) { + config = config.add_source(config::File::new(cf, config::FileFormat::Toml)); + }; + + let mut config: Configuration = config.build()?.try_deserialize()?; + config.application.name = env!("CARGO_CRATE_NAME").into(); + config.application.version = env!("CARGO_PKG_VERSION").into(); + + let _tracing = Tracing::builder().build(&config.monitoring); + + let mut services = stack_up::Services::builder() + .postgres(&config.database) + .await + .inspect_err(|e| tracing::error!("database: {e}"))? + .build(); + + let postgres = services + .postgres + .take() + .ok_or_else(|| anyhow::anyhow!("database is not ready"))?; + + let services = Services { postgres }; + + trace!("running migrations"); + sqlx::migrate!("./migrations") + .run(&services.postgres) + .await?; + + let (state, deletion_task) = AppState::create(services, &config).await?; + + let addr = SocketAddr::from((Ipv6Addr::UNSPECIFIED, config.application.port)); + + let listener = tokio::net::TcpListener::bind(addr).await?; + info!(port = addr.port(), "serving api"); + + let service = AuthServer::with_interceptor(state.clone(), MyInterceptor); + let auth_reflector = tonic_reflection::server::Builder::configure() + .register_encoded_file_descriptor_set(AUTH_FILE_DESCRIPTOR_SET) + .build_v1()?; + + let grpc_server = Routes::new(service) + .add_service(auth_reflector) + .into_axum_router(); + + let service = Steer::new( + vec![server::router(state), grpc_server], + |req: &axum::extract::Request, _services: &[_]| { + if req + .headers() + .get(CONTENT_TYPE) + .map(|content_type| content_type.as_bytes()) + .filter(|content_type| content_type.starts_with(b"application/grpc")) + .is_some() + { + // grpc service + 1 + } else { + // http service + 0 + } + }, + ); + + axum::serve(listener, Shared::new(service)) + .with_graceful_shutdown(shutdown_signal(deletion_task.abort_handle())) + .await?; + + deletion_task.await??; + + Ok(()) +} + +async fn shutdown_signal(deletion_task_abort_handle: AbortHandle) { + let ctrl_c = async { + signal::ctrl_c() + .await + .expect("failed to install Ctrl+C handler"); + }; + + #[cfg(unix)] + let terminate = async { + signal::unix::signal(signal::unix::SignalKind::terminate()) + .expect("failed to install signal handler") + .recv() + .await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => { deletion_task_abort_handle.abort() }, + _ = terminate => { deletion_task_abort_handle.abort() }, + } +} diff --git a/crates/auth-service/src/server.rs b/crates/auth-service/src/server.rs new file mode 100644 index 0000000..7b66c42 --- /dev/null +++ b/crates/auth-service/src/server.rs @@ -0,0 +1,37 @@ +use axum::{Router, routing::get}; +use tower_http::trace::TraceLayer; + +use crate::{ + server::routes::{authorised::login_authorised, health_check}, + state::AppHandle, +}; + +pub mod csrf_token_validation; +pub mod grpc; +pub mod routes; + +const CSRF_TOKEN: &str = "csrf_token"; +const OAUTH_CSRF_COOKIE: &str = "SESSION"; + +pub fn router(state: AppHandle) -> Router { + Router::new() + .route("/auth/authorised", get(login_authorised)) + .route("/", get(health_check)) + .with_state(state.clone()) + .merge(routes::discord::discord_router(state)) + .layer(TraceLayer::new_for_http()) +} + +#[cfg(test)] +pub(crate) fn test_config() -> stack_up::Configuration { + use stack_up::Configuration; + + let config_path = "auth.toml"; + + let config = config::Config::builder() + .add_source(config::File::new(config_path, config::FileFormat::Toml)) + .build() + .unwrap(); + + config.try_deserialize::().unwrap() +} diff --git a/crates/auth-service/src/server/csrf_token_validation.rs b/crates/auth-service/src/server/csrf_token_validation.rs new file mode 100644 index 0000000..94424c8 --- /dev/null +++ b/crates/auth-service/src/server/csrf_token_validation.rs @@ -0,0 +1,40 @@ +use anyhow::{Context, anyhow}; +use oauth2::CsrfToken; +use tower_sessions::{CachingSessionStore, SessionStore, session::Id}; +use tower_sessions_moka_store::MokaStore; +use tower_sessions_sqlx_store::PostgresStore; + +use crate::{ + error::AppError, + server::{CSRF_TOKEN, routes::authorised::AuthRequest}, +}; + +pub async fn csrf_token_validation_workflow( + auth_request: &AuthRequest, + store: &CachingSessionStore, + oauth_session_id: Id, +) -> Result<(), AppError> { + let oauth_session = store.load(&oauth_session_id).await.unwrap().unwrap(); + + // Extract the CSRF token from the session + let csrf_token_serialized = oauth_session + .data + .get(CSRF_TOKEN) + .context("failed to get value from session")?; + let csrf_token = serde_json::from_value::(csrf_token_serialized.clone()) + .context("CSRF token not found in session")? + .to_owned(); + + // Cleanup the CSRF token session + store + .delete(&oauth_session_id) + .await + .context("Failed to destroy old session")?; + + // Validate CSRF token is the same as the one in the auth request + if *csrf_token.secret() != auth_request.state { + return Err(anyhow!("CSRF token mismatch").into()); + } + + Ok(()) +} diff --git a/crates/auth-service/src/server/grpc.rs b/crates/auth-service/src/server/grpc.rs new file mode 100644 index 0000000..0fd775b --- /dev/null +++ b/crates/auth-service/src/server/grpc.rs @@ -0,0 +1,2 @@ +pub mod auth; +pub mod interceptor; diff --git a/crates/auth-service/src/server/grpc/auth.rs b/crates/auth-service/src/server/grpc/auth.rs new file mode 100644 index 0000000..fb00291 --- /dev/null +++ b/crates/auth-service/src/server/grpc/auth.rs @@ -0,0 +1,50 @@ +use std::str::FromStr; + +use jsonwebtoken::DecodingKey; +use sellershut_core::auth::{ValidationRequest, ValidationResponse, auth_server::Auth}; +use tonic::{Request, Response, Status, async_trait}; +use tower_sessions::{SessionStore, session::Id}; +use tracing::warn; + +use crate::{auth::Claims, state::AppHandle}; + +#[async_trait] +impl Auth for AppHandle { + async fn validate_auth_token( + &self, + request: Request, + ) -> Result, Status> { + let token = request.into_inner().token; + + let token = jsonwebtoken::decode::( + &token, + &DecodingKey::from_secret(self.local_config.oauth.jwt_encoding_key.as_bytes()), + &jsonwebtoken::Validation::default(), + ); + + match token { + Ok(value) => { + let session_id = value.claims.sid; + let store = &self.session_store; + match Id::from_str(&session_id) { + Ok(ref id) => { + if let Ok(Some(_)) = store.load(id).await { + return Ok(Response::new(ValidationResponse { valid: true })); + } else { + return Ok(Response::new(Default::default())); + } + } + Err(e) => { + warn!("{e}"); + + return Ok(Response::new(Default::default())); + } + } + } + Err(e) => { + warn!("{e}"); + Ok(Response::new(ValidationResponse::default())) + } + } + } +} diff --git a/crates/auth-service/src/server/grpc/interceptor.rs b/crates/auth-service/src/server/grpc/interceptor.rs new file mode 100644 index 0000000..155a306 --- /dev/null +++ b/crates/auth-service/src/server/grpc/interceptor.rs @@ -0,0 +1,17 @@ +use tonic::{ + Status, + service::{Interceptor, interceptor::InterceptedService}, + transport::Channel, +}; +use tracing::Span; + +pub type Intercepted = InterceptedService; + +#[derive(Clone, Copy)] +pub struct MyInterceptor; + +impl Interceptor for MyInterceptor { + fn call(&mut self, request: tonic::Request<()>) -> Result, Status> { + Ok(request) + } +} diff --git a/crates/auth-service/src/server/routes.rs b/crates/auth-service/src/server/routes.rs new file mode 100644 index 0000000..6773962 --- /dev/null +++ b/crates/auth-service/src/server/routes.rs @@ -0,0 +1,62 @@ +pub mod authorised; +pub mod discord; + +use std::fmt::Display; + +use axum::response::IntoResponse; +use serde::Deserialize; + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Provider { + Discord, +} + +impl Display for Provider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + Provider::Discord => "discord", + } + ) + } +} + +pub async fn health_check() -> impl IntoResponse { + let name = env!("CARGO_PKG_NAME"); + let ver = env!("CARGO_PKG_VERSION"); + + format!("{name} v{ver} is live") +} + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use sqlx::PgPool; + use stack_up::Services; + use tower::ServiceExt; + + use crate::{ + server::{self, test_config}, + state::AppState, + }; + + #[sqlx::test] + async fn health_check(pool: PgPool) { + let services = Services { postgres: pool }; + let (state, _) = AppState::create(services, &test_config()).await.unwrap(); + let app = server::router(state); + + let response = app + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + } +} diff --git a/crates/auth-service/src/server/routes/authorised.rs b/crates/auth-service/src/server/routes/authorised.rs new file mode 100644 index 0000000..4d48299 --- /dev/null +++ b/crates/auth-service/src/server/routes/authorised.rs @@ -0,0 +1,236 @@ +use std::{str::FromStr, time::Duration}; + +use anyhow::Context; +use axum::{ + extract::{Query, State}, + http::HeaderMap, + response::{IntoResponse, Redirect}, +}; +use axum_extra::{TypedHeader, headers}; +use oauth2::{AuthorizationCode, TokenResponse}; +use reqwest::{StatusCode, header::SET_COOKIE}; +use sellershut_core::profile::CreateUserRequest; +use serde::{Deserialize, Serialize}; +use sqlx::types::uuid; +use time::OffsetDateTime; +use tower_sessions::{ + SessionStore, + session::{Id, Record}, +}; +use uuid::Uuid; + +use crate::{ + auth::Claims, + error::AppError, + server::{ + OAUTH_CSRF_COOKIE, csrf_token_validation::csrf_token_validation_workflow, routes::Provider, + }, + state::AppHandle, +}; + +#[derive(Debug, Deserialize)] +pub struct AuthRequest { + provider: Provider, + code: String, + pub state: String, +} + +#[derive(Debug, Deserialize, Serialize)] +struct User { + id: String, + avatar: Option, + username: String, + discriminator: String, + verified: bool, + email: String, +} + +#[derive(Debug, Deserialize, Serialize)] +struct DbUser { + id: Uuid, + email: String, + created_at: OffsetDateTime, + updated_at: OffsetDateTime, +} + +/// The cookie to store the session id for user information. +const SESSION_COOKIE: &str = "info"; +const SESSION_DATA_KEY: &str = "data"; + +pub async fn login_authorised( + Query(query): Query, + State(state): State, + TypedHeader(cookies): TypedHeader, +) -> Result { + let provider = query.provider.to_string(); + let oauth_session_id = Id::from_str( + cookies + .get(OAUTH_CSRF_COOKIE) + .context("missing session cookie")?, + )?; + csrf_token_validation_workflow(&query, &state.session_store, oauth_session_id).await?; + + let client = state.http_client.clone(); + let store = state.session_store.clone(); + + // Get an auth token + let token = state + .discord_client + .exchange_code(AuthorizationCode::new(query.code.clone())) + .request_async(&client) + .await + .context("failed in sending request request to authorisation server")?; + + let user_data = client + // https://discord.com/developers/docs/resources/user#get-current-user + .get("https://discordapp.com/api/users/@me") + .bearer_auth(token.access_token().secret()) + .send() + .await + .context("failed in sending request to target Url")? + .json::() + .await + .context("failed to deserialise response as JSON")?; + + dbg!(&user_data); + + let user_data: User = serde_json::from_value(user_data)?; + + if !user_data.verified { + return Ok((StatusCode::UNAUTHORIZED, "email is not verified").into_response()); + } + + // Create a new session filled with user data + let session_id = Id(i128::from_le_bytes(uuid::Uuid::new_v4().to_bytes_le())); + + let mut transaction = state.services.postgres.begin().await?; + + let user = sqlx::query_as!( + DbUser, + " + select + p.* + from + auth_user p + inner join + oauth_account a + on + p.id=a.user_id + where a.provider_id = $1 and a.provider_user_id = $2 + ", + provider, + user_data.id + ) + .fetch_optional(&mut *transaction) + .await?; + + let user = if let Some(user) = user { + user + } else { + let uuid = uuid::Uuid::now_v7(); + let user = sqlx::query_as!( + DbUser, + "insert into auth_user (id, email) values ($1, $2) + on conflict (email) do update + set email = excluded.email + returning *; + ", + uuid, + user_data.email, + ) + .fetch_one(&mut *transaction) + .await?; + + sqlx::query_as!( + DbUser, + "with upsert as ( + insert into oauth_account (provider_id, provider_user_id, user_id) values ($1, $2, $3) + on conflict (provider_id, provider_user_id) do update + set provider_id = excluded.provider_id -- no-op + returning user_id + ) + select u.* + from upsert + join auth_user u on u.id = upsert.user_id; + ", + provider, + user_data.id, + user.id + ) + .fetch_one(&mut *transaction) + .await? + }; + + let exp = OffsetDateTime::now_utc() + Duration::from_secs(15 * 60); + + let claims = Claims { + sub: user.id, + exp: exp.unix_timestamp(), + iss: "sellershut".to_owned(), + sid: session_id.to_string(), + aud: "sellershut".to_owned(), + iat: OffsetDateTime::now_utc().unix_timestamp(), + }; + + let token = jsonwebtoken::encode( + &jsonwebtoken::Header::default(), + &claims, + &jsonwebtoken::EncodingKey::from_secret( + state.local_config.oauth.jwt_encoding_key.as_bytes(), + ), + )?; + + let user_request = CreateUserRequest { + email: user_data.email.to_owned(), + avatar: user_data.avatar.as_ref().map(|value| { + format!( + "https://cdn.discordapp.com/avatars/{}/{value}", + user_data.id + ) + }), + }; + + store + .create(&mut Record { + id: session_id, + data: [( + SESSION_DATA_KEY.to_string(), + serde_json::to_value(user_data).unwrap(), + )] + .into(), + expiry_date: time::OffsetDateTime::now_utc() + + Duration::from_secs(state.local_config.oauth.session_lifespan), + }) + .await + .context("failed in inserting serialised value into session")?; + + sqlx::query!( + "insert into token (user_id, token, session_id) values ($1, $2, $3)", + user.id, + token, + session_id.to_string() + ) + .execute(&mut *transaction) + .await?; + + let cookie = format!("{SESSION_COOKIE}={session_id}; SameSite=Lax; HttpOnly; Secure; Path=/"); + + let mut profile_client = state.profile_client.clone(); + let resp = profile_client.create_user(user_request).await?.into_inner(); + + let user_id = resp.temp_id; + + let mut headers = HeaderMap::new(); + headers.insert( + SET_COOKIE, + cookie.parse().context("failed to parse cookie")?, + ); + + transaction.commit().await?; + + Ok(( + headers, + Redirect::to(&format!("/?user={user_id}&token={token}")), + ) + .into_response()) +} diff --git a/crates/auth-service/src/server/routes/discord.rs b/crates/auth-service/src/server/routes/discord.rs new file mode 100644 index 0000000..e1a834f --- /dev/null +++ b/crates/auth-service/src/server/routes/discord.rs @@ -0,0 +1,10 @@ +mod discord_auth; +use axum::{Router, routing::get}; + +use crate::state::AppHandle; + +pub fn discord_router(state: AppHandle) -> Router { + Router::new() + .route("/auth/discord", get(discord_auth::discord_auth)) + .with_state(state) +} diff --git a/crates/auth-service/src/server/routes/discord/discord_auth.rs b/crates/auth-service/src/server/routes/discord/discord_auth.rs new file mode 100644 index 0000000..a45de86 --- /dev/null +++ b/crates/auth-service/src/server/routes/discord/discord_auth.rs @@ -0,0 +1,58 @@ +use std::time::Duration; + +use anyhow::Context; +use axum::{ + extract::State, + http::HeaderMap, + response::{IntoResponse, Redirect}, +}; +use oauth2::{CsrfToken, Scope}; +use reqwest::header::SET_COOKIE; +use sqlx::types::uuid; +use tower_sessions::{ + SessionStore, + session::{Id, Record}, +}; + +use crate::{ + error::AppError, + server::{CSRF_TOKEN, OAUTH_CSRF_COOKIE}, + state::AppHandle, +}; + +pub async fn discord_auth(State(state): State) -> Result { + let (auth_url, csrf_token) = state + .discord_client + .authorize_url(CsrfToken::new_random) + .add_scope(Scope::new("identify".to_string())) + .url(); + + // Store the token in the session and retrieve the session cookie. + let session_id = Id(i128::from_le_bytes(uuid::Uuid::new_v4().to_bytes_le())); + let store = state.session_store.clone(); + + store + .create(&mut Record { + id: session_id, + data: [( + CSRF_TOKEN.to_string(), + serde_json::to_value(csrf_token).unwrap(), + )] + .into(), + expiry_date: time::OffsetDateTime::now_utc() + + Duration::from_secs(state.local_config.oauth.session_lifespan), + }) + .await + .context("failed in inserting CSRF token into session")?; + + // Attach the session cookie to the response header + let cookie = + format!("{OAUTH_CSRF_COOKIE}={session_id}; SameSite=Lax; HttpOnly; Secure; Path=/"); + let mut headers = HeaderMap::new(); + headers.insert( + SET_COOKIE, + cookie.parse().context("failed to parse cookie")?, + ); + + Ok((headers, Redirect::to(auth_url.as_ref()))) +} diff --git a/crates/auth-service/src/state.rs b/crates/auth-service/src/state.rs new file mode 100644 index 0000000..5905948 --- /dev/null +++ b/crates/auth-service/src/state.rs @@ -0,0 +1,85 @@ +use std::{ops::Deref, sync::Arc}; + +use sellershut_core::profile::profile_client::ProfileClient; +use sqlx::PgPool; +use stack_up::Configuration; +use tokio::task::JoinHandle; +use tonic::transport::Endpoint; +use tower_sessions::{CachingSessionStore, ExpiredDeletion, session_store}; +use tower_sessions_moka_store::MokaStore; +use tower_sessions_sqlx_store::PostgresStore; +use tracing::error; + +use crate::{ + client::{OauthClient, discord::discord_client}, + cnfg::LocalConfig, + error::AppError, + server::grpc::interceptor::{Intercepted, MyInterceptor}, +}; + +#[derive(Clone)] +pub struct AppHandle(Arc); + +impl Deref for AppHandle { + type Target = Arc; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Clone)] +pub struct Services { + pub postgres: PgPool, +} + +pub struct AppState { + pub services: Services, + pub local_config: LocalConfig, + pub discord_client: OauthClient, + pub http_client: reqwest::Client, + pub session_store: CachingSessionStore, + pub profile_client: ProfileClient, +} + +impl AppState { + pub async fn create( + services: Services, + configuration: &Configuration, + ) -> Result<(AppHandle, JoinHandle>), AppError> { + let local_config: LocalConfig = serde_json::from_value(configuration.misc.clone())?; + + let session_store_db = + tower_sessions_sqlx_store::PostgresStore::new(services.postgres.clone()); + session_store_db.migrate().await?; + let deletion_task = tokio::task::spawn( + session_store_db + .clone() + .continuously_delete_expired(tokio::time::Duration::from_secs(60)), + ); + let session_store_mem = MokaStore::new(Some(100)); + + let store = CachingSessionStore::new(session_store_mem, session_store_db); + + let discord_client = discord_client(&local_config.oauth.discord)?; + + let channel = Endpoint::new(local_config.profile_endpoint.to_string())? + .connect() + .await + .inspect_err(|e| error!("could not connect to profile service: {e}"))?; + + let profile_client = ProfileClient::with_interceptor(channel, MyInterceptor); + + Ok(( + AppHandle(Arc::new(Self { + services, + local_config, + discord_client, + http_client: reqwest::Client::new(), + session_store: store, + profile_client, + })), + deletion_task, + )) + } +} diff --git a/crates/auth/Cargo.toml b/crates/auth/Cargo.toml deleted file mode 100644 index cc6d676..0000000 --- a/crates/auth/Cargo.toml +++ /dev/null @@ -1,42 +0,0 @@ -[package] -name = "auth-service" -version = "0.1.0" -edition = "2024" -license.workspace = true -homepage.workspace = true -documentation.workspace = true -description.workspace = true - -[dependencies] -anyhow.workspace = true -axum = { workspace = true, features = ["macros"] } -axum-extra = { version = "0.10.1", features = ["typed-header"] } -base64.workspace = true -clap = { workspace = true, features = ["derive"] } -config = { workspace = true, features = ["convert-case", "toml"] } -futures-util.workspace = true -jsonwebtoken = "9.3.1" -nanoid.workspace = true -oauth2 = "5.0.0" -reqwest = { workspace = true, features = ["json", "rustls-tls"] } -sellershut-core = { workspace = true, features = ["auth", "serde"] } -serde = { workspace = true, features = ["derive"] } -serde_json.workspace = true -sqlx = { workspace = true, features = ["macros", "migrate", "runtime-tokio", "time", "tls-rustls", "uuid"] } -time = { workspace = true, features = ["parsing", "serde"] } -tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal"] } -tonic.workspace = true -tonic-reflection = "0.13.0" -tower = { workspace = true, features = ["steer", "util"] } -tower-http = { workspace = true, features = ["map-request-body", "trace", "util"] } -tower-sessions = "0.14.0" -tower-sessions-core = { version = "0.14.0", features = ["deletion-task"] } -tower-sessions-moka-store = "0.15.0" -tower-sessions-sqlx-store = { version = "0.15.0", features = ["postgres"] } -tracing.workspace = true -url.workspace = true -uuid = { workspace = true, features = ["serde", "v7"] } - -[dependencies.stack-up] -workspace = true -features = ["api", "postgres", "tracing"] diff --git a/crates/auth/auth.toml b/crates/auth/auth.toml deleted file mode 100644 index 3af6fc0..0000000 --- a/crates/auth/auth.toml +++ /dev/null @@ -1,34 +0,0 @@ -[application] -env = "development" -port = 1304 - -[nats] -hosts = ["nats://localhost:4222"] - -[misc] -profile-endpoint = "http://localhost:1610" - -[misc.oauth] -session-lifespan = 3600 # seconds -jwt-encoding-key = "secret" - -[misc.oauth.discord] -# query param for provider -redirect-url = "http://127.0.0.1:1304/auth/authorised?provider=discord" -#client-id = "" -#client-secret = "" -#auth-url = "" - - -[monitoring] -log-level = "auth_service=trace,info" - -[database] -pool_size = 100 -port = 5432 -name = "auth" -host = "localhost" -password = "password" -user = "postgres" - -# vim:ft=toml diff --git a/crates/auth/migrations/20250723100947_user.sql b/crates/auth/migrations/20250723100947_user.sql deleted file mode 100644 index b5566fe..0000000 --- a/crates/auth/migrations/20250723100947_user.sql +++ /dev/null @@ -1,20 +0,0 @@ --- Add migration script here -create table auth_user ( - id uuid primary key, - email text unique not null, - updated_at timestamptz not null default now(), - created_at timestamptz not null default now() -); - -create or replace function set_updated_at() -returns trigger as $$ -begin - new.updated_at := now(); - return new; -end; -$$ language plpgsql; - -create trigger trigger_set_updated_at -before update on auth_user -for each row -execute function set_updated_at(); diff --git a/crates/auth/migrations/20250723121223_oauth_account.sql b/crates/auth/migrations/20250723121223_oauth_account.sql deleted file mode 100644 index 826fbcc..0000000 --- a/crates/auth/migrations/20250723121223_oauth_account.sql +++ /dev/null @@ -1,7 +0,0 @@ -CREATE TABLE oauth_account ( - provider_id text not null, - provider_user_id text not null, - user_id uuid not null, - primary key (provider_id, provider_user_id), - foreign key (user_id) references auth_user(id) -); diff --git a/crates/auth/migrations/20250725160900_session.sql b/crates/auth/migrations/20250725160900_session.sql deleted file mode 100644 index c5e76dc..0000000 --- a/crates/auth/migrations/20250725160900_session.sql +++ /dev/null @@ -1,8 +0,0 @@ --- Add migration script here -create schema if not exists "tower_sessions"; - -create table "tower_sessions"."session" ( - id text primary key not null, - data bytea not null, - expiry_date timestamptz not null -); diff --git a/crates/auth/migrations/20250725161014_token.sql b/crates/auth/migrations/20250725161014_token.sql deleted file mode 100644 index 68f476c..0000000 --- a/crates/auth/migrations/20250725161014_token.sql +++ /dev/null @@ -1,9 +0,0 @@ --- Add migration script here -create table token ( - user_id uuid not null, - token text not null, - session_id text not null, - primary key (user_id, session_id), - foreign key (session_id) references "tower_sessions"."session"(id) on delete cascade, - foreign key (user_id) references auth_user(id) on delete cascade -); diff --git a/crates/auth/src/auth.rs b/crates/auth/src/auth.rs deleted file mode 100644 index 04cb60a..0000000 --- a/crates/auth/src/auth.rs +++ /dev/null @@ -1,12 +0,0 @@ -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -#[derive(Debug, Serialize, Deserialize)] -pub struct Claims { - pub iss: String, - pub sub: Uuid, - pub exp: i64, - pub iat: i64, - pub sid: String, - pub aud: String, -} diff --git a/crates/auth/src/client.rs b/crates/auth/src/client.rs deleted file mode 100644 index 5aa4de0..0000000 --- a/crates/auth/src/client.rs +++ /dev/null @@ -1,6 +0,0 @@ -use oauth2::{EndpointNotSet, EndpointSet, basic::BasicClient}; - -pub mod discord; - -pub type OauthClient = - BasicClient; diff --git a/crates/auth/src/client/discord.rs b/crates/auth/src/client/discord.rs deleted file mode 100644 index 9217684..0000000 --- a/crates/auth/src/client/discord.rs +++ /dev/null @@ -1,30 +0,0 @@ -use crate::{client::OauthClient, cnfg::OauthCredentials, error::AppError}; -use anyhow::Context; -use oauth2::{AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl, basic::BasicClient}; - -pub fn discord_client(config: &OauthCredentials) -> Result { - let auth_url = config.auth_url.clone().unwrap_or_else(|| { - "https://discord.com/api/oauth2/authorize?response_type=code".to_string() - }); - - let token_url = config - .token_url - .clone() - .unwrap_or_else(|| "https://discord.com/api/oauth2/token".to_string()); - - let c = BasicClient::new(ClientId::new(config.client_id.to_owned())) - .set_client_secret(ClientSecret::new(config.client_secret.to_owned())) - .set_auth_uri( - AuthUrl::new(auth_url).context("failed to create new auth server url [discord]")?, - ) - .set_redirect_uri( - RedirectUrl::new(config.redirect_url.to_owned()) - .context("failed to create new redirect URL [discord]")?, - ) - .set_token_uri( - TokenUrl::new(token_url) - .context("failed to create new token endpoint URL [discord]")?, - ); - - Ok(c) -} diff --git a/crates/auth/src/cnfg.rs b/crates/auth/src/cnfg.rs deleted file mode 100644 index 9b765a5..0000000 --- a/crates/auth/src/cnfg.rs +++ /dev/null @@ -1,26 +0,0 @@ -use serde::Deserialize; - -#[derive(Deserialize, Clone)] -#[serde(rename_all = "kebab-case")] -pub struct LocalConfig { - pub oauth: OauthConfig, - pub profile_endpoint: String, -} - -#[derive(Deserialize, Clone)] -#[serde(rename_all = "kebab-case")] -pub struct OauthConfig { - pub discord: OauthCredentials, - pub session_lifespan: u64, - pub jwt_encoding_key: String, -} - -#[derive(Deserialize, Clone)] -#[serde(rename_all = "kebab-case")] -pub struct OauthCredentials { - pub client_id: String, - pub client_secret: String, - pub redirect_url: String, - pub auth_url: Option, - pub token_url: Option, -} diff --git a/crates/auth/src/error.rs b/crates/auth/src/error.rs deleted file mode 100644 index 730f99a..0000000 --- a/crates/auth/src/error.rs +++ /dev/null @@ -1,26 +0,0 @@ -use axum::{ - http::StatusCode, - response::{IntoResponse, Response}, -}; - -#[derive(Debug)] -pub struct AppError(anyhow::Error); - -impl IntoResponse for AppError { - fn into_response(self) -> Response { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Something went wrong: {}", self.0), - ) - .into_response() - } -} - -impl From for AppError -where - E: Into, -{ - fn from(err: E) -> Self { - Self(err.into()) - } -} diff --git a/crates/auth/src/main.rs b/crates/auth/src/main.rs deleted file mode 100644 index 3f68e2f..0000000 --- a/crates/auth/src/main.rs +++ /dev/null @@ -1,139 +0,0 @@ -mod auth; -mod client; -mod cnfg; -mod error; -mod server; -mod state; - -use std::net::{Ipv6Addr, SocketAddr}; - -use clap::Parser; -use reqwest::header::CONTENT_TYPE; -use sellershut_core::auth::{AUTH_FILE_DESCRIPTOR_SET, auth_server::AuthServer}; -use stack_up::{Configuration, tracing::Tracing}; -use tokio::{signal, task::AbortHandle}; -use tonic::service::Routes; -use tower::{make::Shared, steer::Steer}; -use tracing::{info, trace}; - -use crate::{ - error::AppError, - server::grpc::interceptor::MyInterceptor, - state::{AppState, Services}, -}; - -/// auth-service -#[derive(Parser, Debug)] -#[command(version, about, long_about = None)] -struct Args { - /// Path to config file - #[arg(short, long)] - config_file: Option, -} - -#[tokio::main] -async fn main() -> Result<(), AppError> { - let args = Args::parse(); - let config = include_str!("../auth.toml"); - - let mut config = config::Config::builder() - .add_source(config::File::from_str(config, config::FileFormat::Toml)) - .add_source( - config::Environment::with_prefix("APP") - .separator("__") - .convert_case(config::Case::Kebab), - ); - - if let Some(cf) = args.config_file.as_ref().and_then(|v| v.to_str()) { - config = config.add_source(config::File::new(cf, config::FileFormat::Toml)); - }; - - let mut config: Configuration = config.build()?.try_deserialize()?; - config.application.name = env!("CARGO_CRATE_NAME").into(); - config.application.version = env!("CARGO_PKG_VERSION").into(); - - let _tracing = Tracing::builder().build(&config.monitoring); - - let mut services = stack_up::Services::builder() - .postgres(&config.database) - .await - .inspect_err(|e| tracing::error!("database: {e}"))? - .build(); - - let postgres = services - .postgres - .take() - .ok_or_else(|| anyhow::anyhow!("database is not ready"))?; - - let services = Services { postgres }; - - trace!("running migrations"); - sqlx::migrate!("./migrations").run(&services.postgres).await?; - - let (state, deletion_task) = AppState::create(services, &config).await?; - - let addr = SocketAddr::from((Ipv6Addr::UNSPECIFIED, config.application.port)); - - let listener = tokio::net::TcpListener::bind(addr).await?; - info!(port = addr.port(), "serving api"); - - let service = AuthServer::with_interceptor(state.clone(), MyInterceptor); - let auth_reflector = tonic_reflection::server::Builder::configure() - .register_encoded_file_descriptor_set(AUTH_FILE_DESCRIPTOR_SET) - .build_v1()?; - - let grpc_server = Routes::new(service) - .add_service(auth_reflector) - .into_axum_router(); - - let service = Steer::new( - vec![server::router(state), grpc_server], - |req: &axum::extract::Request, _services: &[_]| { - if req - .headers() - .get(CONTENT_TYPE) - .map(|content_type| content_type.as_bytes()) - .filter(|content_type| content_type.starts_with(b"application/grpc")) - .is_some() - { - // grpc service - 1 - } else { - // http service - 0 - } - }, - ); - - axum::serve(listener, Shared::new(service)) - .with_graceful_shutdown(shutdown_signal(deletion_task.abort_handle())) - .await?; - - deletion_task.await??; - - Ok(()) -} - -async fn shutdown_signal(deletion_task_abort_handle: AbortHandle) { - let ctrl_c = async { - signal::ctrl_c() - .await - .expect("failed to install Ctrl+C handler"); - }; - - #[cfg(unix)] - let terminate = async { - signal::unix::signal(signal::unix::SignalKind::terminate()) - .expect("failed to install signal handler") - .recv() - .await; - }; - - #[cfg(not(unix))] - let terminate = std::future::pending::<()>(); - - tokio::select! { - _ = ctrl_c => { deletion_task_abort_handle.abort() }, - _ = terminate => { deletion_task_abort_handle.abort() }, - } -} diff --git a/crates/auth/src/server.rs b/crates/auth/src/server.rs deleted file mode 100644 index 7b66c42..0000000 --- a/crates/auth/src/server.rs +++ /dev/null @@ -1,37 +0,0 @@ -use axum::{Router, routing::get}; -use tower_http::trace::TraceLayer; - -use crate::{ - server::routes::{authorised::login_authorised, health_check}, - state::AppHandle, -}; - -pub mod csrf_token_validation; -pub mod grpc; -pub mod routes; - -const CSRF_TOKEN: &str = "csrf_token"; -const OAUTH_CSRF_COOKIE: &str = "SESSION"; - -pub fn router(state: AppHandle) -> Router { - Router::new() - .route("/auth/authorised", get(login_authorised)) - .route("/", get(health_check)) - .with_state(state.clone()) - .merge(routes::discord::discord_router(state)) - .layer(TraceLayer::new_for_http()) -} - -#[cfg(test)] -pub(crate) fn test_config() -> stack_up::Configuration { - use stack_up::Configuration; - - let config_path = "auth.toml"; - - let config = config::Config::builder() - .add_source(config::File::new(config_path, config::FileFormat::Toml)) - .build() - .unwrap(); - - config.try_deserialize::().unwrap() -} diff --git a/crates/auth/src/server/csrf_token_validation.rs b/crates/auth/src/server/csrf_token_validation.rs deleted file mode 100644 index 94424c8..0000000 --- a/crates/auth/src/server/csrf_token_validation.rs +++ /dev/null @@ -1,40 +0,0 @@ -use anyhow::{Context, anyhow}; -use oauth2::CsrfToken; -use tower_sessions::{CachingSessionStore, SessionStore, session::Id}; -use tower_sessions_moka_store::MokaStore; -use tower_sessions_sqlx_store::PostgresStore; - -use crate::{ - error::AppError, - server::{CSRF_TOKEN, routes::authorised::AuthRequest}, -}; - -pub async fn csrf_token_validation_workflow( - auth_request: &AuthRequest, - store: &CachingSessionStore, - oauth_session_id: Id, -) -> Result<(), AppError> { - let oauth_session = store.load(&oauth_session_id).await.unwrap().unwrap(); - - // Extract the CSRF token from the session - let csrf_token_serialized = oauth_session - .data - .get(CSRF_TOKEN) - .context("failed to get value from session")?; - let csrf_token = serde_json::from_value::(csrf_token_serialized.clone()) - .context("CSRF token not found in session")? - .to_owned(); - - // Cleanup the CSRF token session - store - .delete(&oauth_session_id) - .await - .context("Failed to destroy old session")?; - - // Validate CSRF token is the same as the one in the auth request - if *csrf_token.secret() != auth_request.state { - return Err(anyhow!("CSRF token mismatch").into()); - } - - Ok(()) -} diff --git a/crates/auth/src/server/grpc.rs b/crates/auth/src/server/grpc.rs deleted file mode 100644 index 0fd775b..0000000 --- a/crates/auth/src/server/grpc.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod auth; -pub mod interceptor; diff --git a/crates/auth/src/server/grpc/auth.rs b/crates/auth/src/server/grpc/auth.rs deleted file mode 100644 index fb00291..0000000 --- a/crates/auth/src/server/grpc/auth.rs +++ /dev/null @@ -1,50 +0,0 @@ -use std::str::FromStr; - -use jsonwebtoken::DecodingKey; -use sellershut_core::auth::{ValidationRequest, ValidationResponse, auth_server::Auth}; -use tonic::{Request, Response, Status, async_trait}; -use tower_sessions::{SessionStore, session::Id}; -use tracing::warn; - -use crate::{auth::Claims, state::AppHandle}; - -#[async_trait] -impl Auth for AppHandle { - async fn validate_auth_token( - &self, - request: Request, - ) -> Result, Status> { - let token = request.into_inner().token; - - let token = jsonwebtoken::decode::( - &token, - &DecodingKey::from_secret(self.local_config.oauth.jwt_encoding_key.as_bytes()), - &jsonwebtoken::Validation::default(), - ); - - match token { - Ok(value) => { - let session_id = value.claims.sid; - let store = &self.session_store; - match Id::from_str(&session_id) { - Ok(ref id) => { - if let Ok(Some(_)) = store.load(id).await { - return Ok(Response::new(ValidationResponse { valid: true })); - } else { - return Ok(Response::new(Default::default())); - } - } - Err(e) => { - warn!("{e}"); - - return Ok(Response::new(Default::default())); - } - } - } - Err(e) => { - warn!("{e}"); - Ok(Response::new(ValidationResponse::default())) - } - } - } -} diff --git a/crates/auth/src/server/grpc/interceptor.rs b/crates/auth/src/server/grpc/interceptor.rs deleted file mode 100644 index 155a306..0000000 --- a/crates/auth/src/server/grpc/interceptor.rs +++ /dev/null @@ -1,17 +0,0 @@ -use tonic::{ - Status, - service::{Interceptor, interceptor::InterceptedService}, - transport::Channel, -}; -use tracing::Span; - -pub type Intercepted = InterceptedService; - -#[derive(Clone, Copy)] -pub struct MyInterceptor; - -impl Interceptor for MyInterceptor { - fn call(&mut self, request: tonic::Request<()>) -> Result, Status> { - Ok(request) - } -} diff --git a/crates/auth/src/server/routes.rs b/crates/auth/src/server/routes.rs deleted file mode 100644 index 6773962..0000000 --- a/crates/auth/src/server/routes.rs +++ /dev/null @@ -1,62 +0,0 @@ -pub mod authorised; -pub mod discord; - -use std::fmt::Display; - -use axum::response::IntoResponse; -use serde::Deserialize; - -#[derive(Debug, Clone, Copy, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum Provider { - Discord, -} - -impl Display for Provider { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "{}", - match self { - Provider::Discord => "discord", - } - ) - } -} - -pub async fn health_check() -> impl IntoResponse { - let name = env!("CARGO_PKG_NAME"); - let ver = env!("CARGO_PKG_VERSION"); - - format!("{name} v{ver} is live") -} - -#[cfg(test)] -mod tests { - use axum::{ - body::Body, - http::{Request, StatusCode}, - }; - use sqlx::PgPool; - use stack_up::Services; - use tower::ServiceExt; - - use crate::{ - server::{self, test_config}, - state::AppState, - }; - - #[sqlx::test] - async fn health_check(pool: PgPool) { - let services = Services { postgres: pool }; - let (state, _) = AppState::create(services, &test_config()).await.unwrap(); - let app = server::router(state); - - let response = app - .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - } -} diff --git a/crates/auth/src/server/routes/authorised.rs b/crates/auth/src/server/routes/authorised.rs deleted file mode 100644 index 32dd929..0000000 --- a/crates/auth/src/server/routes/authorised.rs +++ /dev/null @@ -1,232 +0,0 @@ -use std::{str::FromStr, time::Duration}; - -use anyhow::Context; -use axum::{ - extract::{Query, State}, - http::HeaderMap, - response::{IntoResponse, Redirect}, -}; -use axum_extra::{TypedHeader, headers}; -use oauth2::{AuthorizationCode, TokenResponse}; -use reqwest::{StatusCode, header::SET_COOKIE}; -use sellershut_core::profile::CreateUserRequest; -use serde::{Deserialize, Serialize}; -use sqlx::types::uuid; -use time::OffsetDateTime; -use tower_sessions::{ - SessionStore, - session::{Id, Record}, -}; -use uuid::Uuid; - -use crate::{ - auth::Claims, - error::AppError, - server::{ - OAUTH_CSRF_COOKIE, csrf_token_validation::csrf_token_validation_workflow, routes::Provider, - }, - state::AppHandle, -}; - -#[derive(Debug, Deserialize)] -pub struct AuthRequest { - provider: Provider, - code: String, - pub state: String, -} - -#[derive(Debug, Deserialize, Serialize)] -struct User { - id: String, - avatar: Option, - username: String, - discriminator: String, - verified: bool, - email: String, -} - -#[derive(Debug, Deserialize, Serialize)] -struct DbUser { - id: Uuid, - email: String, - created_at: OffsetDateTime, - updated_at: OffsetDateTime, -} - -/// The cookie to store the session id for user information. -const SESSION_COOKIE: &str = "info"; -const SESSION_DATA_KEY: &str = "data"; - -pub async fn login_authorised( - Query(query): Query, - State(state): State, - TypedHeader(cookies): TypedHeader, -) -> Result { - let provider = query.provider.to_string(); - let oauth_session_id = Id::from_str( - cookies - .get(OAUTH_CSRF_COOKIE) - .context("missing session cookie")?, - )?; - csrf_token_validation_workflow(&query, &state.session_store, oauth_session_id).await?; - - let client = state.http_client.clone(); - let store = state.session_store.clone(); - - // Get an auth token - let token = state - .discord_client - .exchange_code(AuthorizationCode::new(query.code.clone())) - .request_async(&client) - .await - .context("failed in sending request request to authorisation server")?; - - let user_data = client - // https://discord.com/developers/docs/resources/user#get-current-user - .get("https://discordapp.com/api/users/@me") - .bearer_auth(token.access_token().secret()) - .send() - .await - .context("failed in sending request to target Url")? - .json::() - .await - .context("failed to deserialise response as JSON")?; - - dbg!(&user_data); - - let user_data: User = serde_json::from_value(user_data)?; - - if !user_data.verified { - return Ok((StatusCode::UNAUTHORIZED, "email is not verified").into_response()); - } - - // Create a new session filled with user data - let session_id = Id(i128::from_le_bytes(uuid::Uuid::new_v4().to_bytes_le())); - - let mut transaction = state.services.postgres.begin().await?; - - let user = sqlx::query_as!( - DbUser, - " - select - p.* - from - auth_user p - inner join - oauth_account a - on - p.id=a.user_id - where a.provider_id = $1 and a.provider_user_id = $2 - ", - provider, - user_data.id - ) - .fetch_optional(&mut *transaction) - .await?; - - let user = if let Some(user) = user { - user - } else { - let uuid = uuid::Uuid::now_v7(); - let user = sqlx::query_as!( - DbUser, - "insert into auth_user (id, email) values ($1, $2) - on conflict (email) do update - set email = excluded.email - returning *; - ", - uuid, - user_data.email, - ) - .fetch_one(&mut *transaction) - .await?; - - sqlx::query_as!( - DbUser, - "with upsert as ( - insert into oauth_account (provider_id, provider_user_id, user_id) values ($1, $2, $3) - on conflict (provider_id, provider_user_id) do update - set provider_id = excluded.provider_id -- no-op - returning user_id - ) - select u.* - from upsert - join auth_user u on u.id = upsert.user_id; - ", - provider, - user_data.id, - user.id - ) - .fetch_one(&mut *transaction) - .await? - }; - - let exp = OffsetDateTime::now_utc() + Duration::from_secs(15 * 60); - - let claims = Claims { - sub: user.id, - exp: exp.unix_timestamp(), - iss: "sellershut".to_owned(), - sid: session_id.to_string(), - aud: "sellershut".to_owned(), - iat: OffsetDateTime::now_utc().unix_timestamp(), - }; - - let token = jsonwebtoken::encode( - &jsonwebtoken::Header::default(), - &claims, - &jsonwebtoken::EncodingKey::from_secret( - state.local_config.oauth.jwt_encoding_key.as_bytes(), - ), - )?; - - let user_request = CreateUserRequest{ - email: user_data.email.to_owned(), - avatar: user_data.avatar.as_ref().map(|value| { - format!( - "https://cdn.discordapp.com/avatars/{}/{value}", - user_data.id - ) - }) - }; - - - store - .create(&mut Record { - id: session_id, - data: [( - SESSION_DATA_KEY.to_string(), - serde_json::to_value(user_data).unwrap(), - )] - .into(), - expiry_date: time::OffsetDateTime::now_utc() - + Duration::from_secs(state.local_config.oauth.session_lifespan), - }) - .await - .context("failed in inserting serialised value into session")?; - - sqlx::query!( - "insert into token (user_id, token, session_id) values ($1, $2, $3)", - user.id, - token, - session_id.to_string() - ) - .execute(&mut *transaction) - .await?; - - let cookie = format!("{SESSION_COOKIE}={session_id}; SameSite=Lax; HttpOnly; Secure; Path=/"); - - let mut profile_client = state.profile_client.clone(); - let resp = profile_client.create_user(user_request).await?.into_inner(); - let user_id = resp.temp_id; - - let mut headers = HeaderMap::new(); - headers.insert( - SET_COOKIE, - cookie.parse().context("failed to parse cookie")?, - ); - - transaction.commit().await?; - - Ok((headers, Redirect::to(&format!("/?user={user_id}&token={token}"))).into_response()) -} diff --git a/crates/auth/src/server/routes/discord.rs b/crates/auth/src/server/routes/discord.rs deleted file mode 100644 index e1a834f..0000000 --- a/crates/auth/src/server/routes/discord.rs +++ /dev/null @@ -1,10 +0,0 @@ -mod discord_auth; -use axum::{Router, routing::get}; - -use crate::state::AppHandle; - -pub fn discord_router(state: AppHandle) -> Router { - Router::new() - .route("/auth/discord", get(discord_auth::discord_auth)) - .with_state(state) -} diff --git a/crates/auth/src/server/routes/discord/discord_auth.rs b/crates/auth/src/server/routes/discord/discord_auth.rs deleted file mode 100644 index a45de86..0000000 --- a/crates/auth/src/server/routes/discord/discord_auth.rs +++ /dev/null @@ -1,58 +0,0 @@ -use std::time::Duration; - -use anyhow::Context; -use axum::{ - extract::State, - http::HeaderMap, - response::{IntoResponse, Redirect}, -}; -use oauth2::{CsrfToken, Scope}; -use reqwest::header::SET_COOKIE; -use sqlx::types::uuid; -use tower_sessions::{ - SessionStore, - session::{Id, Record}, -}; - -use crate::{ - error::AppError, - server::{CSRF_TOKEN, OAUTH_CSRF_COOKIE}, - state::AppHandle, -}; - -pub async fn discord_auth(State(state): State) -> Result { - let (auth_url, csrf_token) = state - .discord_client - .authorize_url(CsrfToken::new_random) - .add_scope(Scope::new("identify".to_string())) - .url(); - - // Store the token in the session and retrieve the session cookie. - let session_id = Id(i128::from_le_bytes(uuid::Uuid::new_v4().to_bytes_le())); - let store = state.session_store.clone(); - - store - .create(&mut Record { - id: session_id, - data: [( - CSRF_TOKEN.to_string(), - serde_json::to_value(csrf_token).unwrap(), - )] - .into(), - expiry_date: time::OffsetDateTime::now_utc() - + Duration::from_secs(state.local_config.oauth.session_lifespan), - }) - .await - .context("failed in inserting CSRF token into session")?; - - // Attach the session cookie to the response header - let cookie = - format!("{OAUTH_CSRF_COOKIE}={session_id}; SameSite=Lax; HttpOnly; Secure; Path=/"); - let mut headers = HeaderMap::new(); - headers.insert( - SET_COOKIE, - cookie.parse().context("failed to parse cookie")?, - ); - - Ok((headers, Redirect::to(auth_url.as_ref()))) -} diff --git a/crates/auth/src/state.rs b/crates/auth/src/state.rs deleted file mode 100644 index 5905948..0000000 --- a/crates/auth/src/state.rs +++ /dev/null @@ -1,85 +0,0 @@ -use std::{ops::Deref, sync::Arc}; - -use sellershut_core::profile::profile_client::ProfileClient; -use sqlx::PgPool; -use stack_up::Configuration; -use tokio::task::JoinHandle; -use tonic::transport::Endpoint; -use tower_sessions::{CachingSessionStore, ExpiredDeletion, session_store}; -use tower_sessions_moka_store::MokaStore; -use tower_sessions_sqlx_store::PostgresStore; -use tracing::error; - -use crate::{ - client::{OauthClient, discord::discord_client}, - cnfg::LocalConfig, - error::AppError, - server::grpc::interceptor::{Intercepted, MyInterceptor}, -}; - -#[derive(Clone)] -pub struct AppHandle(Arc); - -impl Deref for AppHandle { - type Target = Arc; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -#[derive(Clone)] -pub struct Services { - pub postgres: PgPool, -} - -pub struct AppState { - pub services: Services, - pub local_config: LocalConfig, - pub discord_client: OauthClient, - pub http_client: reqwest::Client, - pub session_store: CachingSessionStore, - pub profile_client: ProfileClient, -} - -impl AppState { - pub async fn create( - services: Services, - configuration: &Configuration, - ) -> Result<(AppHandle, JoinHandle>), AppError> { - let local_config: LocalConfig = serde_json::from_value(configuration.misc.clone())?; - - let session_store_db = - tower_sessions_sqlx_store::PostgresStore::new(services.postgres.clone()); - session_store_db.migrate().await?; - let deletion_task = tokio::task::spawn( - session_store_db - .clone() - .continuously_delete_expired(tokio::time::Duration::from_secs(60)), - ); - let session_store_mem = MokaStore::new(Some(100)); - - let store = CachingSessionStore::new(session_store_mem, session_store_db); - - let discord_client = discord_client(&local_config.oauth.discord)?; - - let channel = Endpoint::new(local_config.profile_endpoint.to_string())? - .connect() - .await - .inspect_err(|e| error!("could not connect to profile service: {e}"))?; - - let profile_client = ProfileClient::with_interceptor(channel, MyInterceptor); - - Ok(( - AppHandle(Arc::new(Self { - services, - local_config, - discord_client, - http_client: reqwest::Client::new(), - session_store: store, - profile_client, - })), - deletion_task, - )) - } -} diff --git a/crates/profile-service/Cargo.toml b/crates/profile-service/Cargo.toml index 409110b..e56db3a 100644 --- a/crates/profile-service/Cargo.toml +++ b/crates/profile-service/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "profile-service" +name = "sellershut-profiles" version = "0.1.0" edition = "2024" license.workspace = true @@ -9,16 +9,12 @@ description.workspace = true [dependencies] anyhow.workspace = true -axum = { workspace = true, features = ["macros"] } -axum-extra = { version = "0.10.1", features = ["typed-header"] } base64.workspace = true clap = { workspace = true, features = ["derive"] } config = { workspace = true, features = ["convert-case", "toml"] } futures-util.workspace = true -jsonwebtoken = "9.3.1" nanoid.workspace = true -oauth2 = "5.0.0" -reqwest = { workspace = true, features = ["json", "rustls-tls"] } +prost.workspace = true sellershut-core = { workspace = true, features = ["profile", "serde"] } serde = { workspace = true, features = ["derive"] } serde_json.workspace = true @@ -27,12 +23,10 @@ time = { workspace = true, features = ["parsing", "serde"] } tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal"] } tonic.workspace = true tonic-reflection = "0.13.0" -tower = { workspace = true, features = ["steer", "util"] } -tower-http = { workspace = true, features = ["map-request-body", "trace", "util"] } -tower-sessions = "0.14.0" -tower-sessions-core = { version = "0.14.0", features = ["deletion-task"] } -tower-sessions-moka-store = "0.15.0" -tower-sessions-sqlx-store = { version = "0.15.0", features = ["postgres"] } tracing.workspace = true url.workspace = true uuid = { workspace = true, features = ["serde", "v7"] } + +[dependencies.stack-up] +workspace = true +features = ["api", "cache", "postgres", "tracing"] diff --git a/crates/profile-service/migrations/20250726161947_profile.sql b/crates/profile-service/migrations/20250726161947_profile.sql new file mode 100644 index 0000000..15822c8 --- /dev/null +++ b/crates/profile-service/migrations/20250726161947_profile.sql @@ -0,0 +1,32 @@ +create table profile ( + id text primary key, + username varchar(30) not null, + inbox text not null, + outbox text, + local boolean not null, + avatar_url text, + description text, + user_type text not null check ( + user_type IN ('PERSON', 'APPLICATION', 'GROUP', 'ORGANIZATION', 'SERVICE') + ), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + public_key text not null +); + +create unique index unique_username_local + on profile (username) + where local = true; + +create or replace function set_updated_at() +returns trigger as $$ +begin + new.updated_at := now(); + return new; +end; +$$ language plpgsql; + +create trigger trigger_set_updated_at +before update on profile +for each row +execute function set_updated_at(); diff --git a/crates/profile-service/profile.toml b/crates/profile-service/profile.toml new file mode 100644 index 0000000..13a5f0a --- /dev/null +++ b/crates/profile-service/profile.toml @@ -0,0 +1,37 @@ +[application] +env = "development" +port = 1610 + +[monitoring] +log-level = "sellershut_profiles=trace,info" + +[misc] +temp-ttl = 1000 +cache-ttl = 300 + +[database] +pool_size = 100 +port = 5432 +name = "profiles" +host = "localhost" +password = "password" +user = "postgres" + +[nats] +hosts = ["nats://localhost:4222"] + +[cache] +dsn = "redis://localhost:6379" +pooled = true +type = "non-clustered" # clustered, non-clustered or sentinel +max-connections = 100 + +[cache.sentinel] +master-name = "mymaster" +nodes = [ + { host = "127.0.0.1", port = 26379 }, + { host = "127.0.0.2", port = 26379 }, + { host = "127.0.0.3", port = 26379 }, +] + +# vim:ft=toml diff --git a/crates/profile-service/src/cnfg.rs b/crates/profile-service/src/cnfg.rs new file mode 100644 index 0000000..fec4cf7 --- /dev/null +++ b/crates/profile-service/src/cnfg.rs @@ -0,0 +1,8 @@ +use serde::Deserialize; + +#[derive(Deserialize, Clone)] +#[serde(rename_all = "kebab-case")] +pub struct LocalConfig { + pub temp_ttl: u64, + pub cache_ttl: u64, +} diff --git a/crates/profile-service/src/main.rs b/crates/profile-service/src/main.rs index e7a11a9..5fe1331 100644 --- a/crates/profile-service/src/main.rs +++ b/crates/profile-service/src/main.rs @@ -1,3 +1,110 @@ -fn main() { - println!("Hello, world!"); +mod cnfg; +mod server; +mod state; +use std::net::{Ipv6Addr, SocketAddr}; + +use clap::Parser; +use sellershut_core::profile::profile_server::ProfileServer; +use stack_up::{Configuration, Services, tracing::Tracing}; +use tokio::signal; +use tonic::transport::{Server, server::TcpIncoming}; +use tracing::{error, info}; + +use crate::{ + server::interceptor::MyInterceptor, + state::{AppHandle, AppState}, +}; + +/// sellershut-profiles +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +struct Args { + /// Path to config file + #[arg(short, long)] + config_file: Option, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let args = Args::parse(); + let config = include_str!("../profile.toml"); + + let mut config = config::Config::builder() + .add_source(config::File::from_str(config, config::FileFormat::Toml)); + + if let Some(cf) = args.config_file.as_ref().and_then(|v| v.to_str()) { + config = config.add_source(config::File::new(cf, config::FileFormat::Toml)); + }; + + let mut config: Configuration = config.build()?.try_deserialize()?; + config.application.name = env!("CARGO_CRATE_NAME").into(); + config.application.version = env!("CARGO_PKG_VERSION").into(); + + let tracing = Tracing::builder().build(&config.monitoring); + + let mut services = Services::builder() + .postgres(&config.database) + .await + .inspect_err(|e| error!("database: {e}"))? + .cache(&config.cache) + .await + .inspect_err(|e| error!("cache: {e}"))? + .build(); + + let postgres = services + .postgres + .take() + .ok_or_else(|| anyhow::anyhow!("database is not ready"))?; + + let cache = services + .cache + .take() + .ok_or_else(|| anyhow::anyhow!("cache is not ready"))?; + + let services = crate::state::Services { postgres, cache }; + + let state = AppState::create(services, &config).await?; + + let addr = SocketAddr::from((Ipv6Addr::UNSPECIFIED, config.application.port)); + + let listener = tokio::net::TcpListener::bind(addr).await?; + + info!(addr = ?addr, "starting server"); + + Server::builder() + .trace_fn(|_| tracing::info_span!(env!("CARGO_PKG_NAME"))) + // .add_service(QueryUsersServer::new(state.clone())) + .add_service(ProfileServer::with_interceptor( + state.clone(), + MyInterceptor, + )) + .serve_with_incoming_shutdown(TcpIncoming::from(listener), shutdown_signal(state)) + .await?; + + Ok(()) +} +async fn shutdown_signal(state: AppHandle) { + let ctrl_c = async { + signal::ctrl_c() + .await + .expect("failed to install Ctrl+C handler"); + }; + + #[cfg(unix)] + let terminate = async { + signal::unix::signal(signal::unix::SignalKind::terminate()) + .expect("failed to install signal handler") + .recv() + .await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => { + }, + _ = terminate => { + }, + } } diff --git a/crates/profile-service/src/server.rs b/crates/profile-service/src/server.rs new file mode 100644 index 0000000..b2e04f9 --- /dev/null +++ b/crates/profile-service/src/server.rs @@ -0,0 +1,2 @@ +pub mod interceptor; +pub mod manager; diff --git a/crates/profile-service/src/server/interceptor.rs b/crates/profile-service/src/server/interceptor.rs new file mode 100644 index 0000000..6fbe7fa --- /dev/null +++ b/crates/profile-service/src/server/interceptor.rs @@ -0,0 +1,11 @@ +use tonic::{Status, service::Interceptor}; +use tracing::Span; + +#[derive(Clone, Copy)] +pub struct MyInterceptor; + +impl Interceptor for MyInterceptor { + fn call(&mut self, request: tonic::Request<()>) -> Result, Status> { + Ok(request) + } +} diff --git a/crates/profile-service/src/server/manager.rs b/crates/profile-service/src/server/manager.rs new file mode 100644 index 0000000..bd7e149 --- /dev/null +++ b/crates/profile-service/src/server/manager.rs @@ -0,0 +1,45 @@ +use prost::Message; +use sellershut_core::profile::{ + CompleteUserRequest, CreateUserRequest, CreateUserResponse, User, profile_server::Profile, +}; +use stack_up::redis::AsyncCommands; +use tonic::{Request, Response, Status, async_trait}; +use tracing::trace; +use uuid::Uuid; + +use crate::state::AppHandle; + +#[async_trait] +impl Profile for AppHandle { + #[doc = " Create a new user profile"] + async fn create_user( + &self, + request: Request, + ) -> Result, Status> { + trace!("creating user"); + let data = request.into_inner(); + let id = Uuid::now_v7().to_string(); + + let bytes = data.encode_to_vec(); + let mut cache = self + .services + .cache + .get() + .await + .map_err(|e| Status::internal("storage not ready"))?; + cache + .set_ex::<_, _, ()>(&id, &bytes, self.local_config.temp_ttl) + .await + .map_err(|e| Status::internal("storage not ready"))?; + + Ok(Response::new(CreateUserResponse { temp_id: id })) + } + + #[doc = " Complete Profile"] + async fn complete_profile( + &self, + request: Request, + ) -> Result, Status> { + todo!() + } +} diff --git a/crates/profile-service/src/state.rs b/crates/profile-service/src/state.rs new file mode 100644 index 0000000..1ccfbfd --- /dev/null +++ b/crates/profile-service/src/state.rs @@ -0,0 +1,42 @@ +use std::sync::Arc; + +use sqlx::PgPool; +use stack_up::{Configuration, cache::RedisManager}; + +use crate::cnfg::LocalConfig; + +#[derive(Clone)] +pub struct AppHandle(Arc); + +impl std::ops::Deref for AppHandle { + type Target = Arc; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Clone)] +pub struct Services { + pub postgres: PgPool, + pub cache: RedisManager, +} + +pub struct AppState { + pub services: Services, + pub local_config: LocalConfig, +} + +impl AppState { + pub async fn create( + services: Services, + configuration: &Configuration, + ) -> Result { + let local_config: LocalConfig = serde_json::from_value(configuration.misc.clone())?; + + Ok(AppHandle(Arc::new(Self { + services, + local_config, + }))) + } +} diff --git a/crates/sellershut/src/server/activities/follow.rs b/crates/sellershut/src/server/activities/follow.rs index 90b3aff..19bc4e7 100644 --- a/crates/sellershut/src/server/activities/follow.rs +++ b/crates/sellershut/src/server/activities/follow.rs @@ -122,13 +122,13 @@ impl Activity for Follow { #[cfg(test)] mod tests { + use crate::state::Services; use axum::{ body::Body, http::{Request, StatusCode}, }; use sqlx::PgPool; use tower::ServiceExt; - use crate::state::Services; use crate::{ server::{self, test_config}, diff --git a/crates/sellershut/src/server/routes.rs b/crates/sellershut/src/server/routes.rs index fc34ff0..c771f94 100644 --- a/crates/sellershut/src/server/routes.rs +++ b/crates/sellershut/src/server/routes.rs @@ -10,12 +10,12 @@ pub async fn health_check() -> impl IntoResponse { #[cfg(test)] mod tests { + use crate::state::Services; use axum::{ body::Body, http::{Request, StatusCode}, }; use sqlx::PgPool; - use crate::state::Services; use tower::ServiceExt; use crate::{ diff --git a/crates/sellershut/src/server/routes/users/followers.rs b/crates/sellershut/src/server/routes/users/followers.rs index 3b6f2ea..9f2b147 100644 --- a/crates/sellershut/src/server/routes/users/followers.rs +++ b/crates/sellershut/src/server/routes/users/followers.rs @@ -55,6 +55,7 @@ pub async fn http_get_followers( #[cfg(test)] mod tests { + use crate::state::Services; use axum::{ body::Body, http::{Request, StatusCode}, @@ -62,7 +63,6 @@ mod tests { use serde::{Deserialize, Serialize}; use serde_json::Value; use sqlx::PgPool; - use crate::state::Services; use tower::ServiceExt; use url::Url; diff --git a/crates/sellershut/src/server/routes/users/get_outbox.rs b/crates/sellershut/src/server/routes/users/get_outbox.rs index 48d2e04..b1a132b 100644 --- a/crates/sellershut/src/server/routes/users/get_outbox.rs +++ b/crates/sellershut/src/server/routes/users/get_outbox.rs @@ -24,12 +24,12 @@ pub async fn http_get_outbox( #[cfg(test)] mod tests { + use crate::state::Services; use axum::{ body::Body, http::{Request, StatusCode}, }; use sqlx::PgPool; - use crate::state::Services; use tower::ServiceExt; use crate::{ diff --git a/crates/sellershut/src/server/routes/users/get_user.rs b/crates/sellershut/src/server/routes/users/get_user.rs index 32cee42..1d8048f 100644 --- a/crates/sellershut/src/server/routes/users/get_user.rs +++ b/crates/sellershut/src/server/routes/users/get_user.rs @@ -47,12 +47,12 @@ pub async fn read_user( #[cfg(test)] mod tests { + use crate::state::Services; use axum::{ body::Body, http::{Request, StatusCode}, }; use sqlx::PgPool; - use crate::state::Services; use tower::ServiceExt; use crate::{ diff --git a/crates/sellershut/src/server/routes/users/webfinger.rs b/crates/sellershut/src/server/routes/users/webfinger.rs index 3096a03..c503c0e 100644 --- a/crates/sellershut/src/server/routes/users/webfinger.rs +++ b/crates/sellershut/src/server/routes/users/webfinger.rs @@ -33,12 +33,12 @@ pub async fn webfinger( #[cfg(test)] mod tests { + use crate::state::Services; use axum::{ body::Body, http::{Request, StatusCode}, }; use sqlx::PgPool; - use crate::state::Services; use tower::ServiceExt; use crate::{ diff --git a/lib/sellershut-core/build.rs b/lib/sellershut-core/build.rs index 110bc22..13e3d06 100644 --- a/lib/sellershut-core/build.rs +++ b/lib/sellershut-core/build.rs @@ -15,7 +15,7 @@ impl Entity { #[cfg(feature = "auth")] Entity::Auth => { res.extend(vec!["proto/auth/auth.proto"]); - }, + } #[cfg(feature = "profile")] Entity::Profile => { res.extend(vec!["proto/profile/profile.proto"]); @@ -81,7 +81,7 @@ fn build_proto(package: &str, entity: Entity) { .compile_protos(&entity.protos(), include_paths).unwrap(); } -#[cfg(all(feature = "serde", any(feature = "auth",feature = "profile")))] +#[cfg(all(feature = "serde", any(feature = "auth", feature = "profile")))] fn add_serde(config: tonic_build::Builder) -> tonic_build::Builder { config.type_attribute( ".", diff --git a/lib/sellershut-core/proto/profile/profile.proto b/lib/sellershut-core/proto/profile/profile.proto index 742dc7b..61181b3 100644 --- a/lib/sellershut-core/proto/profile/profile.proto +++ b/lib/sellershut-core/proto/profile/profile.proto @@ -34,8 +34,6 @@ message CreateUserRequest { message CreateUserResponse { // Temporary assigned id string temp_id = 1; - // Timestamp when the user was created - google.protobuf.Timestamp created_at = 2; } // Message to finalise profile creation diff --git a/lib/sellershut-core/src/lib.rs b/lib/sellershut-core/src/lib.rs index c300495..70544cf 100644 --- a/lib/sellershut-core/src/lib.rs +++ b/lib/sellershut-core/src/lib.rs @@ -7,11 +7,13 @@ )] /// Protobuf types -#[cfg(feature = "auth")] +#[cfg(any(feature = "auth", feature = "profile"))] pub mod google; /// Interactions with Auth server +#[cfg(feature = "auth")] pub mod auth; /// Interactions with Profile server +#[cfg(feature = "profile")] pub mod profile; diff --git a/lib/sellershut-core/src/profile.rs b/lib/sellershut-core/src/profile.rs index bf366b1..06484d9 100644 --- a/lib/sellershut-core/src/profile.rs +++ b/lib/sellershut-core/src/profile.rs @@ -1,3 +1,4 @@ tonic::include_proto!("profile"); /// Profile file descriptor -pub const PROFILE_FILE_DESCRIPTOR_SET: &[u8] = tonic::include_file_descriptor_set!("profile_descriptor"); +pub const PROFILE_FILE_DESCRIPTOR_SET: &[u8] = + tonic::include_file_descriptor_set!("profile_descriptor"); -- cgit v1.2.3