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
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
MAINTENANCE_USER_ACCOUNT=dreadnought_maintenance:HRURqlUmtjIy
MAINTENANCE_USER_ACCOUNT=debt_pirate:HRURqlUmtjIy

View file

@ -5,22 +5,41 @@ edition = "2021"
[dependencies]
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"
dotenvy = "0.15"
futures = "0.3"
http = "1.0.0"
humantime = "2.1.0"
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"
num_cpus = "1.16"
once_cell = "1.19"
pasetors = "0.6"
serde = { version = "1.0", features = ["derive", "rc", "std"] }
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"] }
tower = "0.4"
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 uuid::Uuid;
use crate::error::AppError;
@ -7,24 +7,17 @@ use super::DbPool;
#[derive(Debug)]
pub struct NewUserEntity {
pub username: String,
pub email: String,
pub password: String,
pub name: String,
pub user_id: Uuid,
}
impl NewUserEntity {
pub fn new(
username: String,
email: String,
password: PasswordHashString,
name: String,
) -> Self {
pub fn new(name: String, email: String) -> Self {
Self {
username,
email,
password: password.as_str().to_owned(),
name,
email,
user_id: Uuid::now_v7(),
}
}
}
@ -33,43 +26,42 @@ impl NewUserEntity {
#[derive(Debug, FromRow)]
pub struct UserEntity {
pub id: i32,
pub username: String,
pub email: String,
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<UserEntity, AppError> {
let NewUserEntity {
username,
email,
password,
name,
} = new_user;
// pub async fn _insert_new_user(
// pool: &DbPool,
// new_user: NewUserEntity,
// ) -> Result<UserEntity, AppError> {
// let NewUserEntity {
// name,
// email,
// user_id,
// } = 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(|_| ())
// }

View file

@ -8,7 +8,7 @@ mod models;
mod requests;
mod services;
use db::{create_connection_pool, run_migrations};
use db::create_connection_pool;
use requests::start_app;
use services::{start_emailer_service, UserConfirmationMessage};
use tokio::runtime::Handle;
@ -26,10 +26,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);

View file

@ -19,6 +19,10 @@ static REQUIRED_ENV_VARS: Lazy<HashSet<String>> = Lazy::new(|| {
#[derive(Clone)]
pub struct Environment {
hostname: String,
port: u32,
domain: String,
rp_id: String,
token_key: SymmetricKey<V4>,
database_url: String,
email_sender: Sender<UserConfirmationMessage>,
@ -33,19 +37,34 @@ impl Environment {
.filter_map(|item| item.ok())
.filter(|(key, _)| REQUIRED_ENV_VARS.contains(key))
.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),
"DATABASE_URL" => builder.with_database_url(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))
} else {
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> {
&self.token_key
}
@ -66,6 +85,10 @@ impl Environment {
impl From<EnvironmentObjectBuilder> for Environment {
fn from(builder: EnvironmentObjectBuilder) -> Self {
let EnvironmentObjectBuilder {
hostname,
port,
domain,
rp_id,
token_key,
database_url,
email_sender,
@ -73,6 +96,10 @@ impl From<EnvironmentObjectBuilder> for Environment {
} = builder;
Self {
hostname: hostname.unwrap(),
port: port.unwrap(),
domain: domain.unwrap(),
rp_id: rp_id.unwrap(),
token_key: token_key.unwrap(),
database_url: database_url.unwrap(),
email_sender: email_sender.unwrap(),
@ -83,6 +110,10 @@ impl From<EnvironmentObjectBuilder> for Environment {
#[derive(Default)]
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 database_url: Option<String>,
pub email_sender: Option<Sender<UserConfirmationMessage>>,
@ -98,15 +129,20 @@ impl EnvironmentObjectBuilder {
}
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() {
missing_vars.push("TOKEN_KEY");
}
if self.database_url.is_none() {
missing_vars.push("DATABASE_URL");
}
if self.assets_dir.is_none() {
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) {
match SymmetricKey::<V4>::try_from(key.as_str()).map_err(|_| AppError::token_key()) {
Ok(key) => self.token_key = Some(key),

View file

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

View file

@ -1,15 +1,29 @@
use axum::Router;
use tower::ServiceBuilder;
use tower_sessions::{
cookie::{time::Duration, SameSite},
Expiry, MemoryStore, SessionManagerLayer,
};
use super::AppState;
pub mod new_user;
pub mod verify;
// pub mod verify;
pub fn requests(app_state: AppState) -> Router {
Router::new().nest(
"/user",
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()
.merge(new_user::request(app_state.clone()))
.merge(verify::request(app_state.clone())),
.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 post_response;
mod registration_request;
mod registration_response;
pub use post_request::*;
pub use post_response::*;
pub use registration_request::*;
pub use registration_response::*;

View file

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

View file

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

View file

@ -7,69 +7,61 @@ use axum::{
Json, Router,
};
use http::StatusCode;
use tower_sessions::Session;
use crate::{
db::{insert_new_user, NewUserEntity, UserEntity},
db::NewUserEntity,
models::ApiResponse,
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;
pub fn request(app_state: AppState) -> Router {
Router::new()
.route("/", post(user_post_handler))
.route("/", post(user_registration_post_handler))
.with_state(app_state)
}
async fn user_post_handler(
async fn user_registration_post_handler(
State(app_state): State<AppState>,
Json(request): Json<UserPostRequest>,
session: Session,
Json(request): Json<UserRegistrationRequest>,
) -> Result<Response, Response> {
let UserPostRequest {
username,
password,
email,
name,
} = request;
session.remove_value("reg_state");
let hashed_password = hash_string(password.as_str());
let new_user = NewUserEntity::new(username, email.clone(), hashed_password, 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 UserRegistrationRequest { name, email } = request;
// 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 (auth_token, expiration) = generate_token(
app_state.env().token_key(),
user_id,
Some(Duration::from_secs(FIFTEEN_MINUTES)),
Some(format!("/user/{user_id}/verify").as_str()),
);
// let new_user_confirmation_message =
// UserConfirmationMessage::new(email.as_str(), name.as_str(), auth_token.as_str());
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 _ = 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::CREATED,
Json(ApiResponse::<UserPostResponse>::new(UserPostResponse::new(
user_id, auth_token, expiration,
))),
Json(ApiResponse::<UserRegistrationResponse>::new(
UserRegistrationResponse::new(user_id, auth_token, expiration),
)),
);
Ok(response.into_response())