From f0f98a949695506591279adb3439511ad2242aab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=ADcolas=20Varela=20Auler?= Date: Sun, 25 Aug 2024 22:45:01 -0300 Subject: [PATCH] feat: adding mfa data apis (#125) * refa: extracting set_otp_secret to a feature this is just a glimpse of a restructure into feature, db and router/service layers I will do in the future * feat: adding mfa info route, which returns the mfa url * random: adding comment with clippy reason example --- src/data/router/auth.rs | 11 +++++-- src/data/service/auth.rs | 34 +++++++++++++++++++++ src/data/service/mod.rs | 1 + src/features/mod.rs | 1 + src/features/totp.rs | 48 ++++++++++++++++++++++++++++++ src/hypermedia/service/auth.rs | 54 ++++++++++------------------------ src/main.rs | 1 + src/schema.rs | 4 +++ 8 files changed, 114 insertions(+), 40 deletions(-) create mode 100644 src/data/service/auth.rs create mode 100644 src/features/mod.rs create mode 100644 src/features/totp.rs diff --git a/src/data/router/auth.rs b/src/data/router/auth.rs index b91312b..cc36fbe 100644 --- a/src/data/router/auth.rs +++ b/src/data/router/auth.rs @@ -1,12 +1,19 @@ use std::sync::Arc; use askama_axum::IntoResponse; -use axum::{extract::State, routing::post, Json, Router}; +use axum::{extract::State, routing::get, Json, Router}; use crate::{auth::AuthSession, hypermedia::schema::auth::MfaTokenForm, AppState}; pub fn private_router() -> Router> { - Router::new().route("/api/auth/mfa", post(mfa_verify)) + Router::new().route("/api/auth/mfa", get(mfa_info).post(mfa_verify)) +} + +async fn mfa_info( + auth_session: AuthSession, + State(shared_state): State>, +) -> impl IntoResponse { + crate::data::service::auth::mfa_info(auth_session, &shared_state.pool).await } async fn mfa_verify( diff --git a/src/data/service/auth.rs b/src/data/service/auth.rs new file mode 100644 index 0000000..2602787 --- /dev/null +++ b/src/data/service/auth.rs @@ -0,0 +1,34 @@ +use axum::{http::StatusCode, Json}; +use serde::{Deserialize, Serialize}; +use sqlx::{Pool, Postgres}; + +use crate::{auth::AuthSession, features::totp::set_otp_secret}; + +#[derive(Serialize, Deserialize)] +pub struct MfaInfo { + pub otp_url: String, +} + +pub async fn mfa_info( + auth_session: AuthSession, + db_pool: &Pool, +) -> Result, StatusCode> { + let Some(user) = auth_session.user else { + return Err(StatusCode::UNAUTHORIZED); + }; + tracing::info!("User logged in"); + + // TODO: create logic for changing MFA method + if user.otp_enabled { + todo!("Create logic for changing MFA method"); + } + + let totp = set_otp_secret(db_pool, user.id).await.map_err(|e| { + tracing::error!(?user.id, "Error setting OTP secret: {e}"); + return StatusCode::INTERNAL_SERVER_ERROR; + })?; + + Ok(Json(MfaInfo { + otp_url: totp.otp_url, + })) +} diff --git a/src/data/service/mod.rs b/src/data/service/mod.rs index 2624880..32ec6f3 100644 --- a/src/data/service/mod.rs +++ b/src/data/service/mod.rs @@ -1 +1,2 @@ +pub mod auth; pub mod expenses; diff --git a/src/features/mod.rs b/src/features/mod.rs new file mode 100644 index 0000000..ad7f142 --- /dev/null +++ b/src/features/mod.rs @@ -0,0 +1 @@ +pub mod totp; diff --git a/src/features/totp.rs b/src/features/totp.rs new file mode 100644 index 0000000..7b4603e --- /dev/null +++ b/src/features/totp.rs @@ -0,0 +1,48 @@ +use crate::util::generate_otp_token; +use anyhow::bail; +use sqlx::{Pool, Postgres}; +use totp_rs::{Algorithm, Secret, TOTP}; + +pub struct OtpData { + /// `qr_code` is a base64 encoded image that can be rendered embedded in an tag + pub qr_code: String, + /// `otp_url` is a otpauth:// url that can be rendered as a QR code + pub otp_url: String, +} + +pub async fn set_otp_secret(db_pool: &Pool, user_id: i32) -> anyhow::Result { + let secret = Secret::Raw(generate_otp_token().as_bytes().to_vec()); + let mut transaction = db_pool.begin().await?; + + let user_email = sqlx::query!( + r#" + UPDATE users SET otp_secret = $1 WHERE id = $2 + RETURNING email + "#, + secret.to_encoded().to_string(), + user_id + ) + .fetch_one(&mut *transaction) + .await?; + + let totp = TOTP::new( + Algorithm::SHA1, + 6, + 1, + 30, + secret.to_bytes().unwrap(), + Some("Finnish".to_owned()), + user_email.email, + )?; + + transaction.commit().await?; + + let Ok(qr_code) = totp.get_qr_base64() else { + bail!("Failed to generate QR code from totp"); + }; + + Ok(OtpData { + qr_code, + otp_url: totp.get_url(), + }) +} diff --git a/src/hypermedia/service/auth.rs b/src/hypermedia/service/auth.rs index 581ec12..3438451 100644 --- a/src/hypermedia/service/auth.rs +++ b/src/hypermedia/service/auth.rs @@ -4,6 +4,7 @@ use crate::{ frc::{validate_frc, verify_frc_solution}, mail::{send_forgot_password_mail, send_sign_up_confirmation_mail}, }, + features::totp::set_otp_secret, hypermedia::schema::{ auth::MailToUser, validation::{ChangePasswordInput, Exists, ForgotPasswordInput, ResendEmail, SignUpInput}, @@ -82,55 +83,32 @@ pub async fn signin( return (StatusCode::OK, [("HX-Redirect", "/")]).into_response(); } -pub async fn mfa_qr(auth_session: AuthSession, db_pool: &Pool) -> impl IntoResponse { +pub async fn mfa_qr( + auth_session: AuthSession, + db_pool: &Pool, +) -> Result, Response> { let Some(user) = auth_session.user else { - return (StatusCode::UNAUTHORIZED, [("HX-Redirect", "/auth/signin")]).into_response(); + return Err((StatusCode::UNAUTHORIZED, [("HX-Redirect", "/auth/signin")]).into_response()); }; - let user_id = user.id; tracing::debug!("User logged in"); + + // TODO: create logic for changing MFA method if user.otp_enabled { - // TODO todo!("Create logic for changing MFA method"); } - let secret = Secret::Raw(generate_otp_token().as_bytes().to_vec()); - let mut transaction = db_pool.begin().await.unwrap(); - - let user_email = sqlx::query!( - r#" - UPDATE users SET otp_secret = $1 WHERE id = $2 - RETURNING email - "#, - secret.to_encoded().to_string(), - user_id - ) - .fetch_one(&mut *transaction) - .await - .unwrap(); - - let totp = TOTP::new( - Algorithm::SHA1, - 6, - 1, - 30, - secret.to_bytes().unwrap(), - Some("Finnish".to_owned()), - user_email.email, - ) - .unwrap(); - let qr_code = totp.get_qr_base64().unwrap(); // qr_code is a base64 encoded image - // that can be rendered embedded in an tag - let otp_url = totp.get_url(); // otp_url is a otpauth:// url - // that can be rendered as a QR code + let totp = set_otp_secret(db_pool, user.id).await.map_err(|e| { + tracing::error!(?user.id, "Error setting OTP secret: {e}"); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + })?; - transaction.commit().await.unwrap(); - return MfaTemplate { + Ok(MfaTemplate { mfa_url: "/auth/mfa".to_owned(), - qr_code: format!("data:image/png;base64,{qr_code}"), - otp_auth_url: otp_url, + qr_code: format!("data:image/png;base64,{}", totp.qr_code), + otp_auth_url: totp.otp_url, ..Default::default() } - .into_response_with_nonce(); + .into_response_with_nonce()) } pub async fn mfa_verify( diff --git a/src/main.rs b/src/main.rs index 38b0fdd..6ccc39a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod client; mod constant; mod data; mod data_structs; +mod features; mod hypermedia; /// Module containing the database schemas and i/o schemas for hypermedia and data apis. mod schema; diff --git a/src/schema.rs b/src/schema.rs index d135c08..42ce036 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -102,6 +102,10 @@ pub struct UpdateExpense { /// Function to set the default value for `is_essential` in `UpdateExpense` to be Some(false). #[allow(clippy::unnecessary_wraps)] +//#[allow( +// clippy::unnecessary_wraps, +// reason = "Needs to return option for custom deserializer" +//)] const fn default_is_essential_to_false() -> Option { return Some(false); }