From 0740566c98c571b54bcc15fe6393e8875a7c076a Mon Sep 17 00:00:00 2001 From: Kavika Date: Sun, 7 Apr 2024 11:50:09 +1000 Subject: [PATCH 01/10] feat(db): add indexes for foreign key reference columns --- .../20240406024211_create_organisations.sql | 2 ++ .../migrations/20240406025537_create_campaigns.sql | 2 ++ .../migrations/20240406031400_create_questions.sql | 2 ++ .../20240406031915_create_applications.sql | 14 ++++++++++++++ 4 files changed, 20 insertions(+) diff --git a/backend/migrations/20240406024211_create_organisations.sql b/backend/migrations/20240406024211_create_organisations.sql index 6ba4bbca..48f41aee 100644 --- a/backend/migrations/20240406024211_create_organisations.sql +++ b/backend/migrations/20240406024211_create_organisations.sql @@ -16,3 +16,5 @@ CREATE TABLE organisation_admins ( ON DELETE CASCADE ON UPDATE CASCADE ) + +CREATE INDEX IDX_organisation_admins_organisation on organisation_admins (organisation_id); diff --git a/backend/migrations/20240406025537_create_campaigns.sql b/backend/migrations/20240406025537_create_campaigns.sql index 0bffeb16..5545b636 100644 --- a/backend/migrations/20240406025537_create_campaigns.sql +++ b/backend/migrations/20240406025537_create_campaigns.sql @@ -31,3 +31,5 @@ CREATE TABLE campaign_roles ( ON DELETE CASCADE ON UPDATE CASCADE ) + +CREATE INDEX IDX_campaign_roles_campaign on campaign_roles (campaign_id); diff --git a/backend/migrations/20240406031400_create_questions.sql b/backend/migrations/20240406031400_create_questions.sql index 2e2bb350..7cd38eb4 100644 --- a/backend/migrations/20240406031400_create_questions.sql +++ b/backend/migrations/20240406031400_create_questions.sql @@ -26,3 +26,5 @@ CREATE TABLE multi_option_question_options ( ON DELETE CASCADE ON UPDATE CASCADE ) + +CREATE INDEX IDX_multi_option_question_options_questions on multi_option_question_options (question_id); diff --git a/backend/migrations/20240406031915_create_applications.sql b/backend/migrations/20240406031915_create_applications.sql index e39ede9f..ea30636c 100644 --- a/backend/migrations/20240406031915_create_applications.sql +++ b/backend/migrations/20240406031915_create_applications.sql @@ -36,6 +36,9 @@ CREATE TABLE application_roles ( ON UPDATE CASCADE ); +CREATE INDEX IDX_application_roles_applications on application_roles (application_id); +CREATE INDEX IDX_application_roles_campaign_roles on application_roles (campaign_role_id); + CREATE TABLE answers ( id SERIAL PRIMARY KEY, application_id BIGINT NOT NULL, @@ -52,6 +55,9 @@ CREATE TABLE answers ( ON UPDATE CASCADE ); +CREATE INDEX IDX_answers_applications on answers (application_id); +CREATE INDEX IDX_answers_questions on answers (question_id); + CREATE TABLE short_answer_answers ( id SERIAL PRIMARY KEY, text TEXT NOT NULL, @@ -63,6 +69,8 @@ CREATE TABLE short_answer_answers ( ON UPDATE CASCADE ); +CREATE INDEX IDX_multi_option_answer_options_answers on short_answer_answers (answer_id); + CREATE TABLE multi_option_answer_options ( id SERIAL PRIMARY KEY, option_id BIGINT NOT NULL, @@ -79,6 +87,9 @@ CREATE TABLE multi_option_answer_options ( ON UPDATE CASCADE ); +CREATE INDEX IDX_multi_option_answer_options_question_options on multi_option_answer_options (option_id); +CREATE INDEX IDX_multi_option_answer_options_answers on multi_option_answer_options (answer_id); + CREATE TABLE application_ratings ( id SERIAL PRIMARY KEY, application_id BIGINT NOT NULL, @@ -97,3 +108,6 @@ CREATE TABLE application_ratings ( ON DELETE CASCADE ON UPDATE CASCADE ); + +CREATE INDEX IDX_application_ratings_applications on application_ratings (application_id); +CREATE INDEX IDX_application_ratings_users on application_ratings (rater_id); From cadc6c6a0cfbf0fe2531ef7e8ad0e7fb3595373d Mon Sep 17 00:00:00 2001 From: Kavika Date: Sun, 7 Apr 2024 11:53:43 +1000 Subject: [PATCH 02/10] fix(db): index naming and missing semicolons --- backend/migrations/20240406024211_create_organisations.sql | 2 +- backend/migrations/20240406025537_create_campaigns.sql | 2 +- backend/migrations/20240406031400_create_questions.sql | 2 +- backend/migrations/20240406031915_create_applications.sql | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/migrations/20240406024211_create_organisations.sql b/backend/migrations/20240406024211_create_organisations.sql index 48f41aee..88dda366 100644 --- a/backend/migrations/20240406024211_create_organisations.sql +++ b/backend/migrations/20240406024211_create_organisations.sql @@ -15,6 +15,6 @@ CREATE TABLE organisation_admins ( REFERENCES organisations(id) ON DELETE CASCADE ON UPDATE CASCADE -) +); CREATE INDEX IDX_organisation_admins_organisation on organisation_admins (organisation_id); diff --git a/backend/migrations/20240406025537_create_campaigns.sql b/backend/migrations/20240406025537_create_campaigns.sql index 5545b636..46bca9df 100644 --- a/backend/migrations/20240406025537_create_campaigns.sql +++ b/backend/migrations/20240406025537_create_campaigns.sql @@ -30,6 +30,6 @@ CREATE TABLE campaign_roles ( REFERENCES campaigns(id) ON DELETE CASCADE ON UPDATE CASCADE -) +); CREATE INDEX IDX_campaign_roles_campaign on campaign_roles (campaign_id); diff --git a/backend/migrations/20240406031400_create_questions.sql b/backend/migrations/20240406031400_create_questions.sql index 7cd38eb4..fa170813 100644 --- a/backend/migrations/20240406031400_create_questions.sql +++ b/backend/migrations/20240406031400_create_questions.sql @@ -25,6 +25,6 @@ CREATE TABLE multi_option_question_options ( REFERENCES questions(id) ON DELETE CASCADE ON UPDATE CASCADE -) +); CREATE INDEX IDX_multi_option_question_options_questions on multi_option_question_options (question_id); diff --git a/backend/migrations/20240406031915_create_applications.sql b/backend/migrations/20240406031915_create_applications.sql index ea30636c..91d9edcf 100644 --- a/backend/migrations/20240406031915_create_applications.sql +++ b/backend/migrations/20240406031915_create_applications.sql @@ -62,14 +62,14 @@ CREATE TABLE short_answer_answers ( id SERIAL PRIMARY KEY, text TEXT NOT NULL, answer_id INTEGER NOT NULL, - CONSTRAINT FK_multi_option_answer_options_answers + CONSTRAINT FK_short_answer_answers_answers FOREIGN KEY(answer_id) REFERENCES answers(id) ON DELETE CASCADE ON UPDATE CASCADE ); -CREATE INDEX IDX_multi_option_answer_options_answers on short_answer_answers (answer_id); +CREATE INDEX IDX_short_answer_answers_answers on short_answer_answers (answer_id); CREATE TABLE multi_option_answer_options ( id SERIAL PRIMARY KEY, From 3c397bf0a1dbf297384e209c40aa8d8c5fa5b1f9 Mon Sep 17 00:00:00 2001 From: Kavika Date: Sun, 7 Apr 2024 13:30:03 +1000 Subject: [PATCH 03/10] dep(backend): update axum --- backend/server/Cargo.toml | 30 +++++++-------- backend/server/src/main.rs | 21 +++++++--- backend/server/src/models/auth.rs | 62 +++++++++++++++++------------- backend/server/src/service/auth.rs | 9 +++-- 4 files changed, 72 insertions(+), 50 deletions(-) diff --git a/backend/server/Cargo.toml b/backend/server/Cargo.toml index 85671315..fd764748 100644 --- a/backend/server/Cargo.toml +++ b/backend/server/Cargo.toml @@ -7,20 +7,20 @@ edition = "2021" [dependencies] # Primary crates -tokio = { version = "1.32.0", features = ["macros", "rt-multi-thread"] } -axum = { version = "0.6.20", features = ["macros", "headers"] } -axum-extra = "0.8.0" -sqlx = { version = "0.7.1", features = ["runtime-tokio-rustls", "postgres", "time", "uuid"] } +tokio = { version = "1.34", features = ["macros", "rt-multi-thread"] } +axum = { version = "0.7", features = ["macros"] } +axum-extra = { version = "0.9", features = ["typed-header"] } +sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "time", "uuid"] } # Important secondary crates -anyhow = "1.0.75" -serde = { version = "1.0.188", features = ["derive"] } -reqwest = { version = "0.11.20", features = ["json"] } -serde_json = "1.0.105" -chrono = { version = "0.4.26", features = ["serde"] } -oauth2 = "4.4.1" -log = "0.4.20" -uuid = { version = "1.5.0", features = ["serde", "v4"] } -rs-snowflake = "0.6.0" -jsonwebtoken = "9.1.0" -dotenvy = "0.15.7" +anyhow = "1.0" +serde = { version = "1.0", features = ["derive"] } +reqwest = { version = "0.11", features = ["json"] } +serde_json = "1.0" +chrono = { version = "0.4", features = ["serde"] } +oauth2 = "4.4" +log = "0.4" +uuid = { version = "1.5", features = ["serde", "v4"] } +rs-snowflake = "0.6" +jsonwebtoken = "9.1" +dotenvy = "0.15" diff --git a/backend/server/src/main.rs b/backend/server/src/main.rs index e9d84481..9c807bf2 100644 --- a/backend/server/src/main.rs +++ b/backend/server/src/main.rs @@ -1,10 +1,15 @@ use anyhow::Result; use axum::{routing::get, Router}; -use jsonwebtoken::{DecodingKey, EncodingKey}; +use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation}; use models::app::AppState; use snowflake::SnowflakeIdGenerator; use sqlx::postgres::PgPoolOptions; use std::env; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use crate::handler::auth::{jwt_create, jwt_test}; +use crate::models::auth::AuthUser; + mod handler; mod models; mod service; @@ -30,6 +35,10 @@ async fn main() -> Result<()> { // let jwt_secret = "I want to cry"; let encoding_key = EncodingKey::from_secret(jwt_secret.as_bytes()); let decoding_key = DecodingKey::from_secret(jwt_secret.as_bytes()); + let mut jwt_header = Header::new(Algorithm::HS512); + let mut jwt_validator = Validation::new(Algorithm::HS512); + jwt_validator.set_issuer(&["Chaos"]); + jwt_validator.set_audience(&["chaos.devsoc.app"]); // Initialise reqwest client let ctx = reqwest::Client::new(); @@ -43,17 +52,19 @@ async fn main() -> Result<()> { ctx, encoding_key, decoding_key, + jwt_header, + jwt_validator, snowflake_generator, }; let app = Router::new() .route("/", get(|| async { "Hello, World!" })) + .route("/jwt", get(jwt_test)) + .route("/jwt_create", get(jwt_create)) .with_state(state); - axum::Server::bind(&"0.0.0.0:3000".parse().unwrap()) - .serve(app.into_make_service()) - .await - .unwrap(); + let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); + axum::serve(listener, app).await.unwrap(); Ok(()) } diff --git a/backend/server/src/models/auth.rs b/backend/server/src/models/auth.rs index 3fb447bb..8402bd85 100644 --- a/backend/server/src/models/auth.rs +++ b/backend/server/src/models/auth.rs @@ -1,14 +1,18 @@ use crate::models::app::AppState; use crate::service::auth::is_super_user; use crate::service::jwt::decode_auth_token; -use axum::extract::{FromRef, FromRequestParts, TypedHeader}; +use axum::extract::{FromRef, FromRequestParts}; use axum::http::request::Parts; use axum::response::{IntoResponse, Redirect, Response}; use axum::{ - async_trait, headers, + async_trait, http::{self, Request}, RequestPartsExt, }; +use axum_extra::{ + headers::Cookie, + TypedHeader, +}; use serde::{Deserialize, Serialize}; #[derive(Deserialize, Serialize)] @@ -47,18 +51,20 @@ where async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { let app_state = AppState::from_ref(state); let decoding_key = &app_state.decoding_key; - let extracted_cookies = parts.extract::>().await; + let jwt_validator = &app_state.jwt_validator; + let TypedHeader(cookies) = parts + .extract::>() + .await + .map_err(|_| AuthRedirect)?; - if let Ok(cookies) = extracted_cookies { - let token = cookies.get("auth_token").ok_or(AuthRedirect)?; - let claims = decode_auth_token(token.to_string(), decoding_key).ok_or(AuthRedirect)?; + let token = cookies.get("auth_token").unwrap(); - Ok(AuthUser { - user_id: claims.sub, - }) - } else { - Err(AuthRedirect) - } + let claims = + decode_auth_token(token, decoding_key, jwt_validator).ok_or(AuthRedirect)?; + + Ok(AuthUser { + user_id: claims.sub, + }) } } @@ -78,21 +84,23 @@ where async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { let app_state = AppState::from_ref(state); let decoding_key = &app_state.decoding_key; - let extracted_cookies = parts.extract::>().await; - - if let Ok(cookies) = extracted_cookies { - let token = cookies.get("auth_token").ok_or(AuthRedirect)?; - let claims = decode_auth_token(token.to_string(), decoding_key).ok_or(AuthRedirect)?; - - let pool = &app_state.db; - let possible_user = is_super_user(claims.sub, pool).await; - - if let Ok(is_auth_user) = possible_user { - if is_auth_user { - return Ok(SuperUser { - user_id: claims.sub, - }); - } + let jwt_validator = &app_state.jwt_validator; + let TypedHeader(cookies) = parts + .extract::>() + .await + .map_err(|_| AuthRedirect)?; + + let token = cookies.get("auth_token").unwrap(); + + let claims = decode_auth_token(token, decoding_key, jwt_validator).ok_or(AuthRedirect)?; + let pool = &app_state.db; + let possible_user = is_super_user(claims.sub, pool).await; + + if let Ok(is_auth_user) = possible_user { + if is_auth_user { + return Ok(SuperUser { + user_id: claims.sub, + }); } } diff --git a/backend/server/src/service/auth.rs b/backend/server/src/service/auth.rs index 70b1bc0c..498159c4 100644 --- a/backend/server/src/service/auth.rs +++ b/backend/server/src/service/auth.rs @@ -15,9 +15,12 @@ pub async fn create_or_get_user_id( pool: Pool, mut snowflake_generator: SnowflakeIdGenerator, ) -> Result { - let possible_user_id = sqlx::query!("SELECT id FROM users WHERE lower(email) = $1", email.to_lowercase()) - .fetch_optional(&pool) - .await?; + let possible_user_id = sqlx::query!( + "SELECT id FROM users WHERE lower(email) = $1", + email.to_lowercase() + ) + .fetch_optional(&pool) + .await?; if let Some(result) = possible_user_id { return Ok(result.id); From 120f5e1d2531326a48bc89c85c838f6a4c480455 Mon Sep 17 00:00:00 2001 From: Kavika Date: Sun, 7 Apr 2024 13:51:36 +1000 Subject: [PATCH 04/10] fix(backend): remove testing jwt handlers --- backend/server/src/handler/auth.rs | 34 ++++++++++++------------------ backend/server/src/main.rs | 5 ----- 2 files changed, 13 insertions(+), 26 deletions(-) diff --git a/backend/server/src/handler/auth.rs b/backend/server/src/handler/auth.rs index fe6effee..126c91da 100644 --- a/backend/server/src/handler/auth.rs +++ b/backend/server/src/handler/auth.rs @@ -1,14 +1,16 @@ use crate::models::app::AppState; -use crate::models::auth::{AuthRequest, GoogleUserProfile}; +use crate::models::auth::{AuthRequest, AuthUser, GoogleUserProfile}; use crate::service::auth::create_or_get_user_id; use axum::extract::{Query, State}; use axum::http::StatusCode; use axum::response::IntoResponse; -use axum::Extension; +use axum::{Extension, Json}; use log::error; use oauth2::basic::BasicClient; use oauth2::reqwest::async_http_client; use oauth2::{AuthorizationCode, TokenResponse}; +use crate::models::error::ChaosError; +use crate::service::jwt::encode_auth_token; /// This function handles the passing in of the Google OAuth code. After allowing our app the /// requested permissions, the user is redirected to this url on our server, where we use the @@ -17,34 +19,23 @@ pub async fn google_callback( State(state): State, Query(query): Query, Extension(oauth_client): Extension, -) -> Result { - let token = match oauth_client +) -> Result { + let token = oauth_client .exchange_code(AuthorizationCode::new(query.code)) .request_async(async_http_client) - .await - { - Ok(res) => res, - Err(e) => { - error!("An error occured while exchanging Google OAuth code"); - return Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string())); - } - }; + .await?; - let profile = match state + let profile = state .ctx .get("https://openidconnect.googleapis.com/v1/userinfo") .bearer_auth(token.access_token().secret().to_owned()) .send() - .await - { - Ok(res) => res, - Err(e) => return Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string())), - }; + .await?; let profile = profile.json::().await.unwrap(); let user_id = create_or_get_user_id( - profile.email, + profile.email.clone(), profile.name, state.db, state.snowflake_generator, @@ -52,6 +43,7 @@ pub async fn google_callback( .await .unwrap(); - // TODO: Create a JWT from this user_id and return to the user. - Ok("woohoo") + // TODO: Return JWT as set-cookie header. + let token = encode_auth_token(profile.email, user_id, &state.encoding_key, &state.jwt_header); + Ok(token) } diff --git a/backend/server/src/main.rs b/backend/server/src/main.rs index 9c807bf2..4f185702 100644 --- a/backend/server/src/main.rs +++ b/backend/server/src/main.rs @@ -5,10 +5,7 @@ use models::app::AppState; use snowflake::SnowflakeIdGenerator; use sqlx::postgres::PgPoolOptions; use std::env; -use axum::http::StatusCode; use axum::response::IntoResponse; -use crate::handler::auth::{jwt_create, jwt_test}; -use crate::models::auth::AuthUser; mod handler; mod models; @@ -59,8 +56,6 @@ async fn main() -> Result<()> { let app = Router::new() .route("/", get(|| async { "Hello, World!" })) - .route("/jwt", get(jwt_test)) - .route("/jwt_create", get(jwt_create)) .with_state(state); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); From 5c1c462deaa22ed0fa3c375c71d7875a832ece68 Mon Sep 17 00:00:00 2001 From: Kavika Date: Sun, 7 Apr 2024 13:51:58 +1000 Subject: [PATCH 05/10] feat(backend): Add custom jwt validator and header --- backend/server/src/models/app.rs | 4 +++- backend/server/src/service/jwt.rs | 32 +++++++++++++++++++++++++++---- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/backend/server/src/models/app.rs b/backend/server/src/models/app.rs index 4802de13..9fbd2a8e 100644 --- a/backend/server/src/models/app.rs +++ b/backend/server/src/models/app.rs @@ -1,4 +1,4 @@ -use jsonwebtoken::{DecodingKey, EncodingKey}; +use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation}; use reqwest::Client as ReqwestClient; use snowflake::SnowflakeIdGenerator; use sqlx::{Pool, Postgres}; @@ -9,5 +9,7 @@ pub struct AppState { pub ctx: ReqwestClient, pub decoding_key: DecodingKey, pub encoding_key: EncodingKey, + pub jwt_header: Header, + pub jwt_validator: Validation, pub snowflake_generator: SnowflakeIdGenerator, } diff --git a/backend/server/src/service/jwt.rs b/backend/server/src/service/jwt.rs index 272e6aed..8c5d06bc 100644 --- a/backend/server/src/service/jwt.rs +++ b/backend/server/src/service/jwt.rs @@ -1,5 +1,6 @@ +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use axum::extract::State; -use jsonwebtoken::{decode, Validation}; +use jsonwebtoken::{decode, encode, EncodingKey, Header, Validation}; use jsonwebtoken::{Algorithm, DecodingKey}; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -21,14 +22,37 @@ pub struct AuthorizationJwtPayload { pub username: String, // username } +pub fn encode_auth_token( + username: String, + user_id: i64, + encoding_key: &EncodingKey, + jwt_header: &Header, +) -> String { + let current_time = SystemTime::now().duration_since(UNIX_EPOCH).unwrap(); + let expiry = i64::try_from((current_time + Duration::from_secs(604800)).as_secs()).unwrap(); + let claims = AuthorizationJwtPayload { + iss: "Chaos".to_string(), + sub: user_id, + jti: Uuid::new_v4(), + aud: vec!["chaos.devsoc.app".to_string()], + exp: expiry, + nbf: i64::try_from(current_time.as_secs()).unwrap(), + iat: i64::try_from(current_time.as_secs()).unwrap(), + username + }; + + encode(jwt_header, &claims, encoding_key).unwrap() +} + pub fn decode_auth_token( - token: String, + token: &str, decoding_key: &DecodingKey, + jwt_validator: &Validation, ) -> Option { let decode_token = decode::( - token.as_str(), + token, decoding_key, - &Validation::new(Algorithm::HS256), + jwt_validator, ); match decode_token { From 156f67c37874433afd7e05f9b6e981fe3e92738f Mon Sep 17 00:00:00 2001 From: Kavika Date: Sun, 7 Apr 2024 13:52:15 +1000 Subject: [PATCH 06/10] feat(backend): basic error handling enum --- backend/server/src/models/auth.rs | 19 +++++++++-------- backend/server/src/models/error.rs | 33 ++++++++++++++++++++++++++++++ backend/server/src/models/mod.rs | 1 + 3 files changed, 44 insertions(+), 9 deletions(-) create mode 100644 backend/server/src/models/error.rs diff --git a/backend/server/src/models/auth.rs b/backend/server/src/models/auth.rs index 8402bd85..92cae8f5 100644 --- a/backend/server/src/models/auth.rs +++ b/backend/server/src/models/auth.rs @@ -14,6 +14,7 @@ use axum_extra::{ TypedHeader, }; use serde::{Deserialize, Serialize}; +use crate::models::error::ChaosError; #[derive(Deserialize, Serialize)] pub struct AuthRequest { @@ -46,7 +47,7 @@ where AppState: FromRef, S: Send + Sync, { - type Rejection = AuthRedirect; + type Rejection = ChaosError; async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { let app_state = AppState::from_ref(state); @@ -55,12 +56,12 @@ where let TypedHeader(cookies) = parts .extract::>() .await - .map_err(|_| AuthRedirect)?; + .map_err(|_| ChaosError::NotLoggedIn)?; - let token = cookies.get("auth_token").unwrap(); + let token = cookies.get("auth_token").ok_or(ChaosError::NotLoggedIn)?; let claims = - decode_auth_token(token, decoding_key, jwt_validator).ok_or(AuthRedirect)?; + decode_auth_token(token, decoding_key, jwt_validator).ok_or(ChaosError::NotLoggedIn)?; Ok(AuthUser { user_id: claims.sub, @@ -79,7 +80,7 @@ where AppState: FromRef, S: Send + Sync, { - type Rejection = AuthRedirect; + type Rejection = ChaosError; async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { let app_state = AppState::from_ref(state); @@ -88,11 +89,11 @@ where let TypedHeader(cookies) = parts .extract::>() .await - .map_err(|_| AuthRedirect)?; + .map_err(|_| ChaosError::NotLoggedIn)?; - let token = cookies.get("auth_token").unwrap(); + let token = cookies.get("auth_token").ok_or(ChaosError::NotLoggedIn)?; - let claims = decode_auth_token(token, decoding_key, jwt_validator).ok_or(AuthRedirect)?; + let claims = decode_auth_token(token, decoding_key, jwt_validator).ok_or(ChaosError::NotLoggedIn)?; let pool = &app_state.db; let possible_user = is_super_user(claims.sub, pool).await; @@ -104,6 +105,6 @@ where } } - Err(AuthRedirect) + Err(ChaosError::NotLoggedIn) } } diff --git a/backend/server/src/models/error.rs b/backend/server/src/models/error.rs new file mode 100644 index 00000000..956fdede --- /dev/null +++ b/backend/server/src/models/error.rs @@ -0,0 +1,33 @@ +use anyhow::Error; +use axum::extract::rejection::JsonRejection; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Redirect, Response}; + +pub enum ChaosError { + NotLoggedIn, + Unauthorized, + ForbiddenOperation, + ServerError(anyhow::Error) +} + +impl IntoResponse for ChaosError { + fn into_response(self) -> Response { + match self { + ChaosError::NotLoggedIn => { + Redirect::temporary("/auth/google").into_response() + } + ChaosError::Unauthorized => {(StatusCode::UNAUTHORIZED, "Unauthorized".to_string()).into_response()}, + ChaosError::ForbiddenOperation => {(StatusCode::FORBIDDEN, "Forbidden operation".to_string()).into_response()}, + ChaosError::ServerError(e) => {(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()} + } + } +} + +impl From for ChaosError +where + E: Into, +{ + fn from(err: E) -> Self { + ChaosError::ServerError(err.into()) + } +} diff --git a/backend/server/src/models/mod.rs b/backend/server/src/models/mod.rs index 2164071c..b992e0f9 100644 --- a/backend/server/src/models/mod.rs +++ b/backend/server/src/models/mod.rs @@ -1,3 +1,4 @@ pub mod app; pub mod auth; +pub mod error; pub mod user; From a7483bc95ba418af301e7a3408ca04c41b31ac24 Mon Sep 17 00:00:00 2001 From: Kavika Date: Sun, 7 Apr 2024 13:56:07 +1000 Subject: [PATCH 07/10] fix(backend): ran cargo fmt --- backend/server/src/handler/auth.rs | 11 ++++++++--- backend/server/src/main.rs | 2 +- backend/server/src/models/auth.rs | 10 ++++------ backend/server/src/models/error.rs | 12 +++++------- backend/server/src/service/jwt.rs | 10 +++------- 5 files changed, 21 insertions(+), 24 deletions(-) diff --git a/backend/server/src/handler/auth.rs b/backend/server/src/handler/auth.rs index 126c91da..ed5a94f0 100644 --- a/backend/server/src/handler/auth.rs +++ b/backend/server/src/handler/auth.rs @@ -1,6 +1,8 @@ use crate::models::app::AppState; use crate::models::auth::{AuthRequest, AuthUser, GoogleUserProfile}; +use crate::models::error::ChaosError; use crate::service::auth::create_or_get_user_id; +use crate::service::jwt::encode_auth_token; use axum::extract::{Query, State}; use axum::http::StatusCode; use axum::response::IntoResponse; @@ -9,8 +11,6 @@ use log::error; use oauth2::basic::BasicClient; use oauth2::reqwest::async_http_client; use oauth2::{AuthorizationCode, TokenResponse}; -use crate::models::error::ChaosError; -use crate::service::jwt::encode_auth_token; /// This function handles the passing in of the Google OAuth code. After allowing our app the /// requested permissions, the user is redirected to this url on our server, where we use the @@ -44,6 +44,11 @@ pub async fn google_callback( .unwrap(); // TODO: Return JWT as set-cookie header. - let token = encode_auth_token(profile.email, user_id, &state.encoding_key, &state.jwt_header); + let token = encode_auth_token( + profile.email, + user_id, + &state.encoding_key, + &state.jwt_header, + ); Ok(token) } diff --git a/backend/server/src/main.rs b/backend/server/src/main.rs index 4f185702..4a7937a5 100644 --- a/backend/server/src/main.rs +++ b/backend/server/src/main.rs @@ -1,11 +1,11 @@ use anyhow::Result; +use axum::response::IntoResponse; use axum::{routing::get, Router}; use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation}; use models::app::AppState; use snowflake::SnowflakeIdGenerator; use sqlx::postgres::PgPoolOptions; use std::env; -use axum::response::IntoResponse; mod handler; mod models; diff --git a/backend/server/src/models/auth.rs b/backend/server/src/models/auth.rs index 92cae8f5..e3154fc7 100644 --- a/backend/server/src/models/auth.rs +++ b/backend/server/src/models/auth.rs @@ -1,4 +1,5 @@ use crate::models::app::AppState; +use crate::models::error::ChaosError; use crate::service::auth::is_super_user; use crate::service::jwt::decode_auth_token; use axum::extract::{FromRef, FromRequestParts}; @@ -9,12 +10,8 @@ use axum::{ http::{self, Request}, RequestPartsExt, }; -use axum_extra::{ - headers::Cookie, - TypedHeader, -}; +use axum_extra::{headers::Cookie, TypedHeader}; use serde::{Deserialize, Serialize}; -use crate::models::error::ChaosError; #[derive(Deserialize, Serialize)] pub struct AuthRequest { @@ -93,7 +90,8 @@ where let token = cookies.get("auth_token").ok_or(ChaosError::NotLoggedIn)?; - let claims = decode_auth_token(token, decoding_key, jwt_validator).ok_or(ChaosError::NotLoggedIn)?; + let claims = + decode_auth_token(token, decoding_key, jwt_validator).ok_or(ChaosError::NotLoggedIn)?; let pool = &app_state.db; let possible_user = is_super_user(claims.sub, pool).await; diff --git a/backend/server/src/models/error.rs b/backend/server/src/models/error.rs index 956fdede..f5b591b9 100644 --- a/backend/server/src/models/error.rs +++ b/backend/server/src/models/error.rs @@ -7,18 +7,16 @@ pub enum ChaosError { NotLoggedIn, Unauthorized, ForbiddenOperation, - ServerError(anyhow::Error) + ServerError(anyhow::Error), } impl IntoResponse for ChaosError { fn into_response(self) -> Response { match self { - ChaosError::NotLoggedIn => { - Redirect::temporary("/auth/google").into_response() - } - ChaosError::Unauthorized => {(StatusCode::UNAUTHORIZED, "Unauthorized".to_string()).into_response()}, - ChaosError::ForbiddenOperation => {(StatusCode::FORBIDDEN, "Forbidden operation".to_string()).into_response()}, - ChaosError::ServerError(e) => {(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()} + ChaosError::NotLoggedIn => Redirect::temporary("/auth/google").into_response(), + ChaosError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(), + ChaosError::ForbiddenOperation => (StatusCode::FORBIDDEN, "Forbidden operation").into_response(), + ChaosError::ServerError(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response() } } } diff --git a/backend/server/src/service/jwt.rs b/backend/server/src/service/jwt.rs index 8c5d06bc..29fcf1fa 100644 --- a/backend/server/src/service/jwt.rs +++ b/backend/server/src/service/jwt.rs @@ -1,8 +1,8 @@ -use std::time::{Duration, SystemTime, UNIX_EPOCH}; use axum::extract::State; use jsonwebtoken::{decode, encode, EncodingKey, Header, Validation}; use jsonwebtoken::{Algorithm, DecodingKey}; use serde::{Deserialize, Serialize}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use uuid::Uuid; use crate::AppState; @@ -38,7 +38,7 @@ pub fn encode_auth_token( exp: expiry, nbf: i64::try_from(current_time.as_secs()).unwrap(), iat: i64::try_from(current_time.as_secs()).unwrap(), - username + username, }; encode(jwt_header, &claims, encoding_key).unwrap() @@ -49,11 +49,7 @@ pub fn decode_auth_token( decoding_key: &DecodingKey, jwt_validator: &Validation, ) -> Option { - let decode_token = decode::( - token, - decoding_key, - jwt_validator, - ); + let decode_token = decode::(token, decoding_key, jwt_validator); match decode_token { Ok(token) => Option::from(token.claims), From 25f43ea00779e50bb8eb20e157470f5dba414817 Mon Sep 17 00:00:00 2001 From: Kavika Date: Sun, 7 Apr 2024 14:06:24 +1000 Subject: [PATCH 08/10] fix(backend): remove unused imports --- backend/server/src/handler/auth.rs | 6 ++---- backend/server/src/main.rs | 5 +++-- backend/server/src/models/auth.rs | 1 - backend/server/src/models/error.rs | 6 ++++-- backend/server/src/service/auth.rs | 5 ++--- backend/server/src/service/jwt.rs | 5 +---- backend/server/src/service/oauth2.rs | 3 ++- 7 files changed, 14 insertions(+), 17 deletions(-) diff --git a/backend/server/src/handler/auth.rs b/backend/server/src/handler/auth.rs index ed5a94f0..12fcab02 100644 --- a/backend/server/src/handler/auth.rs +++ b/backend/server/src/handler/auth.rs @@ -1,13 +1,11 @@ use crate::models::app::AppState; -use crate::models::auth::{AuthRequest, AuthUser, GoogleUserProfile}; +use crate::models::auth::{AuthRequest, GoogleUserProfile}; use crate::models::error::ChaosError; use crate::service::auth::create_or_get_user_id; use crate::service::jwt::encode_auth_token; use axum::extract::{Query, State}; -use axum::http::StatusCode; use axum::response::IntoResponse; -use axum::{Extension, Json}; -use log::error; +use axum::Extension; use oauth2::basic::BasicClient; use oauth2::reqwest::async_http_client; use oauth2::{AuthorizationCode, TokenResponse}; diff --git a/backend/server/src/main.rs b/backend/server/src/main.rs index 4a7937a5..8d91e4b0 100644 --- a/backend/server/src/main.rs +++ b/backend/server/src/main.rs @@ -1,11 +1,11 @@ use anyhow::Result; -use axum::response::IntoResponse; use axum::{routing::get, Router}; use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation}; use models::app::AppState; use snowflake::SnowflakeIdGenerator; use sqlx::postgres::PgPoolOptions; use std::env; +use crate::handler::auth::google_callback; mod handler; mod models; @@ -32,7 +32,7 @@ async fn main() -> Result<()> { // let jwt_secret = "I want to cry"; let encoding_key = EncodingKey::from_secret(jwt_secret.as_bytes()); let decoding_key = DecodingKey::from_secret(jwt_secret.as_bytes()); - let mut jwt_header = Header::new(Algorithm::HS512); + let jwt_header = Header::new(Algorithm::HS512); let mut jwt_validator = Validation::new(Algorithm::HS512); jwt_validator.set_issuer(&["Chaos"]); jwt_validator.set_audience(&["chaos.devsoc.app"]); @@ -56,6 +56,7 @@ async fn main() -> Result<()> { let app = Router::new() .route("/", get(|| async { "Hello, World!" })) + .route("/api/auth/callback/google", get(google_callback)) .with_state(state); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); diff --git a/backend/server/src/models/auth.rs b/backend/server/src/models/auth.rs index e3154fc7..149c3a25 100644 --- a/backend/server/src/models/auth.rs +++ b/backend/server/src/models/auth.rs @@ -7,7 +7,6 @@ use axum::http::request::Parts; use axum::response::{IntoResponse, Redirect, Response}; use axum::{ async_trait, - http::{self, Request}, RequestPartsExt, }; use axum_extra::{headers::Cookie, TypedHeader}; diff --git a/backend/server/src/models/error.rs b/backend/server/src/models/error.rs index f5b591b9..ca160d97 100644 --- a/backend/server/src/models/error.rs +++ b/backend/server/src/models/error.rs @@ -1,8 +1,10 @@ -use anyhow::Error; -use axum::extract::rejection::JsonRejection; use axum::http::StatusCode; use axum::response::{IntoResponse, Redirect, Response}; +/// Custom error enum for Chaos. +/// +/// Handles all anyhow errors (when `?` is used) alongside +/// specific errors for business logic. pub enum ChaosError { NotLoggedIn, Unauthorized, diff --git a/backend/server/src/service/auth.rs b/backend/server/src/service/auth.rs index 498159c4..7da5a35b 100644 --- a/backend/server/src/service/auth.rs +++ b/backend/server/src/service/auth.rs @@ -1,8 +1,7 @@ -use crate::models::user::UserRole; use anyhow::Result; -use jsonwebtoken::{DecodingKey, EncodingKey}; use snowflake::SnowflakeIdGenerator; use sqlx::{Pool, Postgres}; +use crate::models::user::UserRole; /// Checks if a user exists in DB based on given email address. If so, their user_id is returned. /// Otherwise, a new user is created in the DB, and the new id is returned. @@ -28,7 +27,7 @@ pub async fn create_or_get_user_id( let user_id = snowflake_generator.real_time_generate(); - let response = sqlx::query!( + sqlx::query!( "INSERT INTO users (id, email, name) VALUES ($1, $2, $3)", user_id, email.to_lowercase(), diff --git a/backend/server/src/service/jwt.rs b/backend/server/src/service/jwt.rs index 29fcf1fa..c7db35cb 100644 --- a/backend/server/src/service/jwt.rs +++ b/backend/server/src/service/jwt.rs @@ -1,12 +1,9 @@ -use axum::extract::State; use jsonwebtoken::{decode, encode, EncodingKey, Header, Validation}; -use jsonwebtoken::{Algorithm, DecodingKey}; +use jsonwebtoken::DecodingKey; use serde::{Deserialize, Serialize}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use uuid::Uuid; -use crate::AppState; - #[derive(Debug, Deserialize, Serialize)] pub struct AuthorizationJwtPayload { pub iss: String, // issuer diff --git a/backend/server/src/service/oauth2.rs b/backend/server/src/service/oauth2.rs index 673379fc..43c1104c 100644 --- a/backend/server/src/service/oauth2.rs +++ b/backend/server/src/service/oauth2.rs @@ -3,12 +3,13 @@ use oauth2::{AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl}; use std::env; /// Returns a oauth2::BasicClient, setup with settings for CHAOS Google OAuth. +/// /// Client follows OAuth2 Standard (https://oauth.net/2/) to get user's email /// using OpenID Connect (https://openid.net/developers/how-connect-works/). pub fn build_oauth_client(client_id: String, client_secret: String) -> BasicClient { let hostname = env::var("CHAOS_HOSTNAME").expect("Could not read CHAOS hostname"); - let redirect_url = format!("{}/api/auth/google_callback", hostname); + let redirect_url = format!("{}/api/auth/callback/google", hostname); let auth_url = AuthUrl::new("https://accounts.google.com/o/oauth2/v2/auth".to_string()) .expect("Invalid authorization endpoint URL"); From 4236b273296b13e4fc4d65e69d4dcd8cc21fd69f Mon Sep 17 00:00:00 2001 From: Kavika Date: Fri, 21 Jun 2024 17:22:08 +0530 Subject: [PATCH 09/10] Change to using `thiserror` --- backend/server/Cargo.toml | 1 + backend/server/src/models/auth.rs | 14 ++++----- backend/server/src/models/error.rs | 47 ++++++++++++++++++------------ 3 files changed, 37 insertions(+), 25 deletions(-) diff --git a/backend/server/Cargo.toml b/backend/server/Cargo.toml index fd764748..84445fdc 100644 --- a/backend/server/Cargo.toml +++ b/backend/server/Cargo.toml @@ -14,6 +14,7 @@ sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "time" # Important secondary crates anyhow = "1.0" +thiserror = "1.0" serde = { version = "1.0", features = ["derive"] } reqwest = { version = "0.11", features = ["json"] } serde_json = "1.0" diff --git a/backend/server/src/models/auth.rs b/backend/server/src/models/auth.rs index 149c3a25..1e164200 100644 --- a/backend/server/src/models/auth.rs +++ b/backend/server/src/models/auth.rs @@ -52,12 +52,12 @@ where let TypedHeader(cookies) = parts .extract::>() .await - .map_err(|_| ChaosError::NotLoggedIn)?; + .map_err(|_| ChaosError::NotLoggedInError)?; - let token = cookies.get("auth_token").ok_or(ChaosError::NotLoggedIn)?; + let token = cookies.get("auth_token").ok_or(ChaosError::NotLoggedInError)?; let claims = - decode_auth_token(token, decoding_key, jwt_validator).ok_or(ChaosError::NotLoggedIn)?; + decode_auth_token(token, decoding_key, jwt_validator).ok_or(ChaosError::NotLoggedInError)?; Ok(AuthUser { user_id: claims.sub, @@ -85,12 +85,12 @@ where let TypedHeader(cookies) = parts .extract::>() .await - .map_err(|_| ChaosError::NotLoggedIn)?; + .map_err(|_| ChaosError::NotLoggedInError)?; - let token = cookies.get("auth_token").ok_or(ChaosError::NotLoggedIn)?; + let token = cookies.get("auth_token").ok_or(ChaosError::NotLoggedInError)?; let claims = - decode_auth_token(token, decoding_key, jwt_validator).ok_or(ChaosError::NotLoggedIn)?; + decode_auth_token(token, decoding_key, jwt_validator).ok_or(ChaosError::NotLoggedInError)?; let pool = &app_state.db; let possible_user = is_super_user(claims.sub, pool).await; @@ -102,6 +102,6 @@ where } } - Err(ChaosError::NotLoggedIn) + Err(ChaosError::UnauthorizedError) } } diff --git a/backend/server/src/models/error.rs b/backend/server/src/models/error.rs index ca160d97..67aaa001 100644 --- a/backend/server/src/models/error.rs +++ b/backend/server/src/models/error.rs @@ -3,31 +3,42 @@ use axum::response::{IntoResponse, Redirect, Response}; /// Custom error enum for Chaos. /// -/// Handles all anyhow errors (when `?` is used) alongside +/// Handles all errors thrown by libraries (when `?` is used) alongside /// specific errors for business logic. +#[derive(thiserror::Error, Debug)] pub enum ChaosError { - NotLoggedIn, - Unauthorized, - ForbiddenOperation, - ServerError(anyhow::Error), + #[error("Not logged in")] + NotLoggedInError, + + #[error("Not authorized")] + UnauthorizedError, + + #[error("Forbidden operation")] + ForbiddenOperationError, + + #[error("SQLx error")] + DatabaseError(#[from] sqlx::Error), + + #[error("Reqwest error")] + ReqwestError(#[from] reqwest::Error), + + #[error("OAuth2 error")] + OAuthError(#[from] oauth2::RequestTokenError, oauth2::StandardErrorResponse>) } +/// Implementation for converting errors into responses. Manages error code and message returned. impl IntoResponse for ChaosError { fn into_response(self) -> Response { match self { - ChaosError::NotLoggedIn => Redirect::temporary("/auth/google").into_response(), - ChaosError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(), - ChaosError::ForbiddenOperation => (StatusCode::FORBIDDEN, "Forbidden operation").into_response(), - ChaosError::ServerError(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response() + ChaosError::NotLoggedInError => Redirect::temporary("/auth/google").into_response(), + ChaosError::UnauthorizedError => (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(), + ChaosError::ForbiddenOperationError => (StatusCode::FORBIDDEN, "Forbidden operation").into_response(), + ChaosError::DatabaseError(db_error) => match db_error { + // We only care about the RowNotFound error, as others are miscellaneous DB errors. + sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Not found").into_response(), + _ => (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response(), + }, + _ => (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response() } } } - -impl From for ChaosError -where - E: Into, -{ - fn from(err: E) -> Self { - ChaosError::ServerError(err.into()) - } -} From aa22f35ca56130d303bc32c3cce185a12784cc48 Mon Sep 17 00:00:00 2001 From: Kavika Date: Wed, 26 Jun 2024 13:21:51 +1000 Subject: [PATCH 10/10] remove 'Error' from logic error names --- backend/server/src/models/auth.rs | 14 +++++++------- backend/server/src/models/error.rs | 12 ++++++------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/backend/server/src/models/auth.rs b/backend/server/src/models/auth.rs index 0227c623..c292f092 100644 --- a/backend/server/src/models/auth.rs +++ b/backend/server/src/models/auth.rs @@ -52,12 +52,12 @@ where let TypedHeader(cookies) = parts .extract::>() .await - .map_err(|_| ChaosError::NotLoggedInError)?; + .map_err(|_| ChaosError::NotLoggedIn)?; - let token = cookies.get("auth_token").ok_or(ChaosError::NotLoggedInError)?; + let token = cookies.get("auth_token").ok_or(ChaosError::NotLoggedIn)?; let claims = - decode_auth_token(token, decoding_key, jwt_validator).ok_or(ChaosError::NotLoggedInError)?; + decode_auth_token(token, decoding_key, jwt_validator).ok_or(ChaosError::NotLoggedIn)?; Ok(AuthUser { user_id: claims.sub, @@ -85,12 +85,12 @@ where let TypedHeader(cookies) = parts .extract::>() .await - .map_err(|_| ChaosError::NotLoggedInError)?; + .map_err(|_| ChaosError::NotLoggedIn)?; - let token = cookies.get("auth_token").ok_or(ChaosError::NotLoggedInError)?; + let token = cookies.get("auth_token").ok_or(ChaosError::NotLoggedIn)?; let claims = - decode_auth_token(token, decoding_key, jwt_validator).ok_or(ChaosError::NotLoggedInError)?; + decode_auth_token(token, decoding_key, jwt_validator).ok_or(ChaosError::NotLoggedIn)?; let pool = &app_state.db; let possible_user = is_super_user(claims.sub, pool).await; @@ -103,6 +103,6 @@ where } } - Err(ChaosError::UnauthorizedError) + Err(ChaosError::Unauthorized) } } diff --git a/backend/server/src/models/error.rs b/backend/server/src/models/error.rs index 67aaa001..86c3004c 100644 --- a/backend/server/src/models/error.rs +++ b/backend/server/src/models/error.rs @@ -8,13 +8,13 @@ use axum::response::{IntoResponse, Redirect, Response}; #[derive(thiserror::Error, Debug)] pub enum ChaosError { #[error("Not logged in")] - NotLoggedInError, + NotLoggedIn, #[error("Not authorized")] - UnauthorizedError, + Unauthorized, #[error("Forbidden operation")] - ForbiddenOperationError, + ForbiddenOperation, #[error("SQLx error")] DatabaseError(#[from] sqlx::Error), @@ -30,9 +30,9 @@ pub enum ChaosError { impl IntoResponse for ChaosError { fn into_response(self) -> Response { match self { - ChaosError::NotLoggedInError => Redirect::temporary("/auth/google").into_response(), - ChaosError::UnauthorizedError => (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(), - ChaosError::ForbiddenOperationError => (StatusCode::FORBIDDEN, "Forbidden operation").into_response(), + ChaosError::NotLoggedIn => Redirect::temporary("/auth/google").into_response(), + ChaosError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(), + ChaosError::ForbiddenOperation => (StatusCode::FORBIDDEN, "Forbidden operation").into_response(), ChaosError::DatabaseError(db_error) => match db_error { // We only care about the RowNotFound error, as others are miscellaneous DB errors. sqlx::Error::RowNotFound => (StatusCode::NOT_FOUND, "Not found").into_response(),