diff --git a/api/.env b/api/.env index 6fdab9a..b86a0c4 100644 --- a/api/.env +++ b/api/.env @@ -1,7 +1,7 @@ HOSTNAME=localhost PORT=42069 DOMAIN=http://api.debtpirate.app -RP_ID=api.debtpirate.app +RP_ID=debtpirate.app TOKEN_KEY=k4.local.hWoS2ZulK9xPEATtXH1Dvj_iynzqfUv5ER5_IFTg5-Q DATABASE_URL=postgres://debt_pirate:HRURqlUmtjIy@192.168.122.251/debt_pirate ASSETS_DIR=/home/zcdziura/Documents/Projects/debt-pirate/api/assets diff --git a/api/Cargo.toml b/api/Cargo.toml index 1668b76..8165497 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -14,8 +14,9 @@ axum = { version = "0.7", features = [ base64 = "0.22" dotenvy = "0.15" futures = "0.3" -http = "1.0.0" +http = "1.0" humantime = "2.1.0" +humantime-serde = "1.1" hyper = { version = "1.1", features = ["full"] } lettre = { version = "0.11", default-features = false, features = [ "builder", @@ -28,9 +29,10 @@ lettre = { version = "0.11", default-features = false, features = [ log = "0.4" num_cpus = "1.16" once_cell = "1.19" -pasetors = "0.6" +pasetors = "0.7" serde = { version = "1.0", features = ["derive", "rc", "std"] } serde_json = "1.0" +serde_with = "3.9" sqlx = { version = "0.8", features = [ "default", "chrono", @@ -38,8 +40,6 @@ sqlx = { version = "0.8", features = [ "runtime-tokio", ] } tokio = { version = "1.35", features = ["full"] } -tower = "0.4" -tower-http = { version = "0.5", features = ["full"] } -tower-sessions = "0.12.3" +tower = "0.5" +tower-http = { version = "0.6", features = ["full"] } uuid = { version = "1.10", features = ["serde", "v7"] } -webauthn-rs = { version = "0.5.0", features = ["danger-allow-state-serialisation"] } diff --git a/api/migrations/20231221181946_create-tables.up.sql b/api/migrations/20231221181946_create-tables.up.sql index cfb69bd..3d8a982 100644 --- a/api/migrations/20231221181946_create-tables.up.sql +++ b/api/migrations/20231221181946_create-tables.up.sql @@ -1,6 +1,6 @@ CREATE TABLE IF NOT EXISTS public.status ( - id SERIAL NOT NULL PRIMARY KEY, + id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name VARCHAR(255) NOT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), updated_at TIMESTAMP WITH TIME ZONE NULL @@ -20,7 +20,7 @@ VALUES CREATE TABLE IF NOT EXISTS public.user ( - id SERIAL NOT NULL PRIMARY KEY, + id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, username VARCHAR(255) NOT NULL, password VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL, @@ -35,7 +35,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS user_email_uniq_idx ON public.user(email); CREATE TABLE IF NOT EXISTS public.permission ( - id SERIAL NOT NULL PRIMARY KEY, + id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name VARCHAR(255) NOT NULL, status_id INT NOT NULL REFERENCES status(id) DEFAULT 1, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), @@ -46,7 +46,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS permission_name_uniq_idx ON public.permission( CREATE TABLE IF NOT EXISTS public.user_permission ( - id SERIAL NOT NULL PRIMARY KEY, + id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, permission_id INT NOT NULL REFERENCES permission(id), user_id INT NOT NULL REFERENCES public.user(id), status_id INT NOT NULL REFERENCES status(id) diff --git a/api/src/db/user.rs b/api/src/db/user.rs index eea68a8..e600a5d 100644 --- a/api/src/db/user.rs +++ b/api/src/db/user.rs @@ -1,23 +1,15 @@ use sqlx::prelude::FromRow; -use uuid::Uuid; + +use crate::models::AppError; use super::DbPool; #[derive(Debug)] pub struct NewUserEntity { + pub username: String, + pub password: String, pub email: String, pub name: String, - pub user_id: Uuid, -} - -impl NewUserEntity { - pub fn new(name: String, email: String) -> Self { - Self { - name, - email, - user_id: Uuid::now_v7(), - } - } } #[allow(dead_code)] @@ -26,40 +18,40 @@ pub struct UserEntity { pub id: i32, pub name: String, pub email: String, - pub user_id: Uuid, pub status_id: i32, } -// pub async fn _insert_new_user( -// pool: &DbPool, -// new_user: NewUserEntity, -// ) -> Result { -// let NewUserEntity { -// name, -// email, -// user_id, -// } = new_user; +pub async fn insert_new_user( + pool: &DbPool, + new_user: NewUserEntity, +) -> Result { + let NewUserEntity { + username, + password, + email, + name, + } = new_user; -// sqlx::query_as::<_, UserEntity>("INSERT INTO public.user (username, email, password, name) VALUES ($1, $2, $3, $4) RETURNING id, username, email, name, status_id;") -// .bind(username) -// .bind(email) -// .bind(password) -// .bind(name) -// .fetch_one(pool).await -// .map_err(|err| { -// eprintln!("Error inserting NewUserEntity {err:?}"); -// AppError::from(err) -// }) -// } + sqlx::query_as::<_, UserEntity>("INSERT INTO public.user (username, email, password, name) VALUES ($1, $2, $3, $4) RETURNING id, username, email, name, status_id;") + .bind(username) + .bind(email) + .bind(password) + .bind(name) + .fetch_one(pool).await + .map_err(|err| { + eprintln!("Error inserting NewUserEntity {err:?}"); + AppError::from(err) + }) +} -// pub async fn verify_user(pool: &DbPool, user_id: i32) -> Result<(), AppError> { -// sqlx::query("UPDATE public.user SET status_id = 1, updated_at = now() WHERE id = $1;") -// .bind(user_id) -// .execute(pool) -// .await -// .map_err(|err| { -// eprintln!("Error verifying user with id '{user_id}'."); -// AppError::from(err) -// }) -// .map(|_| ()) -// } +pub async fn verify_user(pool: &DbPool, user_id: i32) -> Result<(), AppError> { + sqlx::query("UPDATE public.user SET status_id = 1, updated_at = now() WHERE id = $1;") + .bind(user_id) + .execute(pool) + .await + .map_err(|err| { + eprintln!("Error verifying user with id '{user_id}'."); + AppError::from(err) + }) + .map(|_| ()) +} diff --git a/api/src/main.rs b/api/src/main.rs index d3c36c2..9663879 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -7,7 +7,7 @@ mod models; mod requests; mod services; -use db::create_connection_pool; +use db::{create_connection_pool, run_migrations}; use requests::start_app; use services::{start_emailer_service, UserConfirmationMessage}; use tokio::runtime::Handle; @@ -25,10 +25,10 @@ async fn main() { }; let pool = create_connection_pool(env.db_connection_uri()).await; - // if let Err(err) = run_migrations(&pool).await { - // eprintln!("{err:?}"); - // process::exit(2); - // } + if let Err(err) = run_migrations(&pool).await { + eprintln!("{err:?}"); + process::exit(2); + } start_emailer_service(Handle::current(), env.assets_dir(), rx); diff --git a/api/src/requests/mod.rs b/api/src/requests/mod.rs index 27c9afc..3c8b9db 100644 --- a/api/src/requests/mod.rs +++ b/api/src/requests/mod.rs @@ -1,10 +1,7 @@ mod user; -use std::sync::Arc; - use axum::Router; use tokio::net::TcpListener; -use webauthn_rs::prelude::*; use crate::{ db::DbPool, @@ -15,30 +12,11 @@ use crate::{ pub struct AppState { pool: DbPool, env: Environment, - webauthn: Arc, } impl AppState { pub fn new(pool: DbPool, env: Environment) -> Self { - let rp_id = env.rp_id(); - let rp_origin = env - .domain() - .parse::() - .expect("RP_ORIGIN must be in a valid domain name format"); - - let webauthn = Arc::new( - WebauthnBuilder::new(rp_id, &rp_origin) - .map(|builder| builder.allow_any_port(true)) - .and_then(WebauthnBuilder::build) - .inspect_err(|err| eprintln!("{err}")) - .expect("Unable to build authenticator"), - ); - - Self { - pool, - env, - webauthn, - } + Self { pool, env } } pub fn pool(&self) -> &DbPool { @@ -48,10 +26,6 @@ impl AppState { pub fn env(&self) -> &Environment { &self.env } - - pub fn webauthn(&self) -> Arc { - Arc::clone(&self.webauthn) - } } pub async fn start_app(pool: DbPool, env: Environment) -> Result<(), AppError> { @@ -66,7 +40,6 @@ pub async fn start_app(pool: DbPool, env: Environment) -> Result<(), AppError> { axum::serve(listener, app) .await - .inspect(|_| println!("Application started successfully.")) .map_err(AppError::app_startup)?; Ok(()) diff --git a/api/src/requests/user/create/handler.rs b/api/src/requests/user/create/handler.rs new file mode 100644 index 0000000..562155b --- /dev/null +++ b/api/src/requests/user/create/handler.rs @@ -0,0 +1,78 @@ +use std::time::Duration; + +use crate::{ + db::{insert_new_user, NewUserEntity, UserEntity}, + models::ApiResponse, + requests::AppState, + services::{auth_token::generate_token, hash_string, UserConfirmationMessage}, +}; +use axum::{ + extract::State, + response::{IntoResponse, Response}, + Json, +}; +use http::StatusCode; + +use super::models::{UserRegistrationRequest, UserRegistrationResponse}; + +static FIFTEEN_MINUTES: u64 = 60 * 15; + +pub async fn user_registration_post_handler( + State(app_state): State, + Json(request): Json, +) -> Result { + let UserRegistrationRequest { + username, + password, + email, + name, + } = request; + + let hashed_password = hash_string(password); + + let new_user = NewUserEntity { + username, + password: hashed_password.to_string(), + email, + name, + }; + + let UserEntity { id: user_id, name, email , ..} = insert_new_user(app_state.pool(), new_user).await + .map_err(|err| { + if err.is_duplicate_record() { + (StatusCode::CONFLICT, ApiResponse::error("There is already an account associated with this username or email address.").into_json_response()).into_response() + } else { + (StatusCode::INTERNAL_SERVER_ERROR, ApiResponse::error("An error occurred while creating your new user account. Please try again later.").into_json_response()).into_response() + } + })?; + + let signing_key = app_state.env().token_key(); + let (auth_token, expiration) = generate_token( + signing_key, + user_id, + Some(Duration::from_secs(FIFTEEN_MINUTES)), + Some("user-verify.debtpirate.bikeshedengineering.internal"), + ); + + let new_user_confirmation_message = + UserConfirmationMessage::new(email.as_str(), name.as_str(), auth_token.as_str()); + + let _ = app_state + .env() + .email_sender() + .send(new_user_confirmation_message) + .inspect_err(|err| { + eprintln!("Got the rollowing error while sending across the channel: {err}"); + }); + + let response = ( + StatusCode::CREATED, + ApiResponse::new(UserRegistrationResponse { + id: user_id, + expiration, + }) + .into_json_response(), + ); + + Ok(response.into_response()) +} diff --git a/api/src/requests/user/create/mod.rs b/api/src/requests/user/create/mod.rs new file mode 100644 index 0000000..f00e7ca --- /dev/null +++ b/api/src/requests/user/create/mod.rs @@ -0,0 +1,4 @@ +mod handler; +mod models; + +pub use handler::*; diff --git a/api/src/requests/user/create/models/mod.rs b/api/src/requests/user/create/models/mod.rs new file mode 100644 index 0000000..d30e4ef --- /dev/null +++ b/api/src/requests/user/create/models/mod.rs @@ -0,0 +1,5 @@ +mod registration_request; +mod registration_response; + +pub use registration_request::*; +pub use registration_response::*; diff --git a/api/src/requests/user/new_user/models/registration_request.rs b/api/src/requests/user/create/models/registration_request.rs similarity index 76% rename from api/src/requests/user/new_user/models/registration_request.rs rename to api/src/requests/user/create/models/registration_request.rs index 259506b..d1ac282 100644 --- a/api/src/requests/user/new_user/models/registration_request.rs +++ b/api/src/requests/user/create/models/registration_request.rs @@ -3,6 +3,8 @@ use serde::Deserialize; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UserRegistrationRequest { - pub name: String, + pub username: String, + pub password: String, pub email: String, + pub name: String, } diff --git a/api/src/requests/user/create/models/registration_response.rs b/api/src/requests/user/create/models/registration_response.rs new file mode 100644 index 0000000..651d11d --- /dev/null +++ b/api/src/requests/user/create/models/registration_response.rs @@ -0,0 +1,13 @@ +use std::time::SystemTime; + +use serde::Serialize; +use serde_with::serde_as; + +#[serde_as] +#[derive(Debug, Serialize)] +pub struct UserRegistrationResponse { + pub id: i32, + + #[serde(serialize_with = "humantime_serde::serialize")] + pub expiration: SystemTime, +} diff --git a/api/src/requests/user/mod.rs b/api/src/requests/user/mod.rs index c920847..999db39 100644 --- a/api/src/requests/user/mod.rs +++ b/api/src/requests/user/mod.rs @@ -1,29 +1,14 @@ -use axum::Router; -use tower::ServiceBuilder; -use tower_sessions::{ - cookie::{time::Duration, SameSite}, - Expiry, MemoryStore, SessionManagerLayer, -}; +use axum::{routing::post, Router}; +use create::user_registration_post_handler; use super::AppState; -pub mod new_user; +pub mod create; // pub mod verify; pub fn requests(app_state: AppState) -> Router { - let domain = app_state.env().domain().to_owned(); - let user_requests_middleware = ServiceBuilder::new().layer( - SessionManagerLayer::new(MemoryStore::default()) - .with_domain(domain) - .with_secure(false) - .with_same_site(SameSite::Strict) - .with_expiry(Expiry::OnInactivity(Duration::seconds(300))), - ); - - Router::new() - .nest( - "/user", - Router::new().merge(new_user::request(app_state.clone())), // .merge(verify::request(app_state.clone())), - ) - .layer(user_requests_middleware) + Router::new().route( + "/user", + post(user_registration_post_handler).with_state(app_state.clone()), + ) } diff --git a/api/src/requests/user/new_user/mod.rs b/api/src/requests/user/new_user/mod.rs deleted file mode 100644 index 8db0890..0000000 --- a/api/src/requests/user/new_user/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -mod models; -mod request; - -pub use request::*; diff --git a/api/src/requests/user/new_user/models/mod.rs b/api/src/requests/user/new_user/models/mod.rs deleted file mode 100644 index 4000572..0000000 --- a/api/src/requests/user/new_user/models/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -mod registration_request; - -pub use registration_request::*; diff --git a/api/src/requests/user/new_user/request.rs b/api/src/requests/user/new_user/request.rs deleted file mode 100644 index 6a5e6b2..0000000 --- a/api/src/requests/user/new_user/request.rs +++ /dev/null @@ -1,78 +0,0 @@ -use std::time::Duration; - -use axum::{ - extract::State, - response::{IntoResponse, Response}, - routing::post, - Json, Router, -}; -use http::StatusCode; -use tower_sessions::Session; -use uuid::Uuid; -use webauthn_rs::prelude::CreationChallengeResponse; - -use crate::{ - db::NewUserEntity, - models::ApiResponse, - requests::AppState, - services::{auth_token::generate_token, UserConfirmationMessage}, -}; - -use super::models::UserRegistrationRequest; - -static FIFTEEN_MINUTES: u64 = 60 * 15; - -pub fn request(app_state: AppState) -> Router { - Router::new() - .route("/", post(user_registration_post_handler)) - .with_state(app_state) -} - -async fn user_registration_post_handler( - State(app_state): State, - session: Session, - Json(request): Json, -) -> Result { - let _ = session.remove_value("reg_state").await; - - let UserRegistrationRequest { name, email } = request; - let user_uuid = Uuid::now_v7(); - - // TODO: Fetch any already saved credentials for the given user for exclusion - let (creation_challenge_response, reg) = app_state - .webauthn() - .start_passkey_registration(user_uuid, email.as_str(), name.as_str(), None) - .expect("Invalid passkey registration"); - - // let new_user = NewUserEntity::new(email.clone(), name.clone()); - // let UserEntity { id: user_id, name, email , ..} = insert_new_user(app_state.pool(), new_user).await - // .map_err(|err| { - // if err.is_duplicate_record() { - // (StatusCode::CONFLICT, ApiResponse::error("There is already an account associated with this username or email address.").into_json_response()).into_response() - // } else { - // (StatusCode::INTERNAL_SERVER_ERROR, ApiResponse::error("An error occurred while creating your new user account. Please try again later.").into_json_response()).into_response() - // } - // })?; - - // let new_user_confirmation_message = - // UserConfirmationMessage::new(email.as_str(), name.as_str(), auth_token.as_str()); - - // let _ = app_state - // .env() - // .email_sender() - // .send(new_user_confirmation_message) - // .inspect_err(|err| { - // eprintln!("Got the rollowing error while sending across the channel: {err}"); - // }); - - // let new_user_entity = NewUserEntity::new(name, email); - - let response = ( - StatusCode::OK, - Json(ApiResponse::::new( - creation_challenge_response, - )), - ); - - Ok(response.into_response()) -} diff --git a/api/src/services/auth_token.rs b/api/src/services/auth_token.rs index bdf40c2..8ed35f7 100644 --- a/api/src/services/auth_token.rs +++ b/api/src/services/auth_token.rs @@ -78,7 +78,11 @@ pub fn generate_token( .token_identifier(Uuid::now_v7().to_string().as_str()) .map(|_| claims) }) - .and_then(|mut claims| claims.issuer("auth-test").map(|_| claims)) + .and_then(|mut claims| { + claims + .issuer("debtpirate.bikeshedengineering.internal") + .map(|_| claims) + }) .and_then(|mut claims| claims.subject(user_id.to_string().as_str()).map(|_| claims)) .and_then(|mut claims| { if let Some(audience) = audience { diff --git a/api/src/services/hasher.rs b/api/src/services/hasher.rs index 2c4b59c..a4a0f5f 100644 --- a/api/src/services/hasher.rs +++ b/api/src/services/hasher.rs @@ -3,7 +3,7 @@ use argon2::{ Argon2, }; -pub fn hash_string(string: &str) -> PasswordHashString { +pub fn hash_string(string: String) -> PasswordHashString { let algorithm = Argon2::default(); let salt = SaltString::generate(&mut OsRng);