Beginning to rip apart the API

This commit is contained in:
Z. Charles Dziura 2024-08-22 17:29:24 -04:00
parent 4c752b4a0f
commit 5596724ba9
11 changed files with 205 additions and 122 deletions

View file

@ -1,4 +1,8 @@
HOSTNAME=localhost
PORT=42068
DOMAIN=http://localhost:42069
RP_ID=localhost
TOKEN_KEY=k4.local.hWoS2ZulK9xPEATtXH1Dvj_iynzqfUv5ER5_IFTg5-Q TOKEN_KEY=k4.local.hWoS2ZulK9xPEATtXH1Dvj_iynzqfUv5ER5_IFTg5-Q
DATABASE_URL=postgres://debt-pirate:test@192.168.122.241/debt_pirate DATABASE_URL=postgres://debt-pirate:test@192.168.122.215/debt_pirate
ASSETS_DIR=/home/zcdziura/Documents/Projects/debt-pirate/api/assets ASSETS_DIR=/home/zcdziura/Documents/Projects/debt-pirate/api/assets
MAINTENANCE_USER_ACCOUNT=dreadnought_maintenance:HRURqlUmtjIy MAINTENANCE_USER_ACCOUNT=debt_pirate:HRURqlUmtjIy

View file

@ -5,22 +5,41 @@ edition = "2021"
[dependencies] [dependencies]
argon2 = "0.5" argon2 = "0.5"
axum = { version = "0.7", features = ["default", "macros", "multipart", "ws"], default-features = false } axum = { version = "0.7", features = [
"default",
"macros",
"multipart",
"ws",
], default-features = false }
base64 = "0.22" base64 = "0.22"
dotenvy = "0.15" dotenvy = "0.15"
futures = "0.3" futures = "0.3"
http = "1.0.0" http = "1.0.0"
humantime = "2.1.0" humantime = "2.1.0"
hyper = { version = "1.1", features = ["full"] } hyper = { version = "1.1", features = ["full"] }
lettre = { version = "0.11", default-features = false, features = ["builder", "hostname", "pool", "smtp-transport", "tokio1", "tokio1-rustls-tls"] } lettre = { version = "0.11", default-features = false, features = [
"builder",
"hostname",
"pool",
"smtp-transport",
"tokio1",
"tokio1-rustls-tls",
] }
log = "0.4" log = "0.4"
num_cpus = "1.16" num_cpus = "1.16"
once_cell = "1.19" once_cell = "1.19"
pasetors = "0.6" pasetors = "0.6"
serde = { version = "1.0", features = ["derive", "rc", "std"] } serde = { version = "1.0", features = ["derive", "rc", "std"] }
serde_json = "1.0" serde_json = "1.0"
sqlx = { version = "0.7", features = ["default", "chrono", "postgres", "runtime-tokio"] } sqlx = { version = "0.8", features = [
"default",
"chrono",
"postgres",
"runtime-tokio",
] }
tokio = { version = "1.35", features = ["full"] } tokio = { version = "1.35", features = ["full"] }
tower = "0.4" tower = "0.4"
tower-http = { version = "0.5", features = ["full"] } tower-http = { version = "0.5", features = ["full"] }
uuid = { version = "1.8.0", features = ["v7", "fast-rng"] } tower-sessions = "0.12.3"
uuid = { version = "1.10", features = ["serde", "v7"] }
webauthn-rs = { version = "0.5.0", features = ["danger-allow-state-serialisation"] }

View file

@ -1,5 +1,5 @@
use argon2::password_hash::PasswordHashString;
use sqlx::prelude::FromRow; use sqlx::prelude::FromRow;
use uuid::Uuid;
use crate::error::AppError; use crate::error::AppError;
@ -7,24 +7,17 @@ use super::DbPool;
#[derive(Debug)] #[derive(Debug)]
pub struct NewUserEntity { pub struct NewUserEntity {
pub username: String,
pub email: String, pub email: String,
pub password: String,
pub name: String, pub name: String,
pub user_id: Uuid,
} }
impl NewUserEntity { impl NewUserEntity {
pub fn new( pub fn new(name: String, email: String) -> Self {
username: String,
email: String,
password: PasswordHashString,
name: String,
) -> Self {
Self { Self {
username,
email,
password: password.as_str().to_owned(),
name, name,
email,
user_id: Uuid::now_v7(),
} }
} }
} }
@ -33,43 +26,42 @@ impl NewUserEntity {
#[derive(Debug, FromRow)] #[derive(Debug, FromRow)]
pub struct UserEntity { pub struct UserEntity {
pub id: i32, pub id: i32,
pub username: String,
pub email: String,
pub name: String, pub name: String,
pub email: String,
pub user_id: Uuid,
pub status_id: i32, pub status_id: i32,
} }
pub async fn insert_new_user( // pub async fn _insert_new_user(
pool: &DbPool, // pool: &DbPool,
new_user: NewUserEntity, // new_user: NewUserEntity,
) -> Result<UserEntity, AppError> { // ) -> Result<UserEntity, AppError> {
let NewUserEntity { // let NewUserEntity {
username, // name,
email, // email,
password, // user_id,
name, // } = new_user;
} = 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;") // 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(username)
.bind(email) // .bind(email)
.bind(password) // .bind(password)
.bind(name) // .bind(name)
.fetch_one(pool).await // .fetch_one(pool).await
.map_err(|err| { // .map_err(|err| {
eprintln!("Error inserting NewUserEntity {err:?}"); // eprintln!("Error inserting NewUserEntity {err:?}");
AppError::from(err) // AppError::from(err)
}) // })
} // }
pub async fn verify_user(pool: &DbPool, user_id: i32) -> Result<(), AppError> { // 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;") // sqlx::query("UPDATE public.user SET status_id = 1, updated_at = now() WHERE id = $1;")
.bind(user_id) // .bind(user_id)
.execute(pool) // .execute(pool)
.await // .await
.map_err(|err| { // .map_err(|err| {
eprintln!("Error verifying user with id '{user_id}'."); // eprintln!("Error verifying user with id '{user_id}'.");
AppError::from(err) // AppError::from(err)
}) // })
.map(|_| ()) // .map(|_| ())
} // }

View file

@ -8,7 +8,7 @@ mod models;
mod requests; mod requests;
mod services; mod services;
use db::{create_connection_pool, run_migrations}; use db::create_connection_pool;
use requests::start_app; use requests::start_app;
use services::{start_emailer_service, UserConfirmationMessage}; use services::{start_emailer_service, UserConfirmationMessage};
use tokio::runtime::Handle; use tokio::runtime::Handle;
@ -26,10 +26,10 @@ async fn main() {
}; };
let pool = create_connection_pool(env.db_connection_uri()).await; let pool = create_connection_pool(env.db_connection_uri()).await;
if let Err(err) = run_migrations(&pool).await { // if let Err(err) = run_migrations(&pool).await {
eprintln!("{err:?}"); // eprintln!("{err:?}");
process::exit(2); // process::exit(2);
} // }
start_emailer_service(Handle::current(), env.assets_dir(), rx); start_emailer_service(Handle::current(), env.assets_dir(), rx);

View file

@ -19,6 +19,10 @@ static REQUIRED_ENV_VARS: Lazy<HashSet<String>> = Lazy::new(|| {
#[derive(Clone)] #[derive(Clone)]
pub struct Environment { pub struct Environment {
hostname: String,
port: u32,
domain: String,
rp_id: String,
token_key: SymmetricKey<V4>, token_key: SymmetricKey<V4>,
database_url: String, database_url: String,
email_sender: Sender<UserConfirmationMessage>, email_sender: Sender<UserConfirmationMessage>,
@ -33,19 +37,34 @@ impl Environment {
.filter_map(|item| item.ok()) .filter_map(|item| item.ok())
.filter(|(key, _)| REQUIRED_ENV_VARS.contains(key)) .filter(|(key, _)| REQUIRED_ENV_VARS.contains(key))
.for_each(|(key, value)| match key.as_str() { .for_each(|(key, value)| match key.as_str() {
"HOSTNAME" => builder.with_hostname(value),
"PORT" => builder.with_port(value),
"TOKEN_KEY" => builder.with_token_key(value), "TOKEN_KEY" => builder.with_token_key(value),
"DATABASE_URL" => builder.with_database_url(value), "DATABASE_URL" => builder.with_database_url(value),
"ASSETS_DIR" => builder.with_assets_dir(value), "ASSETS_DIR" => builder.with_assets_dir(value),
_ => {} _ => {}
}); });
if let Some(missing_vars) = builder.uninitialized_variables() { let missing_vars = builder.uninitialized_variables();
if let Some(missing_vars) = missing_vars {
Err(AppError::missing_environment_variables(missing_vars)) Err(AppError::missing_environment_variables(missing_vars))
} else { } else {
Ok(Environment::from(builder)) Ok(Environment::from(builder))
} }
} }
pub fn hostname(&self) -> &str {
self.hostname.as_str()
}
pub fn port(&self) -> u32 {
self.port
}
pub fn domain(&self) -> &str {
self.domain.as_str()
}
pub fn token_key(&self) -> &SymmetricKey<V4> { pub fn token_key(&self) -> &SymmetricKey<V4> {
&self.token_key &self.token_key
} }
@ -66,6 +85,10 @@ impl Environment {
impl From<EnvironmentObjectBuilder> for Environment { impl From<EnvironmentObjectBuilder> for Environment {
fn from(builder: EnvironmentObjectBuilder) -> Self { fn from(builder: EnvironmentObjectBuilder) -> Self {
let EnvironmentObjectBuilder { let EnvironmentObjectBuilder {
hostname,
port,
domain,
rp_id,
token_key, token_key,
database_url, database_url,
email_sender, email_sender,
@ -73,6 +96,10 @@ impl From<EnvironmentObjectBuilder> for Environment {
} = builder; } = builder;
Self { Self {
hostname: hostname.unwrap(),
port: port.unwrap(),
domain: domain.unwrap(),
rp_id: rp_id.unwrap(),
token_key: token_key.unwrap(), token_key: token_key.unwrap(),
database_url: database_url.unwrap(), database_url: database_url.unwrap(),
email_sender: email_sender.unwrap(), email_sender: email_sender.unwrap(),
@ -83,6 +110,10 @@ impl From<EnvironmentObjectBuilder> for Environment {
#[derive(Default)] #[derive(Default)]
pub struct EnvironmentObjectBuilder { pub struct EnvironmentObjectBuilder {
pub hostname: Option<String>,
pub port: Option<u32>,
pub domain: Option<String>,
pub rp_id: Option<String>,
pub token_key: Option<SymmetricKey<V4>>, pub token_key: Option<SymmetricKey<V4>>,
pub database_url: Option<String>, pub database_url: Option<String>,
pub email_sender: Option<Sender<UserConfirmationMessage>>, pub email_sender: Option<Sender<UserConfirmationMessage>>,
@ -98,15 +129,20 @@ impl EnvironmentObjectBuilder {
} }
pub fn uninitialized_variables(&self) -> Option<Vec<&'static str>> { pub fn uninitialized_variables(&self) -> Option<Vec<&'static str>> {
let mut missing_vars = Vec::with_capacity(3); let mut missing_vars = [
("HOSTNAME", self.hostname.as_deref()),
("DOMAIN", self.domain.as_deref()),
("RP_ID", self.rp_id.as_deref()),
("DATABASE_URL", self.database_url.as_deref()),
]
.into_iter()
.filter_map(|(key, value)| value.xor(Some(key)))
.collect::<Vec<&'static str>>();
if self.token_key.is_none() { if self.token_key.is_none() {
missing_vars.push("TOKEN_KEY"); missing_vars.push("TOKEN_KEY");
} }
if self.database_url.is_none() {
missing_vars.push("DATABASE_URL");
}
if self.assets_dir.is_none() { if self.assets_dir.is_none() {
missing_vars.push("ASSETS_DIR"); missing_vars.push("ASSETS_DIR");
} }
@ -118,6 +154,27 @@ impl EnvironmentObjectBuilder {
} }
} }
pub fn with_hostname(&mut self, hostname: String) {
self.hostname = Some(hostname);
}
pub fn with_port(&mut self, port: String) {
let port = port
.parse::<u32>()
.inspect_err(|err| eprintln!("Not a valid port, defaulting to '42069': {err}"))
.ok();
self.port = port;
}
pub fn with_domain(&mut self, domain: String) {
self.domain = Some(domain);
}
pub fn with_rp_id(&mut self, rp_id: String) {
self.rp_id = Some(rp_id);
}
pub fn with_token_key(&mut self, key: String) { pub fn with_token_key(&mut self, key: String) {
match SymmetricKey::<V4>::try_from(key.as_str()).map_err(|_| AppError::token_key()) { match SymmetricKey::<V4>::try_from(key.as_str()).map_err(|_| AppError::token_key()) {
Ok(key) => self.token_key = Some(key), Ok(key) => self.token_key = Some(key),

View file

@ -1,7 +1,10 @@
mod user; mod user;
use std::sync::Arc;
use axum::Router; use axum::Router;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use webauthn_rs::Webauthn;
use crate::{db::DbPool, error::AppError, models::Environment}; use crate::{db::DbPool, error::AppError, models::Environment};
@ -9,10 +12,13 @@ use crate::{db::DbPool, error::AppError, models::Environment};
pub struct AppState { pub struct AppState {
pool: DbPool, pool: DbPool,
env: Environment, env: Environment,
webauthn: Arc<Webauthn>,
} }
impl AppState { impl AppState {
pub fn new(pool: DbPool, env: Environment) -> Self { pub fn new(pool: DbPool, env: Environment) -> Self {
let rp_id =
Self { pool, env } Self { pool, env }
} }
@ -26,8 +32,8 @@ impl AppState {
} }
pub async fn start_app(pool: DbPool, env: Environment) -> Result<(), AppError> { pub async fn start_app(pool: DbPool, env: Environment) -> Result<(), AppError> {
let address = "localhost"; let address = env.hostname();
let port = "42069"; let port = env.port();
let listener = TcpListener::bind(format!("{address}:{port}")) let listener = TcpListener::bind(format!("{address}:{port}"))
.await .await
.unwrap(); .unwrap();

View file

@ -1,15 +1,29 @@
use axum::Router; use axum::Router;
use tower::ServiceBuilder;
use tower_sessions::{
cookie::{time::Duration, SameSite},
Expiry, MemoryStore, SessionManagerLayer,
};
use super::AppState; use super::AppState;
pub mod new_user; pub mod new_user;
pub mod verify; // pub mod verify;
pub fn requests(app_state: AppState) -> Router { pub fn requests(app_state: AppState) -> Router {
Router::new().nest( let domain = app_state.env().domain().to_owned();
"/user", let user_requests_middleware = ServiceBuilder::new().layer(
Router::new() SessionManagerLayer::new(MemoryStore::default())
.merge(new_user::request(app_state.clone())) .with_domain(domain)
.merge(verify::request(app_state.clone())), .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)
} }

View file

@ -1,5 +1,5 @@
mod post_request; mod registration_request;
mod post_response; mod registration_response;
pub use post_request::*; pub use registration_request::*;
pub use post_response::*; pub use registration_response::*;

View file

@ -1,9 +1,8 @@
use serde::Deserialize; use serde::Deserialize;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct UserPostRequest { #[serde(rename_all = "camelCase")]
pub username: String, pub struct UserRegistrationRequest {
pub password: String,
pub email: String,
pub name: String, pub name: String,
pub email: String,
} }

View file

@ -5,12 +5,12 @@ use serde::Serialize;
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct UserPostResponse { pub struct UserRegistrationResponse {
user_id: i32, user_id: i32,
auth: UserPostResponseToken, auth: UserPostResponseToken,
} }
impl UserPostResponse { impl UserRegistrationResponse {
pub fn new(user_id: i32, token: String, expiration: SystemTime) -> Self { pub fn new(user_id: i32, token: String, expiration: SystemTime) -> Self {
let auth = UserPostResponseToken { let auth = UserPostResponseToken {
token, token,

View file

@ -7,69 +7,61 @@ use axum::{
Json, Router, Json, Router,
}; };
use http::StatusCode; use http::StatusCode;
use tower_sessions::Session;
use crate::{ use crate::{
db::{insert_new_user, NewUserEntity, UserEntity}, db::NewUserEntity,
models::ApiResponse, models::ApiResponse,
requests::AppState, requests::AppState,
services::{auth_token::generate_token, hash_string, UserConfirmationMessage}, services::{auth_token::generate_token, UserConfirmationMessage},
}; };
use super::models::{UserPostRequest, UserPostResponse}; use super::models::{UserRegistrationRequest, UserRegistrationResponse};
static FIFTEEN_MINUTES: u64 = 60 * 15; static FIFTEEN_MINUTES: u64 = 60 * 15;
pub fn request(app_state: AppState) -> Router { pub fn request(app_state: AppState) -> Router {
Router::new() Router::new()
.route("/", post(user_post_handler)) .route("/", post(user_registration_post_handler))
.with_state(app_state) .with_state(app_state)
} }
async fn user_post_handler( async fn user_registration_post_handler(
State(app_state): State<AppState>, State(app_state): State<AppState>,
Json(request): Json<UserPostRequest>, session: Session,
Json(request): Json<UserRegistrationRequest>,
) -> Result<Response, Response> { ) -> Result<Response, Response> {
let UserPostRequest { session.remove_value("reg_state");
username,
password,
email,
name,
} = request;
let hashed_password = hash_string(password.as_str()); let UserRegistrationRequest { name, email } = request;
let new_user = NewUserEntity::new(username, email.clone(), hashed_password, name.clone()); // 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 // let UserEntity { id: user_id, name, email , ..} = insert_new_user(app_state.pool(), new_user).await
.map_err(|err| { // .map_err(|err| {
if err.is_duplicate_record() { // 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() // (StatusCode::CONFLICT, ApiResponse::error("There is already an account associated with this username or email address.").into_json_response()).into_response()
} else { // } 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() // (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 (auth_token, expiration) = generate_token( // let new_user_confirmation_message =
app_state.env().token_key(), // UserConfirmationMessage::new(email.as_str(), name.as_str(), auth_token.as_str());
user_id,
Some(Duration::from_secs(FIFTEEN_MINUTES)),
Some(format!("/user/{user_id}/verify").as_str()),
);
let new_user_confirmation_message = // let _ = app_state
UserConfirmationMessage::new(email.as_str(), name.as_str(), auth_token.as_str()); // .env()
// .email_sender()
// .send(new_user_confirmation_message)
// .inspect_err(|err| {
// eprintln!("Got the rollowing error while sending across the channel: {err}");
// });
let _ = app_state let new_user_entity = NewUserEntity::new(name, email);
.env()
.email_sender()
.send(new_user_confirmation_message)
.inspect_err(|err| {
eprintln!("Got the rollowing error while sending across the channel: {err}");
});
let response = ( let response = (
StatusCode::CREATED, StatusCode::CREATED,
Json(ApiResponse::<UserPostResponse>::new(UserPostResponse::new( Json(ApiResponse::<UserRegistrationResponse>::new(
user_id, auth_token, expiration, UserRegistrationResponse::new(user_id, auth_token, expiration),
))), )),
); );
Ok(response.into_response()) Ok(response.into_response())