From f7116dde40a1a5c8fb5b4eb63ef8c642b82eb016 Mon Sep 17 00:00:00 2001 From: ZelionD Date: Fri, 16 Feb 2024 01:41:15 -0500 Subject: [PATCH] add randomization to quiz, add difficulty levels, udpate quiz configuration, add a check for user profile completion token, add tests --- Cargo.lock | 1 + config/default.json | 4 +- gov-portal-db/Cargo.toml | 1 + gov-portal-db/config/default.json | 13 +- gov-portal-db/src/config.rs | 4 +- gov-portal-db/src/main.rs | 2 +- gov-portal-db/src/quiz.rs | 331 ++++++++++++++++++++++--- gov-portal-db/src/server.rs | 326 +++++++++++++++++++++++- gov-portal-db/src/users_manager/mod.rs | 115 +++------ gov-portal-db/tests/test_db.rs | 17 +- shared/src/common.rs | 216 +++++++++++++++- shared/src/utils.rs | 2 +- user-verifier/src/config.rs | 1 + user-verifier/src/server.rs | 28 +-- 14 files changed, 916 insertions(+), 145 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f0a0179..c52f9e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,6 +61,7 @@ dependencies = [ "k256", "log", "mongodb", + "rand", "reqwest", "serde", "serde-email", diff --git a/config/default.json b/config/default.json index 6e41a82..d4657d8 100644 --- a/config/default.json +++ b/config/default.json @@ -1,8 +1,6 @@ { "listenAddress": "0.0.0.0:10000", - "userDb": { - "secret": null - }, + "usersManagerSecret": null, "signer": { "signingKey": null, "requestLifetime": 60000, diff --git a/gov-portal-db/Cargo.toml b/gov-portal-db/Cargo.toml index 87b4611..32fa493 100644 --- a/gov-portal-db/Cargo.toml +++ b/gov-portal-db/Cargo.toml @@ -54,6 +54,7 @@ hex = { workspace = true } base64 = { workspace = true } anyhow = { workspace = true } dotenv = { workspace = true } +rand = { workspace = true } [dev-dependencies] assert_matches = { workspace = true } diff --git a/gov-portal-db/config/default.json b/gov-portal-db/config/default.json index 84b9986..fa32b76 100644 --- a/gov-portal-db/config/default.json +++ b/gov-portal-db/config/default.json @@ -4,7 +4,7 @@ "secret": null, "lifetime": 86400 }, - "registration": { + "usersManager": { "secret": null, "lifetime": 600, "userProfileAttributes": { @@ -24,8 +24,17 @@ "requestTimeout": 10 }, "quiz": { + "secret": null, + "timeToSolve": 300, "failedQuizBlockDuration": 172800, - "minimumValidAnswersRequired": null, + "numberOfQuizQuestionsShown": { + "easy": null, + "moderate": null + }, + "minimumValidAnswersRequired": { + "easy": null, + "moderate": null + }, "questions": null } } \ No newline at end of file diff --git a/gov-portal-db/src/config.rs b/gov-portal-db/src/config.rs index fad0793..a9f35da 100644 --- a/gov-portal-db/src/config.rs +++ b/gov-portal-db/src/config.rs @@ -3,7 +3,7 @@ use serde::Deserialize; use crate::{ quiz::QuizConfig, session_token::SessionConfig, - users_manager::{mongo_client::MongoConfig, UserRegistrationConfig}, + users_manager::{mongo_client::MongoConfig, UsersManagerConfig}, }; #[derive(Deserialize, Debug, Clone)] @@ -14,7 +14,7 @@ pub struct AppConfig { /// Session tokens configuration to allow access to database pub session: SessionConfig, /// User registration configuration - pub registration: UserRegistrationConfig, + pub users_manager: UsersManagerConfig, /// MongoDB client configuration pub mongo: MongoConfig, /// Quiz configuration diff --git a/gov-portal-db/src/main.rs b/gov-portal-db/src/main.rs index a82b453..41c7f9b 100644 --- a/gov-portal-db/src/main.rs +++ b/gov-portal-db/src/main.rs @@ -21,7 +21,7 @@ async fn main() -> Result<(), Box> { let config = utils::load_config::("./gov-portal-db").await?; let users_manager = - Arc::new(UsersManager::new(&config.mongo, config.registration.clone()).await?); + Arc::new(UsersManager::new(&config.mongo, config.users_manager.clone()).await?); server::start(config, users_manager.clone()).await?; diff --git a/gov-portal-db/src/quiz.rs b/gov-portal-db/src/quiz.rs index cd1f604..81f959a 100644 --- a/gov-portal-db/src/quiz.rs +++ b/gov-portal-db/src/quiz.rs @@ -1,6 +1,7 @@ use chrono::Utc; +use rand::{rngs::ThreadRng, seq::SliceRandom, thread_rng}; use serde::{Deserialize, Serialize}; -use std::time::Duration; +use std::{collections::HashMap, time::Duration}; use crate::users_manager::QuizResult; @@ -12,58 +13,164 @@ pub struct Quiz { #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct QuizConfig { + pub secret: String, + #[serde(deserialize_with = "shared::utils::de_secs_duration")] + pub time_to_solve: Duration, #[serde(deserialize_with = "shared::utils::de_secs_duration")] pub failed_quiz_block_duration: Duration, - pub minimum_valid_answers_required: u64, + pub number_of_quiz_questions_shown: HashMap, + pub minimum_valid_answers_required: HashMap, pub questions: Vec, } #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct QuizQuestion { - title: String, - variants: Vec, + pub title: String, + #[serde(skip_serializing)] + pub difficulty: QuizQuestionDifficultyLevel, + pub variants: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq, Hash)] +#[serde(rename_all = "camelCase")] +pub enum QuizQuestionDifficultyLevel { + Easy, + Moderate, } #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct QuizVariant { - text: String, + pub text: String, #[serde(skip_serializing)] - is_correct: bool, + pub is_correct: bool, } #[derive(Debug, Deserialize)] pub struct QuizAnswer { - question: String, - variant: String, + pub question: String, + pub variant: String, } impl Quiz { + /// Returns randomly shuffled questions to be shown + pub fn get_random_quiz_questions(&self) -> Vec { + let number_of_shown_easy_questions = self + .config + .number_of_quiz_questions_shown + .get(&QuizQuestionDifficultyLevel::Easy) + .cloned() + .unwrap_or_default() as usize; + let number_of_shown_moderate_questions = self + .config + .number_of_quiz_questions_shown + .get(&QuizQuestionDifficultyLevel::Moderate) + .cloned() + .unwrap_or_default() as usize; + + let mut rng = thread_rng(); + let easy_questions = self.get_random_quiz_questions_by_difficulty( + &mut rng, + QuizQuestionDifficultyLevel::Easy, + number_of_shown_easy_questions, + ); + let moderate_questions = self.get_random_quiz_questions_by_difficulty( + &mut rng, + QuizQuestionDifficultyLevel::Moderate, + number_of_shown_moderate_questions, + ); + + // Concat all difficulty levels into single list and return + let mut questions = Vec::with_capacity(easy_questions.len() + moderate_questions.len()); + questions.extend(easy_questions.into_iter().cloned()); + questions.extend(moderate_questions.into_iter().cloned()); + questions + } + + fn get_random_quiz_questions_by_difficulty( + &self, + mut rng: &mut ThreadRng, + difficulty: QuizQuestionDifficultyLevel, + count: usize, + ) -> Vec<&QuizQuestion> { + if count == 0 { + return vec![]; + } + + // Filter questions by difficulty level + let mut questions = self + .config + .questions + .iter() + .filter(|question| question.difficulty == difficulty) + .collect::>(); + // Randomly shuffle questions + questions.as_mut_slice().shuffle(&mut rng); + + // Remove questions from the end until reaches the required count left + loop { + if questions.len() <= count { + break; + } + + if questions.pop().is_none() { + break; + } + } + + questions + } + /// Verifies provided quiz answers pub fn verify_answers(&self, answers: Vec) -> QuizResult { - let valid_answers = answers.into_iter().fold(0u64, |valid_answers, answer| { - let is_correct_answer = self.config.questions.iter().any(|question| { - // Filter by question title - if question.title != answer.question { - return false; - } + let mut easy_valid_answers = 0; + let mut moderate_valid_answers = 0; - // Find a variant in question and verify it's a correct one - question - .variants - .iter() - .any(|variant| variant.text == answer.variant && variant.is_correct) - }); - - if is_correct_answer { - valid_answers + 1 - } else { - valid_answers + for answer in answers { + match self + .config + .questions + .iter() + .find(|question| { + // Filter by question title + if question.title != answer.question { + return false; + } + + // Find a variant in question and verify it's a correct one + question + .variants + .iter() + .any(|variant| variant.text == answer.variant && variant.is_correct) + }) + .map(|question| &question.difficulty) + { + Some(QuizQuestionDifficultyLevel::Easy) => { + easy_valid_answers += 1; + } + Some(QuizQuestionDifficultyLevel::Moderate) => { + moderate_valid_answers += 1; + } + None => {} } - }); + } - if valid_answers >= self.config.minimum_valid_answers_required { + if easy_valid_answers + >= self + .config + .minimum_valid_answers_required + .get(&QuizQuestionDifficultyLevel::Easy) + .cloned() + .unwrap_or_default() + && moderate_valid_answers + >= self + .config + .minimum_valid_answers_required + .get(&QuizQuestionDifficultyLevel::Moderate) + .cloned() + .unwrap_or_default() + { QuizResult::Solved } else { QuizResult::Failed(Utc::now() + self.config.failed_quiz_block_duration) @@ -79,11 +186,21 @@ mod tests { fn test_de_quiz_config() { let config = r#" { + "secret": "TestSecret", + "timeToSolve": 300, "failedQuizBlockDuration": 172800, - "minimumValidAnswersRequired": 1, + "numberOfQuizQuestionsShown": { + "easy": 2, + "moderate": 1 + }, + "minimumValidAnswersRequired": { + "easy": 1, + "moderate": 1 + }, "questions": [ { "title": "Question 1", + "difficulty": "easy", "variants": [ ["some invalid answer 1", false], ["some invalid answer 2", false], @@ -93,12 +210,33 @@ mod tests { }, { "title": "Question 2", + "difficulty": "moderate", "variants": [ ["some invalid answer 1", false], ["some invalid answer 2", false], ["some invalid answer 3", false], ["some valid answer 4", true] ] + }, + { + "title": "Question 3", + "difficulty": "easy", + "variants": [ + ["some invalid answer 1", false], + ["some invalid answer 2", false], + ["some valid answer 3", true], + ["some invalid answer 4", false] + ] + }, + { + "title": "Question 4", + "difficulty": "easy", + "variants": [ + ["some invalid answer 1", false], + ["some invalid answer 2", false], + ["some valid answer 3", true], + ["some invalid answer 4", false] + ] } ] } @@ -111,11 +249,21 @@ mod tests { fn test_quiz_answers() { let config = r#" { + "secret": "TestSecret", + "timeToSolve": 300, "failedQuizBlockDuration": 172800, - "minimumValidAnswersRequired": 1, + "numberOfQuizQuestionsShown": { + "easy": 2, + "moderate": 1 + }, + "minimumValidAnswersRequired": { + "easy": 1, + "moderate": 1 + }, "questions": [ { "title": "Question 1", + "difficulty": "easy", "variants": [ ["some invalid answer 1", false], ["some invalid answer 2", false], @@ -125,12 +273,33 @@ mod tests { }, { "title": "Question 2", + "difficulty": "moderate", "variants": [ ["some invalid answer 1", false], ["some invalid answer 2", false], ["some invalid answer 3", false], ["some valid answer 4", true] ] + }, + { + "title": "Question 3", + "difficulty": "easy", + "variants": [ + ["some invalid answer 1", false], + ["some invalid answer 2", false], + ["some valid answer 3", true], + ["some invalid answer 4", false] + ] + }, + { + "title": "Question 4", + "difficulty": "easy", + "variants": [ + ["some invalid answer 1", false], + ["some invalid answer 2", false], + ["some valid answer 3", true], + ["some invalid answer 4", false] + ] } ] } @@ -151,7 +320,9 @@ mod tests { input: r#" [ ["Question 1", "some valid answer 3"], - ["Question 2", "some valid answer 4"] + ["Question 2", "some valid answer 4"], + ["Question 3", "some valid answer 3"], + ["Question 4", "some valid answer 3"] ] "#, expected: true, @@ -161,16 +332,27 @@ mod tests { input: r#" [ ["Question 1", "some invalid answer 2"], - ["Question 2", "some valid answer 4"] + ["Question 2", "some valid answer 4"], + ["Question 3", "some valid answer 3"] ] "#, expected: true, }, TestCase { - title: "Not enough valid answers", + title: "Not enough easy level valid answers", input: r#" [ - ["Question 1", "some invalid answer 2"], + ["Question 1", "some invalid answer 1"], + ["Question 2", "some valid answer 4"] + ] + "#, + expected: false, + }, + TestCase { + title: "Not enough moderate level valid answers", + input: r#" + [ + ["Question 1", "some valid answer 3"], ["Question 2", "some invalid answer 3"] ] "#, @@ -191,10 +373,91 @@ mod tests { Ok(answers) => assert_eq!( quiz.verify_answers(answers) == QuizResult::Solved, expected, - "Test case #{i} failed!" + "Test case #{i} '{title}' failed!" ), Err(e) => panic!("Test case #{i} '{title}': deserialization failure. Error: {e:?}"), } } } + + #[test] + fn test_quiz_to_be_shown() { + let config = r#" + { + "secret": "TestSecret", + "timeToSolve": 300, + "failedQuizBlockDuration": 172800, + "numberOfQuizQuestionsShown": { + "easy": 2, + "moderate": 1 + }, + "minimumValidAnswersRequired": { + "easy": 1, + "moderate": 1 + }, + "questions": [ + { + "title": "Question 1", + "difficulty": "easy", + "variants": [ + ["some invalid answer 1", false], + ["some invalid answer 2", false], + ["some valid answer 3", true], + ["some invalid answer 4", false] + ] + }, + { + "title": "Question 2", + "difficulty": "moderate", + "variants": [ + ["some invalid answer 1", false], + ["some invalid answer 2", false], + ["some invalid answer 3", false], + ["some valid answer 4", true] + ] + }, + { + "title": "Question 3", + "difficulty": "easy", + "variants": [ + ["some invalid answer 1", false], + ["some invalid answer 2", false], + ["some valid answer 3", true], + ["some invalid answer 4", false] + ] + }, + { + "title": "Question 4", + "difficulty": "easy", + "variants": [ + ["some invalid answer 1", false], + ["some invalid answer 2", false], + ["some valid answer 3", true], + ["some invalid answer 4", false] + ] + } + ] + } + "#; + + let config = serde_json::from_str::(config).unwrap(); + let quiz = Quiz { config }; + + let questions = quiz.get_random_quiz_questions(); + assert_eq!(questions.len(), 3); + assert_eq!( + questions + .iter() + .filter(|question| question.difficulty == QuizQuestionDifficultyLevel::Easy) + .count(), + 2 + ); + assert_eq!( + questions + .iter() + .filter(|question| question.difficulty == QuizQuestionDifficultyLevel::Moderate) + .count(), + 1 + ); + } } diff --git a/gov-portal-db/src/server.rs b/gov-portal-db/src/server.rs index 46a169e..149da40 100644 --- a/gov-portal-db/src/server.rs +++ b/gov-portal-db/src/server.rs @@ -1,7 +1,8 @@ use axum::{extract::State, routing::post, Json, Router}; -use chrono::Utc; +use chrono::{DateTime, Utc}; use ethereum_types::Address; -use serde::Deserialize; +use jsonwebtoken::TokenData; +use serde::{Deserialize, Serialize}; use std::sync::Arc; use tower_http::cors::CorsLayer; @@ -50,11 +51,21 @@ pub enum TokenQuery { NoMessage {}, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SignedQuizResponse { + pub questions: Vec, + pub expires_at: u64, + pub quiz_token: String, +} + /// JSON-serialized request passed as POST-data to `/quiz` endpoint and contains quiz answers /// which should be verified and then updates User's profile in MongoDB #[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct VerifyQuizRequest { pub answers: Vec, + pub quiz_token: String, #[serde(flatten)] pub session: SessionToken, } @@ -159,11 +170,20 @@ async fn user_route( async fn quiz_route( State(state): State, Json(session): Json, -) -> Result>, String> { +) -> Result, String> { tracing::debug!("[/quiz] Request {:?}", session); let res = match state.session_manager.verify_token(&session) { - Ok(_) => Ok(state.quiz.config.questions), + Ok(_) => { + let questions = state.quiz.get_random_quiz_questions(); + + SignedQuizResponse::new( + questions, + Utc::now() + state.config.quiz.time_to_solve, + state.config.quiz.secret.as_bytes(), + ) + .map_err(|e| format!("Failed to sign random quiz questions. Error: {e}")) + } Err(e) => Err(format!("Quiz request failure. Error: {e}")), }; @@ -179,7 +199,14 @@ async fn verify_quiz_route( ) -> Result, String> { tracing::debug!("[/verify-quiz] Request {:?}", quiz_req); - let user_res = match state.session_manager.verify_token(&quiz_req.session) { + let token_res = match &quiz_req { + req if req.verify(state.config.users_manager.secret.as_bytes()) => { + state.session_manager.verify_token(&quiz_req.session) + } + _ => Err(anyhow::anyhow!("Invalid quiz token")), + }; + + let user_res = match token_res { Ok(wallet) => state .users_manager .get_user_by_wallet(wallet) @@ -306,3 +333,292 @@ impl UpdateUserRequest { } } } + +impl SignedQuizResponse { + fn new( + questions: Vec, + expires_at: DateTime, + secret: &[u8], + ) -> anyhow::Result { + jsonwebtoken::encode( + &jsonwebtoken::Header::default(), + &serde_json::json!({ + "questions": questions.iter().map(|question| question.title.as_str()).collect::>(), + "exp": expires_at.timestamp(), + }), + &jsonwebtoken::EncodingKey::from_secret(secret), + ) + .map_err(anyhow::Error::from) + .map(|token| Self { + questions, + expires_at: expires_at.timestamp_millis() as u64, + quiz_token: token, + }) + } +} + +impl VerifyQuizRequest { + fn verify(&self, secret: &[u8]) -> bool { + let validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::default()); + + let Ok(TokenData { + claims: serde_json::Value::Object(object), + .. + }) = jsonwebtoken::decode::( + &self.quiz_token, + &jsonwebtoken::DecodingKey::from_secret(secret), + &validation, + ) + else { + return false; + }; + + let Some(questions) = object + .get("questions") + .cloned() + .and_then(|maybe_questions| { + serde_json::from_value::>(maybe_questions).ok() + }) + else { + tracing::debug!( + "Verify quiz request token doesn't contain valid questions list: {object:?}" + ); + return false; + }; + + // Verify if number of answers is the same as number of questions given + if questions.len() != self.answers.len() { + return false; + } + + // Verify that all answers corresponds to the questions given + for question in questions { + if !self + .answers + .iter() + .any(|answer| answer.question.as_str() == question.as_str()) + { + return false; + } + } + + true + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + use crate::quiz::{QuizQuestionDifficultyLevel, QuizVariant}; + + #[test] + fn test_verify_quiz_answers() { + struct TestCase { + title: &'static str, + input: VerifyQuizRequest, + expected: bool, + } + + let test_cases = [ + TestCase { + title: "Request with 1 question is valid", + input: SignedQuizResponse::new( + vec![QuizQuestion::new( + "Q1", + QuizQuestionDifficultyLevel::Easy, + vec![ + QuizVariant::new("V1", false), + QuizVariant::new("V2", true), + QuizVariant::new("V3", false), + ], + )], + Utc::now() + Duration::from_secs(300), + "test".as_bytes(), + ) + .map(|quiz_response| VerifyQuizRequest { + answers: vec![QuizAnswer::new("Q1", "V2")], + quiz_token: quiz_response.quiz_token, + session: default_session_token(), + }) + .unwrap(), + expected: true, + }, + TestCase { + title: "Request with mixed difficulty levels is valid", + input: SignedQuizResponse::new( + vec![ + QuizQuestion::new( + "Q1", + QuizQuestionDifficultyLevel::Easy, + vec![QuizVariant::new("V1", false), QuizVariant::new("V2", true)], + ), + QuizQuestion::new( + "Q2", + QuizQuestionDifficultyLevel::Moderate, + vec![QuizVariant::new("V1", true), QuizVariant::new("V2", false)], + ), + QuizQuestion::new( + "Q3", + QuizQuestionDifficultyLevel::Easy, + vec![QuizVariant::new("V1", true), QuizVariant::new("V2", false)], + ), + QuizQuestion::new( + "Q4", + QuizQuestionDifficultyLevel::Moderate, + vec![QuizVariant::new("V1", false), QuizVariant::new("V2", true)], + ), + ], + Utc::now() + Duration::from_secs(300), + "test".as_bytes(), + ) + .map(|quiz_response| VerifyQuizRequest { + answers: vec![ + QuizAnswer::new("Q4", "V2"), + QuizAnswer::new("Q3", "V1"), + QuizAnswer::new("Q2", "V1"), + QuizAnswer::new("Q1", "V2"), + ], + quiz_token: quiz_response.quiz_token, + session: default_session_token(), + }) + .unwrap(), + expected: true, + }, + TestCase { + title: "Request is invalid (different answer given)", + input: SignedQuizResponse::new( + vec![ + QuizQuestion::new( + "Q1", + QuizQuestionDifficultyLevel::Easy, + vec![QuizVariant::new("V1", false), QuizVariant::new("V2", true)], + ), + QuizQuestion::new( + "Q2", + QuizQuestionDifficultyLevel::Moderate, + vec![QuizVariant::new("V1", true), QuizVariant::new("V2", false)], + ), + QuizQuestion::new( + "Q3", + QuizQuestionDifficultyLevel::Easy, + vec![QuizVariant::new("V1", true), QuizVariant::new("V2", false)], + ), + QuizQuestion::new( + "Q4", + QuizQuestionDifficultyLevel::Moderate, + vec![QuizVariant::new("V1", false), QuizVariant::new("V2", true)], + ), + ], + Utc::now() + Duration::from_secs(300), + "test".as_bytes(), + ) + .map(|quiz_response| VerifyQuizRequest { + answers: vec![ + QuizAnswer::new("Q5", "V2"), + QuizAnswer::new("Q4", "V1"), + QuizAnswer::new("Q3", "V1"), + QuizAnswer::new("Q2", "V2"), + ], + quiz_token: quiz_response.quiz_token, + session: default_session_token(), + }) + .unwrap(), + expected: false, + }, + TestCase { + title: "Request is invalid (expired)", + input: SignedQuizResponse::new( + vec![QuizQuestion::new( + "Q1", + QuizQuestionDifficultyLevel::Easy, + vec![QuizVariant::new("V1", false), QuizVariant::new("V2", true)], + )], + Utc::now() - Duration::from_secs(300), + "test".as_bytes(), + ) + .map(|quiz_response| VerifyQuizRequest { + answers: vec![QuizAnswer::new("Q1", "V2")], + quiz_token: quiz_response.quiz_token, + session: default_session_token(), + }) + .unwrap(), + expected: false, + }, + TestCase { + title: "Request is invalid (secret doesn't match)", + input: SignedQuizResponse::new( + vec![QuizQuestion::new( + "Q1", + QuizQuestionDifficultyLevel::Easy, + vec![QuizVariant::new("V1", false), QuizVariant::new("V2", true)], + )], + Utc::now() - Duration::from_secs(300), + "unknown_secret".as_bytes(), + ) + .map(|quiz_response| VerifyQuizRequest { + answers: vec![QuizAnswer::new("Q1", "V2")], + quiz_token: quiz_response.quiz_token, + session: default_session_token(), + }) + .unwrap(), + expected: false, + }, + ]; + + for ( + i, + TestCase { + title, + input, + expected, + }, + ) in test_cases.into_iter().enumerate() + { + assert_eq!( + input.verify("test".as_bytes()), + expected, + "Test case #{i} '{title}' failed!" + ); + } + } + + impl QuizQuestion { + fn new( + title: &str, + difficulty: QuizQuestionDifficultyLevel, + variants: Vec, + ) -> Self { + Self { + title: title.to_string(), + difficulty, + variants, + } + } + } + + impl QuizVariant { + fn new(text: &str, is_correct: bool) -> Self { + Self { + text: text.to_string(), + is_correct, + } + } + } + + impl QuizAnswer { + fn new(question: &str, answer: &str) -> Self { + Self { + question: question.to_string(), + variant: answer.to_string(), + } + } + } + + fn default_session_token() -> SessionToken { + SessionToken { + token: "".to_string(), + } + } +} diff --git a/gov-portal-db/src/users_manager/mod.rs b/gov-portal-db/src/users_manager/mod.rs index 6be4426..7d1ceca 100644 --- a/gov-portal-db/src/users_manager/mod.rs +++ b/gov-portal-db/src/users_manager/mod.rs @@ -24,11 +24,11 @@ const MONGO_DUPLICATION_ERROR: i32 = 11000; /// Users manager's [`UsersManager`] settings for JWT registration token and user profile attributes verification #[derive(Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] -pub struct UserRegistrationConfig { - /// Lifetime for which JWT registration token will be valid to register new user profile +pub struct UsersManagerConfig { + /// Lifetime for which user verification JWT token will be valid #[serde(deserialize_with = "shared::utils::de_secs_duration")] pub lifetime: Duration, - /// Secret being used to sign JWT registration token + /// Secret being used to create user verification JWT token pub secret: String, /// User profile attributes verification settings pub user_profile_attributes: UserProfileAttributes, @@ -56,20 +56,20 @@ pub enum QuizResult { /// User profiles manager which provides read/write access to user profile data stored MongoDB pub struct UsersManager { pub mongo_client: MongoClient, - pub registration_config: UserRegistrationConfig, + pub config: UsersManagerConfig, } impl UsersManager { /// Constructs [`UsersManager`] with provided confuguration pub async fn new( mongo_config: &MongoConfig, - registration_config: UserRegistrationConfig, + config: UsersManagerConfig, ) -> anyhow::Result { let mongo_client = MongoClient::new(mongo_config).await?; Ok(Self { mongo_client, - registration_config, + config, }) } @@ -115,8 +115,8 @@ impl UsersManager { res.and_then(|raw_profile| { UserProfile::new( raw_profile, - self.registration_config.lifetime, - self.registration_config.secret.as_bytes(), + Utc::now() + self.config.lifetime, + self.config.secret.as_bytes(), ) .map_err(error::Error::from) }) @@ -218,10 +218,9 @@ impl UsersManager { RawUserRegistrationToken { checksum_wallet: shared::utils::get_checksum_address(&wallet), email, - expires_at: (Utc::now() + self.registration_config.lifetime).timestamp_millis() - as u64, + expires_at: (Utc::now() + self.config.lifetime).timestamp_millis() as u64, }, - self.registration_config.secret.as_bytes(), + self.config.secret.as_bytes(), ) .map_err(|e| anyhow::Error::msg(format!("Failed to generate JWT token. Error: {}", e))) } @@ -232,7 +231,7 @@ impl UsersManager { &self, token: &UserRegistrationToken, ) -> Result { - let user = UserInfo::try_from(token.verify(self.registration_config.secret.as_bytes())?)?; + let user = UserInfo::try_from(token.verify(self.config.secret.as_bytes())?)?; self.verify_user(&user)?; @@ -241,108 +240,72 @@ impl UsersManager { /// Verifies user profile [`User`] struct fields for correctness fn verify_user(&self, user: &UserInfo) -> Result<(), error::Error> { - if user.name.as_ref().is_some_and(|value| { - value.len() - > self - .registration_config - .user_profile_attributes - .name_max_length - }) { + if user + .name + .as_ref() + .is_some_and(|value| value.len() > self.config.user_profile_attributes.name_max_length) + { return Err(error::Error::InvalidInput(format!( "Name too long (max: {})", - self.registration_config - .user_profile_attributes - .name_max_length + self.config.user_profile_attributes.name_max_length ))); } - if user.role.as_ref().is_some_and(|value| { - value.len() - > self - .registration_config - .user_profile_attributes - .role_max_length - }) { + if user + .role + .as_ref() + .is_some_and(|value| value.len() > self.config.user_profile_attributes.role_max_length) + { return Err(error::Error::InvalidInput(format!( "Role too long (max: {})", - self.registration_config - .user_profile_attributes - .role_max_length + self.config.user_profile_attributes.role_max_length ))); } if user.email.as_ref().is_some_and(|value| { - value.as_str().len() - > self - .registration_config - .user_profile_attributes - .email_max_length + value.as_str().len() > self.config.user_profile_attributes.email_max_length }) { return Err(error::Error::InvalidInput(format!( "Email too long (max: {})", - self.registration_config - .user_profile_attributes - .email_max_length + self.config.user_profile_attributes.email_max_length ))); } if user.telegram.as_ref().is_some_and(|value| { - value.len() - > self - .registration_config - .user_profile_attributes - .telegram_max_length + value.len() > self.config.user_profile_attributes.telegram_max_length }) { return Err(error::Error::InvalidInput(format!( "Telegram handle too long (max: {})", - self.registration_config - .user_profile_attributes - .telegram_max_length + self.config.user_profile_attributes.telegram_max_length ))); } if user.twitter.as_ref().is_some_and(|value| { - value.len() - > self - .registration_config - .user_profile_attributes - .twitter_max_length + value.len() > self.config.user_profile_attributes.twitter_max_length }) { return Err(error::Error::InvalidInput(format!( "Twitter identifier too long (max: {})", - self.registration_config - .user_profile_attributes - .twitter_max_length + self.config.user_profile_attributes.twitter_max_length ))); } - if user.bio.as_ref().is_some_and(|value| { - value.len() - > self - .registration_config - .user_profile_attributes - .bio_max_length - }) { + if user + .bio + .as_ref() + .is_some_and(|value| value.len() > self.config.user_profile_attributes.bio_max_length) + { return Err(error::Error::InvalidInput(format!( "Bio too long (max: {})", - self.registration_config - .user_profile_attributes - .bio_max_length + self.config.user_profile_attributes.bio_max_length ))); } if user.avatar.as_ref().is_some_and(|value| { - value.as_str().len() - > self - .registration_config - .user_profile_attributes - .avatar_url_max_length + value.as_str().len() > self.config.user_profile_attributes.avatar_url_max_length }) { return Err(error::Error::InvalidInput(format!( "Avatar URL too long (max: {})", - self.registration_config - .user_profile_attributes - .avatar_url_max_length + self.config.user_profile_attributes.avatar_url_max_length ))); } @@ -363,11 +326,11 @@ fn is_key_duplication_error(error_kind: &MongoErrorKind) -> bool { #[cfg(test)] mod tests { - use super::UserRegistrationConfig; + use super::UsersManagerConfig; #[test] fn test_user_registration_config() { - serde_json::from_str::( + serde_json::from_str::( r#" { "secret": "some_secret", diff --git a/gov-portal-db/tests/test_db.rs b/gov-portal-db/tests/test_db.rs index 0ee5dd7..7361bf7 100644 --- a/gov-portal-db/tests/test_db.rs +++ b/gov-portal-db/tests/test_db.rs @@ -18,7 +18,7 @@ async fn test_register_user() -> Result<(), anyhow::Error> { request_timeout: 10, }; - let registration_config = UserRegistrationConfig { + let registration_config = UsersManagerConfig { secret: "IntegrationTestRegistrationSecretForJWT".to_owned(), lifetime: std::time::Duration::from_secs(600), user_profile_attributes: UserProfileAttributes { @@ -79,11 +79,21 @@ async fn test_complete_profile() -> Result<(), anyhow::Error> { let quiz_config = serde_json::from_str::( r#" { + "secret": "IntegrationTestQuizSecretForJWT", + "numberOfQuizQuestionsShown": { + "easy": 2, + "moderate": 1 + }, + "minimumValidAnswersRequired": { + "easy": 1, + "moderate": 1 + }, + "timeToSolve": 300, "failedQuizBlockDuration": 172800, - "minimumValidAnswersRequired": 1, "questions": [ { "title": "Question 1", + "difficulty": "easy", "variants": [ ["some invalid answer 1", false], ["some invalid answer 2", false], @@ -93,6 +103,7 @@ async fn test_complete_profile() -> Result<(), anyhow::Error> { }, { "title": "Question 2", + "difficulty": "moderate", "variants": [ ["some invalid answer 1", false], ["some invalid answer 2", false], @@ -117,7 +128,7 @@ async fn test_complete_profile() -> Result<(), anyhow::Error> { request_timeout: 10, }; - let registration_config = UserRegistrationConfig { + let registration_config = UsersManagerConfig { secret: "IntegrationTestRegistrationSecretForJWT".to_owned(), lifetime: std::time::Duration::from_secs(600), user_profile_attributes: UserProfileAttributes { diff --git a/shared/src/common.rs b/shared/src/common.rs index bae4184..13f1043 100644 --- a/shared/src/common.rs +++ b/shared/src/common.rs @@ -169,7 +169,7 @@ impl Default for UserProfileStatus { impl UserProfile { pub fn new( raw_profile: RawUserProfile, - lifetime: Duration, + expires_at: DateTime, secret: &[u8], ) -> anyhow::Result { let finished_profile = raw_profile.info.is_profile_finished(); @@ -188,7 +188,7 @@ impl UserProfile { info, .. } if finished_profile => { - let claims = convert_to_claims_with_expiration(&info, Utc::now() + lifetime)?; + let claims = convert_to_claims_with_expiration(&info, expires_at)?; let status = jsonwebtoken::encode( &jsonwebtoken::Header::default(), @@ -228,6 +228,27 @@ impl UserProfile { pub fn is_verification_blocked(&self) -> bool { matches!(self.status, UserProfileStatus::Blocked { blocked_until } if blocked_until > Utc::now().timestamp_millis() as u64) } + + pub fn is_complete(&self, secret: &[u8]) -> bool { + // Check if user profile status is complete + if let UserProfileStatus::Complete(CompletionToken { token }) = &self.status { + let validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::default()); + + // User profile verification JWT token check + let Ok(token_data) = jsonwebtoken::decode::( + token, + &jsonwebtoken::DecodingKey::from_secret(secret), + &validation, + ) else { + return false; + }; + + // Check that user profile verification token corresponds to the same wallet + token_data.claims.wallet == self.info.wallet + } else { + false + } + } } impl TryFrom for UserInfo { @@ -483,9 +504,14 @@ pub struct UserDbConfig { #[cfg(test)] mod tests { - use crate::common::CompletionToken; + use std::{str::FromStr, time::Duration}; + + use chrono::Utc; + use ethereum_types::Address; + use serde_email::Email; use super::{RawUserProfile, UserProfile, UserProfileStatus}; + use crate::common::{CompletionToken, UserInfo}; #[test] fn test_de_raw_user_profile() { @@ -587,4 +613,188 @@ mod tests { }) if token.as_str() == "some_verification_token" ); } + + #[test] + fn test_user_profile_completion() { + struct TestCase { + title: &'static str, + input: UserProfile, + expected: bool, + } + + let test_cases = [ + TestCase { + title: "User profile is completed", + input: UserProfile::new( + RawUserProfile { + quiz_solved: Some(true), + blocked_until: None, + info: default_user_info(), + }, + Utc::now() + Duration::from_secs(300), + "test".as_bytes(), + ) + .unwrap(), + expected: true, + }, + TestCase { + title: "User profile is not completed (missed bio)", + input: UserProfile::new( + RawUserProfile { + quiz_solved: Some(true), + blocked_until: None, + info: UserInfo { + bio: None, + ..default_user_info() + }, + }, + Utc::now() + Duration::from_secs(300), + "test".as_bytes(), + ) + .unwrap(), + expected: false, + }, + TestCase { + title: "User profile is not completed (missed name)", + input: UserProfile::new( + RawUserProfile { + quiz_solved: Some(true), + blocked_until: None, + info: UserInfo { + name: None, + ..default_user_info() + }, + }, + Utc::now() + Duration::from_secs(300), + "test".as_bytes(), + ) + .unwrap(), + expected: false, + }, + TestCase { + title: "User profile is not completed (missed role)", + input: UserProfile::new( + RawUserProfile { + quiz_solved: Some(true), + blocked_until: None, + info: UserInfo { + role: None, + ..default_user_info() + }, + }, + Utc::now() + Duration::from_secs(300), + "test".as_bytes(), + ) + .unwrap(), + expected: false, + }, + TestCase { + title: "User profile is not completed (missed avatar)", + input: UserProfile::new( + RawUserProfile { + quiz_solved: Some(true), + blocked_until: None, + info: UserInfo { + avatar: None, + ..default_user_info() + }, + }, + Utc::now() + Duration::from_secs(300), + "test".as_bytes(), + ) + .unwrap(), + expected: false, + }, + TestCase { + title: "User profile is not completed (quiz not solved)", + input: UserProfile::new( + RawUserProfile { + quiz_solved: Some(false), + blocked_until: None, + info: default_user_info(), + }, + Utc::now() + Duration::from_secs(300), + "test".as_bytes(), + ) + .unwrap(), + expected: false, + }, + TestCase { + title: "User profile is not completed (blocked)", + input: UserProfile::new( + RawUserProfile { + quiz_solved: Some(true), + blocked_until: Some( + (Utc::now() + Duration::from_secs(300)).timestamp_millis() as u64, + ), + info: UserInfo { + bio: None, + ..default_user_info() + }, + }, + Utc::now() + Duration::from_secs(300), + "test".as_bytes(), + ) + .unwrap(), + expected: false, + }, + TestCase { + title: "User profile is not completed (invalid secret)", + input: UserProfile::new( + RawUserProfile { + quiz_solved: Some(true), + blocked_until: None, + info: default_user_info(), + }, + Utc::now() + Duration::from_secs(300), + "unknown_secret".as_bytes(), + ) + .unwrap(), + expected: false, + }, + TestCase { + title: "User profile is not completed (expired token)", + input: UserProfile::new( + RawUserProfile { + quiz_solved: Some(true), + blocked_until: None, + info: default_user_info(), + }, + Utc::now() - Duration::from_secs(300), + "test".as_bytes(), + ) + .unwrap(), + expected: false, + }, + ]; + + for ( + i, + TestCase { + title, + input, + expected, + }, + ) in test_cases.into_iter().enumerate() + { + assert_eq!( + input.is_complete("test".as_bytes()), + expected, + "Test case #{i} '{title}' failed!" + ); + } + } + + fn default_user_info() -> UserInfo { + UserInfo { + wallet: Address::from_low_u64_le(0), + name: Some("test".to_owned()), + role: Some("test".to_owned()), + email: Some(Email::from_str("test@test.com").unwrap()), + telegram: None, + twitter: None, + bio: Some("test bio".to_owned()), + avatar: Some(url::Url::from_str("http://test.com").unwrap()), + } + } } diff --git a/shared/src/utils.rs b/shared/src/utils.rs index 130da2a..9019769 100644 --- a/shared/src/utils.rs +++ b/shared/src/utils.rs @@ -201,7 +201,7 @@ pub fn convert_to_claims_with_expiration( let mut m = m.clone(); m.insert( "exp".to_owned(), - serde_json::Value::Number(expires_at.timestamp_millis().into()), + serde_json::Value::Number(expires_at.timestamp().into()), ); Ok(serde_json::Value::Object(m)) } diff --git a/user-verifier/src/config.rs b/user-verifier/src/config.rs index 334b6ca..50e81a3 100644 --- a/user-verifier/src/config.rs +++ b/user-verifier/src/config.rs @@ -8,4 +8,5 @@ pub struct AppConfig { pub listen_address: String, pub fractal: FractalConfig, pub signer: SignerConfig, + pub users_manager_secret: String, } diff --git a/user-verifier/src/server.rs b/user-verifier/src/server.rs index e7ea372..1056534 100644 --- a/user-verifier/src/server.rs +++ b/user-verifier/src/server.rs @@ -2,9 +2,7 @@ use axum::{extract::State, routing::post, Json, Router}; use chrono::Utc; use tower_http::cors::CorsLayer; -use shared::common::{ - UserInfo, UserProfile, UserProfileStatus, VerifyAccountRequest, VerifyAccountResponse, -}; +use shared::common::{UserProfile, UserProfileStatus, VerifyAccountRequest, VerifyAccountResponse}; use crate::{ config::AppConfig, error::AppError, fractal::FractalClient, signer::SbtRequestSigner, @@ -55,20 +53,20 @@ async fn verify_endpoint( tracing::debug!("Request: {req:?}"); let is_oauth_token = matches!(req.token, shared::common::TokenKind::OAuth { .. }); + let is_user_profile_complete = matches!( + req.user, + UserProfile { status: UserProfileStatus::Complete(_), .. } if req.user.is_complete(state.config.users_manager_secret.as_bytes()) + ); - let verified_user_res = match req.user { - UserProfile { - status: UserProfileStatus::Complete(_), - info: UserInfo { wallet, .. }, - } - | UserProfile { - info: UserInfo { wallet, .. }, - .. - } if is_oauth_token => state.client.fetch_and_verify_user(req.token, wallet).await, - _ => Err(AppError::VerificationNotAllowed), - }; + if !is_oauth_token && !is_user_profile_complete { + return Err(AppError::VerificationNotAllowed); + } - let result = match verified_user_res { + let result = match state + .client + .fetch_and_verify_user(req.token, req.user.info.wallet) + .await + { Ok(verified_user) => create_verify_account_response( &state.signer, req.user.info.wallet,