diff options
Diffstat (limited to 'crates/auth-service/src/server')
-rw-r--r-- | crates/auth-service/src/server/csrf_token_validation.rs | 40 | ||||
-rw-r--r-- | crates/auth-service/src/server/grpc.rs | 2 | ||||
-rw-r--r-- | crates/auth-service/src/server/grpc/auth.rs | 50 | ||||
-rw-r--r-- | crates/auth-service/src/server/grpc/interceptor.rs | 17 | ||||
-rw-r--r-- | crates/auth-service/src/server/routes.rs | 62 | ||||
-rw-r--r-- | crates/auth-service/src/server/routes/authorised.rs | 236 | ||||
-rw-r--r-- | crates/auth-service/src/server/routes/discord.rs | 10 | ||||
-rw-r--r-- | crates/auth-service/src/server/routes/discord/discord_auth.rs | 58 |
8 files changed, 475 insertions, 0 deletions
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<MokaStore, PostgresStore>, + 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::<CsrfToken>(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<ValidationRequest>, + ) -> Result<Response<ValidationResponse>, Status> { + let token = request.into_inner().token; + + let token = jsonwebtoken::decode::<Claims>( + &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<Channel, MyInterceptor>; + +#[derive(Clone, Copy)] +pub struct MyInterceptor; + +impl Interceptor for MyInterceptor { + fn call(&mut self, request: tonic::Request<()>) -> Result<tonic::Request<()>, 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<String>, + 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<AuthRequest>, + State(state): State<AppHandle>, + TypedHeader(cookies): TypedHeader<headers::Cookie>, +) -> Result<impl IntoResponse, AppError> { + 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::<serde_json::Value>() + .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<AppHandle>) -> Result<impl IntoResponse, AppError> { + 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()))) +} |