diff --git a/api/.env b/api/.env new file mode 100644 index 0000000..187329b --- /dev/null +++ b/api/.env @@ -0,0 +1,4 @@ +TOKEN_KEY=k4.local.hWoS2ZulK9xPEATtXH1Dvj_iynzqfUv5ER5_IFTg5-Q +DATABASE_URL=postgres://debt-pirate:test@192.168.122.241/debt_pirate +ASSETS_DIR=/home/zcdziura/Documents/Projects/debt-pirate/api/assets +MAINTENANCE_USER_ACCOUNT=dreadnought_maintenance:HRURqlUmtjIy diff --git a/api/.env.template b/api/.env.template new file mode 100644 index 0000000..1065183 --- /dev/null +++ b/api/.env.template @@ -0,0 +1,3 @@ +TOKEN_KEY= +DB_CONNECTION_URI= +ASSETS_DIR= diff --git a/api/Cargo.toml b/api/Cargo.toml new file mode 100644 index 0000000..9795e2d --- /dev/null +++ b/api/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "auth-test" +version = "0.1.0" +edition = "2021" + +[dependencies] +argon2 = "0.5" +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"] } +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"] } +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"] } diff --git a/api/assets/new-user-confirmation.html b/api/assets/new-user-confirmation.html new file mode 100644 index 0000000..6941c18 --- /dev/null +++ b/api/assets/new-user-confirmation.html @@ -0,0 +1,181 @@ + + + + + + + Email Confirmation + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+

Welcome, $NAME!

+

Please confirm your email address.

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Click or tap the button below to confirm your email address. If you didn't create an account with Auth-Test, you can safely delete this email.

+
+ + + + +
+ + + + +
+ Confirm Email Address +
+
+
+

If that doesn't work, copy and paste the following link in your browser:

+

$AUTH_TOKEN

+
+

Thank you,
The Auth-Test Team

+
+ +
+ + + + diff --git a/api/build.rs b/api/build.rs new file mode 100644 index 0000000..3a8149e --- /dev/null +++ b/api/build.rs @@ -0,0 +1,3 @@ +fn main() { + println!("cargo:rerun-if-changed=migrations"); +} diff --git a/api/migrations/20231221181946_create-tables.down.sql b/api/migrations/20231221181946_create-tables.down.sql new file mode 100644 index 0000000..078db25 --- /dev/null +++ b/api/migrations/20231221181946_create-tables.down.sql @@ -0,0 +1,9 @@ +DROP INDEX IF EXISTS status_name_uniq_idx; +DROP INDEX IF EXISTS user_username_uniq_idx; +DROP INDEX IF EXISTS user_email_uniq_idx; +DROP INDEX IF EXISTS permission_name_uniq_idx; + +DROP TABLE IF EXISTS public.user_permission; +DROP TABLE IF EXISTS public.permission; +DROP TABLE IF EXISTS public.user; +DROP TABLE IF EXISTS public.status CASCADE; diff --git a/api/migrations/20231221181946_create-tables.up.sql b/api/migrations/20231221181946_create-tables.up.sql new file mode 100644 index 0000000..cfb69bd --- /dev/null +++ b/api/migrations/20231221181946_create-tables.up.sql @@ -0,0 +1,53 @@ +CREATE TABLE IF NOT EXISTS + public.status ( + id SERIAL NOT NULL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + updated_at TIMESTAMP WITH TIME ZONE NULL + ); + +CREATE UNIQUE INDEX IF NOT EXISTS status_name_uniq_idx ON public.status(name); + +INSERT INTO + public.status ( + name + ) +VALUES + ('Active'), + ('Unverified'), + ('Removed'), + ('Quaranteened'); + +CREATE TABLE IF NOT EXISTS + public.user ( + id SERIAL NOT NULL PRIMARY KEY, + username VARCHAR(255) NOT NULL, + password VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + status_id INT NOT NULL REFERENCES status(id) DEFAULT 2, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + updated_at TIMESTAMP WITH TIME ZONE NULL + ); + +CREATE UNIQUE INDEX IF NOT EXISTS user_username_uniq_idx ON public.user(username); +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, + 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(), + updated_at TIMESTAMP WITH TIME ZONE NULL + ); + +CREATE UNIQUE INDEX IF NOT EXISTS permission_name_uniq_idx ON public.permission(name); + +CREATE TABLE IF NOT EXISTS + public.user_permission ( + id SERIAL NOT NULL 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/requests/user-post.sh b/api/requests/user-post.sh new file mode 100755 index 0000000..c65beab --- /dev/null +++ b/api/requests/user-post.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +curl -d '{"username":"zcdziura","password":"test","email":"zachary@dziura.email","name":"Z. Charles Dziura"}' -H 'accept: application/url' -H 'content-type: application/json' -v http://localhost:42069/user diff --git a/api/src/db/mod.rs b/api/src/db/mod.rs new file mode 100644 index 0000000..b3bf63e --- /dev/null +++ b/api/src/db/mod.rs @@ -0,0 +1,22 @@ +mod user; + +use sqlx::{self, postgres::PgPoolOptions, Pool, Postgres}; +pub use user::*; + +use crate::error::AppError; + +pub type DbPool = Pool; + +pub async fn create_connection_pool(connection_uri: &str) -> DbPool { + let num_cpus = num_cpus::get() as u32; + + PgPoolOptions::new() + .max_connections(num_cpus) + .connect(connection_uri) + .await + .unwrap() +} + +pub async fn run_migrations(pool: &DbPool) -> Result<(), AppError> { + sqlx::migrate!().run(pool).await.map_err(AppError::from) +} diff --git a/api/src/db/user.rs b/api/src/db/user.rs new file mode 100644 index 0000000..ebe160e --- /dev/null +++ b/api/src/db/user.rs @@ -0,0 +1,75 @@ +use argon2::password_hash::PasswordHashString; +use sqlx::prelude::FromRow; + +use crate::error::AppError; + +use super::DbPool; + +#[derive(Debug)] +pub struct NewUserEntity { + pub username: String, + pub email: String, + pub password: String, + pub name: String, +} + +impl NewUserEntity { + pub fn new( + username: String, + email: String, + password: PasswordHashString, + name: String, + ) -> Self { + Self { + username, + email, + password: password.as_str().to_owned(), + name, + } + } +} + +#[allow(dead_code)] +#[derive(Debug, FromRow)] +pub struct UserEntity { + pub id: i32, + pub username: String, + pub email: String, + pub name: String, + pub status_id: i32, +} + +pub async fn insert_new_user( + pool: &DbPool, + new_user: NewUserEntity, +) -> Result { + let NewUserEntity { + username, + email, + password, + 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) + }) +} + +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/error.rs b/api/src/error.rs new file mode 100644 index 0000000..4bf2487 --- /dev/null +++ b/api/src/error.rs @@ -0,0 +1,135 @@ +use std::{borrow::Cow, error::Error, fmt::Display, io}; + +use axum::response::IntoResponse; +use http::StatusCode; +use sqlx::{migrate::MigrateError, Error as SqlxError}; + +#[derive(Debug)] +pub struct AppError { + kind: ErrorKind, +} + +impl AppError { + fn new(kind: ErrorKind) -> Self { + Self { kind } + } + + pub fn app_startup(error: io::Error) -> Self { + Self::new(ErrorKind::AppStartupError(error)) + } + + pub fn missing_environment_variables(missing_vars: Vec<&'static str>) -> Self { + Self::new(ErrorKind::MissingEnvironmentVariables(missing_vars)) + } + + pub fn invalid_token() -> Self { + Self::new(ErrorKind::InvalidToken) + } + + pub fn invalid_token_audience(audience: &str) -> Self { + Self::new(ErrorKind::InvalidTokenAudience(audience.to_owned())) + } + + pub fn token_key() -> Self { + Self::new(ErrorKind::TokenKey) + } + + pub fn is_duplicate_record(&self) -> bool { + matches!(self.kind, ErrorKind::DuplicateRecord) + } +} + +impl From for AppError { + fn from(kind: ErrorKind) -> Self { + Self::new(kind) + } +} + +impl From for AppError { + fn from(other: MigrateError) -> Self { + Self::new(ErrorKind::DbMigration(other)) + } +} + +impl From for AppError { + fn from(other: SqlxError) -> Self { + match &other { + SqlxError::Database(db_err) => { + if let Some(err_code) = db_err.code() { + map_db_error_code_to_error_kind(err_code) + } else { + ErrorKind::Sqlx(other) + } + } + _ => ErrorKind::Sqlx(other), + } + .into() + } +} + +impl Display for AppError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.kind { + ErrorKind::AppStartupError(err) => write!(f, "{err}"), + ErrorKind::Database => write!(f, "Unknown database error occurred."), + ErrorKind::DbMigration(err) => write!( + f, + "Error occurred while initializing connection to database: {err}" + ), + ErrorKind::DuplicateRecord => write!( + f, + "Error occurred while inserting a duplicate record into the database." + ), + ErrorKind::InvalidToken => write!(f, "The provided token is invalid."), + ErrorKind::InvalidTokenAudience(audience) => write!( + f, + "The provided token is not valid for this endpoint: '{audience}'." + ), + ErrorKind::MissingEnvironmentVariables(missing_vars) => write!( + f, + "Missing required environment variables: {}", + missing_vars.join(", ") + ), + ErrorKind::Sqlx(err) => write!(f, "{err}"), + ErrorKind::TokenKey => write!( + f, + "Invalid PASETO symmetric key; must be in valid PASERK format." + ), + } + } +} + +impl Error for AppError {} + +#[derive(Debug)] +enum ErrorKind { + AppStartupError(io::Error), + Database, + DbMigration(MigrateError), + DuplicateRecord, + InvalidToken, + InvalidTokenAudience(String), + MissingEnvironmentVariables(Vec<&'static str>), + Sqlx(SqlxError), + TokenKey, +} + +impl IntoResponse for AppError { + fn into_response(self) -> axum::response::Response { + match &self.kind { + ErrorKind::DuplicateRecord => (), + _ => (), + } + + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } +} + +fn map_db_error_code_to_error_kind(code: Cow) -> ErrorKind { + const UNIQUE_CONSTRAINT_VIOLATION: &str = "23505"; + if code == UNIQUE_CONSTRAINT_VIOLATION { + ErrorKind::DuplicateRecord + } else { + ErrorKind::Database + } +} diff --git a/api/src/main.rs b/api/src/main.rs new file mode 100644 index 0000000..0ccc58d --- /dev/null +++ b/api/src/main.rs @@ -0,0 +1,40 @@ +use std::{process, sync::mpsc::channel}; + +use models::Environment; + +mod db; +mod error; +mod models; +mod requests; +mod services; + +use db::{create_connection_pool, run_migrations}; +use requests::start_app; +use services::{start_emailer_service, UserConfirmationMessage}; +use tokio::runtime::Handle; + +#[tokio::main] +async fn main() { + let (tx, rx) = channel::(); + + let env = match Environment::init(tx) { + Ok(env) => env, + Err(err) => { + eprintln!("{err}"); + process::exit(1); + } + }; + + let pool = create_connection_pool(env.db_connection_uri()).await; + if let Err(err) = run_migrations(&pool).await { + eprintln!("{err:?}"); + process::exit(2); + } + + start_emailer_service(Handle::current(), env.assets_dir(), rx); + + if let Err(err) = start_app(pool, env).await { + eprintln!("{err:?}"); + process::exit(3); + } +} diff --git a/api/src/models/api_response.rs b/api/src/models/api_response.rs new file mode 100644 index 0000000..69267dd --- /dev/null +++ b/api/src/models/api_response.rs @@ -0,0 +1,59 @@ +use axum::Json; +use serde::Serialize; + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ApiResponse { + #[serde(rename = "_meta")] + #[serde(skip_serializing_if = "Option::is_none")] + pub meta: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option<&'static str>, +} + +impl ApiResponse { + pub fn new(data: T) -> ApiResponse { + Self { + meta: None, + data: Some(data), + error: None, + } + } + + pub fn _new_empty() -> ApiResponse { + Self { + meta: None, + data: None, + error: None, + } + } + + pub fn _new_with_metadata(data: T, meta: ApiResponseMetadata) -> ApiResponse { + Self { + meta: Some(meta), + data: Some(data), + error: None, + } + } + + pub fn into_json_response(self) -> Json> { + Json(self) + } +} + +impl ApiResponse<()> { + pub fn error(error: &'static str) -> Self { + Self { + meta: None, + data: None, + error: Some(error), + } + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct ApiResponseMetadata {} diff --git a/api/src/models/environment.rs b/api/src/models/environment.rs new file mode 100644 index 0000000..30fa27e --- /dev/null +++ b/api/src/models/environment.rs @@ -0,0 +1,146 @@ +use std::{ + collections::HashSet, + path::{Path, PathBuf}, + sync::mpsc::Sender, +}; + +use once_cell::sync::Lazy; +use pasetors::{keys::SymmetricKey, version4::V4}; + +use crate::{error::AppError, services::UserConfirmationMessage}; + +static REQUIRED_ENV_VARS: Lazy> = Lazy::new(|| { + HashSet::::from_iter( + ["TOKEN_KEY", "DATABASE_URL", "ASSETS_DIR"] + .into_iter() + .map(ToString::to_string), + ) +}); + +#[derive(Clone)] +pub struct Environment { + token_key: SymmetricKey, + database_url: String, + email_sender: Sender, + assets_dir: PathBuf, +} + +impl Environment { + pub fn init(email_sender: Sender) -> Result { + let mut builder = EnvironmentObjectBuilder::new(email_sender); + dotenvy::dotenv_iter() + .expect("Missing .env file") + .filter_map(|item| item.ok()) + .filter(|(key, _)| REQUIRED_ENV_VARS.contains(key)) + .for_each(|(key, value)| match key.as_str() { + "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() { + Err(AppError::missing_environment_variables(missing_vars)) + } else { + Ok(Environment::from(builder)) + } + } + + pub fn token_key(&self) -> &SymmetricKey { + &self.token_key + } + + pub fn db_connection_uri(&self) -> &str { + self.database_url.as_str() + } + + pub fn assets_dir(&self) -> &Path { + self.assets_dir.as_path() + } + + pub fn email_sender(&self) -> &Sender { + &self.email_sender + } +} + +impl From for Environment { + fn from(builder: EnvironmentObjectBuilder) -> Self { + let EnvironmentObjectBuilder { + token_key, + database_url, + email_sender, + assets_dir, + } = builder; + + Self { + token_key: token_key.unwrap(), + database_url: database_url.unwrap(), + email_sender: email_sender.unwrap(), + assets_dir: assets_dir.unwrap(), + } + } +} + +#[derive(Default)] +pub struct EnvironmentObjectBuilder { + pub token_key: Option>, + pub database_url: Option, + pub email_sender: Option>, + pub assets_dir: Option, +} + +impl EnvironmentObjectBuilder { + pub fn new(email_sender: Sender) -> Self { + Self { + email_sender: Some(email_sender), + ..Default::default() + } + } + + pub fn uninitialized_variables(&self) -> Option> { + let mut missing_vars = Vec::with_capacity(3); + 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"); + } + + if missing_vars.is_empty() { + None + } else { + Some(missing_vars) + } + } + + pub fn with_token_key(&mut self, key: String) { + match SymmetricKey::::try_from(key.as_str()).map_err(|_| AppError::token_key()) { + Ok(key) => self.token_key = Some(key), + Err(err) => panic!("{err}"), + }; + } + + pub fn with_database_url(&mut self, url: String) { + self.database_url = Some(url); + } + + pub fn with_assets_dir(&mut self, assets_dir_path: String) { + let assets_dir = PathBuf::from(assets_dir_path); + if !assets_dir.try_exists().unwrap_or_default() { + panic!( + "The 'ASSETS_DIR' environment variable points to a directory that doesn't exist." + ); + } + + if assets_dir.is_file() { + panic!("The 'ASSETS_DIR' environment variable must be set to a directory, not a file."); + } + + self.assets_dir = Some(assets_dir); + } +} diff --git a/api/src/models/mod.rs b/api/src/models/mod.rs new file mode 100644 index 0000000..87ffc86 --- /dev/null +++ b/api/src/models/mod.rs @@ -0,0 +1,5 @@ +mod api_response; +mod environment; + +pub use api_response::*; +pub use environment::*; diff --git a/api/src/requests/mod.rs b/api/src/requests/mod.rs new file mode 100644 index 0000000..b5cb126 --- /dev/null +++ b/api/src/requests/mod.rs @@ -0,0 +1,44 @@ +mod user; + +use axum::Router; +use tokio::net::TcpListener; + +use crate::{db::DbPool, error::AppError, models::Environment}; + +#[derive(Clone)] +pub struct AppState { + pool: DbPool, + env: Environment, +} + +impl AppState { + pub fn new(pool: DbPool, env: Environment) -> Self { + Self { pool, env } + } + + pub fn pool(&self) -> &DbPool { + &self.pool + } + + pub fn env(&self) -> &Environment { + &self.env + } +} + +pub async fn start_app(pool: DbPool, env: Environment) -> Result<(), AppError> { + let address = "localhost"; + let port = "42069"; + let listener = TcpListener::bind(format!("{address}:{port}")) + .await + .unwrap(); + + let app_state = AppState::new(pool, env); + let app = Router::new().merge(user::requests(app_state.clone())); + + axum::serve(listener, app) + .await + .map_err(AppError::app_startup)?; + + println!("Application started successfully."); + Ok(()) +} diff --git a/api/src/requests/user/mod.rs b/api/src/requests/user/mod.rs new file mode 100644 index 0000000..bee043d --- /dev/null +++ b/api/src/requests/user/mod.rs @@ -0,0 +1,15 @@ +use axum::Router; + +use super::AppState; + +pub mod new_user; +pub mod verify; + +pub fn requests(app_state: AppState) -> Router { + Router::new().nest( + "/user", + Router::new() + .merge(new_user::request(app_state.clone())) + .merge(verify::request(app_state.clone())), + ) +} diff --git a/api/src/requests/user/new_user/mod.rs b/api/src/requests/user/new_user/mod.rs new file mode 100644 index 0000000..8db0890 --- /dev/null +++ b/api/src/requests/user/new_user/mod.rs @@ -0,0 +1,4 @@ +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 new file mode 100644 index 0000000..c547d51 --- /dev/null +++ b/api/src/requests/user/new_user/models/mod.rs @@ -0,0 +1,5 @@ +mod post_request; +mod post_response; + +pub use post_request::*; +pub use post_response::*; diff --git a/api/src/requests/user/new_user/models/post_request.rs b/api/src/requests/user/new_user/models/post_request.rs new file mode 100644 index 0000000..6a9b934 --- /dev/null +++ b/api/src/requests/user/new_user/models/post_request.rs @@ -0,0 +1,9 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct UserPostRequest { + pub username: String, + pub password: String, + pub email: String, + pub name: String, +} diff --git a/api/src/requests/user/new_user/models/post_response.rs b/api/src/requests/user/new_user/models/post_response.rs new file mode 100644 index 0000000..91f5968 --- /dev/null +++ b/api/src/requests/user/new_user/models/post_response.rs @@ -0,0 +1,29 @@ +use std::time::SystemTime; + +use humantime::format_rfc3339_seconds; +use serde::Serialize; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UserPostResponse { + user_id: i32, + auth: UserPostResponseToken, +} + +impl UserPostResponse { + pub fn new(user_id: i32, token: String, expiration: SystemTime) -> Self { + let auth = UserPostResponseToken { + token, + expiration: format_rfc3339_seconds(expiration).to_string(), + }; + + Self { user_id, auth } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UserPostResponseToken { + pub token: String, + pub expiration: String, +} diff --git a/api/src/requests/user/new_user/request.rs b/api/src/requests/user/new_user/request.rs new file mode 100644 index 0000000..7270291 --- /dev/null +++ b/api/src/requests/user/new_user/request.rs @@ -0,0 +1,76 @@ +use std::time::Duration; + +use axum::{ + extract::State, + response::{IntoResponse, Response}, + routing::post, + Json, Router, +}; +use http::StatusCode; + +use crate::{ + db::{insert_new_user, NewUserEntity, UserEntity}, + models::ApiResponse, + requests::AppState, + services::{auth_token::generate_token, hash_string, UserConfirmationMessage}, +}; + +use super::models::{UserPostRequest, UserPostResponse}; + +static FIFTEEN_MINUTES: u64 = 60 * 15; + +pub fn request(app_state: AppState) -> Router { + Router::new() + .route("/", post(user_post_handler)) + .with_state(app_state) +} + +async fn user_post_handler( + State(app_state): State, + Json(request): Json, +) -> Result { + let UserPostRequest { + username, + password, + email, + name, + } = request; + + 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 (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 _ = 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, + Json(ApiResponse::::new(UserPostResponse::new( + user_id, auth_token, expiration, + ))), + ); + + Ok(response.into_response()) +} diff --git a/api/src/requests/user/verify/mod.rs b/api/src/requests/user/verify/mod.rs new file mode 100644 index 0000000..8f2640d --- /dev/null +++ b/api/src/requests/user/verify/mod.rs @@ -0,0 +1,5 @@ +mod models; +mod request; + +pub use models::*; +pub use request::*; diff --git a/api/src/requests/user/verify/models/mod.rs b/api/src/requests/user/verify/models/mod.rs new file mode 100644 index 0000000..b8be632 --- /dev/null +++ b/api/src/requests/user/verify/models/mod.rs @@ -0,0 +1,5 @@ +mod request; +mod response; + +pub use request::*; +pub use response::*; diff --git a/api/src/requests/user/verify/models/request.rs b/api/src/requests/user/verify/models/request.rs new file mode 100644 index 0000000..b7a1ad2 --- /dev/null +++ b/api/src/requests/user/verify/models/request.rs @@ -0,0 +1,7 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct UserVerifyGetRequest { + #[serde(alias = "t")] + pub auth_token: String, +} diff --git a/api/src/requests/user/verify/models/response.rs b/api/src/requests/user/verify/models/response.rs new file mode 100644 index 0000000..98f7e1c --- /dev/null +++ b/api/src/requests/user/verify/models/response.rs @@ -0,0 +1,35 @@ +use humantime::format_rfc3339_seconds; +use pasetors::{keys::SymmetricKey, version4::V4}; +use serde::Serialize; + +use crate::services::auth_token::{generate_access_token, generate_auth_token}; + +#[derive(Debug, Serialize)] +pub struct UserVerifyGetResponse { + access: UserVerifyGetResponseTokenAndExpiration, + auth: UserVerifyGetResponseTokenAndExpiration, +} + +impl UserVerifyGetResponse { + pub fn new(key: &SymmetricKey, user_id: i32) -> Self { + let (access_token, access_token_expiration) = generate_access_token(key, user_id); + let (auth_token, auth_token_expiration) = generate_auth_token(key, user_id); + + Self { + access: UserVerifyGetResponseTokenAndExpiration { + token: access_token, + expiration: format_rfc3339_seconds(access_token_expiration).to_string(), + }, + auth: UserVerifyGetResponseTokenAndExpiration { + token: auth_token, + expiration: format_rfc3339_seconds(auth_token_expiration).to_string(), + }, + } + } +} + +#[derive(Debug, Serialize)] +pub struct UserVerifyGetResponseTokenAndExpiration { + pub token: String, + pub expiration: String, +} diff --git a/api/src/requests/user/verify/request.rs b/api/src/requests/user/verify/request.rs new file mode 100644 index 0000000..542ab1a --- /dev/null +++ b/api/src/requests/user/verify/request.rs @@ -0,0 +1,48 @@ +use axum::{ + extract::{Path, Query, State}, + response::{IntoResponse, Response}, + routing::get, + Json, Router, +}; +use http::StatusCode; +use pasetors::claims::ClaimsValidationRules; + +use crate::{ + db::verify_user, models::ApiResponse, requests::AppState, services::auth_token::verify_token, +}; + +use super::{UserVerifyGetRequest, UserVerifyGetResponse}; + +pub fn request(app_state: AppState) -> Router { + Router::new().route("/:user_id/verify", get(get_handler).with_state(app_state)) +} + +async fn get_handler( + State(app_state): State, + Path(user_id): Path, + Query(request): Query, +) -> Result { + let UserVerifyGetRequest { auth_token } = request; + + let validation_rules = { + let mut rules = ClaimsValidationRules::new(); + rules.validate_audience_with(format!("/user/{user_id}/verify").as_str()); + + rules + }; + + let key = app_state.env().token_key(); + let response = verify_token(key, auth_token.as_str(), Some(validation_rules)) + .map(|_| UserVerifyGetResponse::new(key, user_id)) + .map_err(|err| err.into_response())?; + + verify_user(app_state.pool(), user_id) + .await + .map_err(|err| err.into_response())?; + + Ok(( + StatusCode::OK, + Json(ApiResponse::::new(response)), + ) + .into_response()) +} diff --git a/api/src/services/auth_token.rs b/api/src/services/auth_token.rs new file mode 100644 index 0000000..1300ee5 --- /dev/null +++ b/api/src/services/auth_token.rs @@ -0,0 +1,161 @@ +use std::time::{Duration, SystemTime}; + +use pasetors::{ + claims::{Claims, ClaimsValidationRules}, + footer::Footer, + keys::SymmetricKey, + local, + token::UntrustedToken, + version4::V4, +}; +use uuid::Uuid; + +use crate::error::AppError; + +static FOURTY_FIVE_DAYS: u64 = 3_888_000; // 60 * 60 * 24 * 45 +static ONE_HOUR: u64 = 3_600; + +pub fn verify_token( + key: &SymmetricKey, + token: &str, + validation_rules: Option, +) -> Result<(), AppError> { + let token = UntrustedToken::try_from(token).map_err(|_| AppError::invalid_token())?; + + let validation_rules = if let Some(validation_rules) = validation_rules { + validation_rules + } else { + ClaimsValidationRules::new() + }; + + let footer = { + let mut footer = Footer::new(); + footer.key_id(&key.into()); + footer + }; + + let _ = local::decrypt( + key, + &token, + &validation_rules, + Some(&footer), + Some("TODO_ENV_NAME_HERE".as_bytes()), + ) + .map_err(|_| AppError::invalid_token())?; + + Ok(()) +} + +pub fn generate_access_token(key: &SymmetricKey, user_id: i32) -> (String, SystemTime) { + generate_token( + key, + user_id, + Some(Duration::from_secs(FOURTY_FIVE_DAYS)), + None, + ) +} + +pub fn generate_auth_token(key: &SymmetricKey, user_id: i32) -> (String, SystemTime) { + generate_token(key, user_id, None, None) +} + +pub fn generate_token( + key: &SymmetricKey, + user_id: i32, + duration: Option, + audience: Option<&str>, +) -> (String, SystemTime) { + let now = SystemTime::now(); + let expiration = if let Some(duration) = duration { + duration + } else { + Duration::from_secs(ONE_HOUR) + }; + + let token = Claims::new_expires_in(&expiration) + .and_then(|mut claims| { + claims + .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.subject(user_id.to_string().as_str()).map(|_| claims)) + .and_then(|mut claims| { + if let Some(audience) = audience { + claims.audience(audience).map(|_| claims) + } else { + Ok(claims) + } + }) + .and_then(|claims| { + let footer = { + let mut footer = Footer::new(); + footer.key_id(&key.into()); + footer + }; + + local::encrypt( + &key, + &claims, + Some(&footer), + Some("TODO_ENV_NAME_HERE".as_bytes()), + ) + }) + .unwrap(); + + (token, now + expiration) +} + +#[cfg(test)] +mod tests { + use base64::prelude::*; + use pasetors::paserk::Id; + use serde_json::Value; + + use super::*; + + #[test] + fn test_does_verify_token_audience_claim() { + let zero = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + let key = BASE64_STANDARD + .decode(zero) + .map_err(|_| ()) + .and_then(|bytes| SymmetricKey::::from(bytes.as_slice()).map_err(|_| ())) + .unwrap(); + + let token = generate_token(&key, 1, Some(Duration::from_secs(60)), Some("testing")).0; + + let footer = { + let mut footer = Footer::new(); + footer.key_id(&Id::from(&key)); + footer + }; + + let validation_rules = { + let mut rules = ClaimsValidationRules::new(); + rules.validate_audience_with("testing"); + + rules + }; + + let trusted_token = local::decrypt( + &key, + &UntrustedToken::try_from(token.as_str()).unwrap(), + &validation_rules, + Some(&footer), + Some("TODO_ENV_NAME_HERE".as_bytes()), + ); + + assert!(trusted_token.is_ok()); + + let trusted_token = trusted_token.unwrap(); + let claims = trusted_token.payload_claims(); + assert!(claims.is_some()); + + let claims = claims.unwrap(); + assert_eq!( + claims.get_claim("aud"), + Some(&Value::String("testing".to_string())) + ); + } +} diff --git a/api/src/services/hasher.rs b/api/src/services/hasher.rs new file mode 100644 index 0000000..2c4b59c --- /dev/null +++ b/api/src/services/hasher.rs @@ -0,0 +1,14 @@ +use argon2::{ + password_hash::{rand_core::OsRng, PasswordHash, PasswordHashString, SaltString}, + Argon2, +}; + +pub fn hash_string(string: &str) -> PasswordHashString { + let algorithm = Argon2::default(); + let salt = SaltString::generate(&mut OsRng); + + let hashed_password = + PasswordHash::generate(algorithm, string.as_bytes(), salt.as_salt()).unwrap(); + + hashed_password.serialize() +} diff --git a/api/src/services/mailer/mod.rs b/api/src/services/mailer/mod.rs new file mode 100644 index 0000000..9b5a21c --- /dev/null +++ b/api/src/services/mailer/mod.rs @@ -0,0 +1,5 @@ +mod service; +mod user_confirmation_message; + +pub use service::*; +pub use user_confirmation_message::*; diff --git a/api/src/services/mailer/service.rs b/api/src/services/mailer/service.rs new file mode 100644 index 0000000..703c270 --- /dev/null +++ b/api/src/services/mailer/service.rs @@ -0,0 +1,96 @@ +use std::{fs::File, io::Read, path::Path, sync::mpsc::Receiver, thread}; + +use lettre::{ + message::{header::ContentType, Mailbox}, + transport::smtp::{ + authentication::{Credentials, Mechanism}, + PoolConfig, + }, + AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, +}; +use once_cell::sync::Lazy; +use tokio::runtime::Handle; + +use super::UserConfirmationMessage; + +static CREDENTIALS: Lazy = Lazy::new(|| { + Credentials::new( + "donotreply@mail.dziura.cloud".to_owned(), + "hunter2".to_owned(), + ) +}); + +static NUM_CPUS: Lazy = Lazy::new(|| num_cpus::get() as u32); + +static FROM_MAILBOX: Lazy = Lazy::new(|| { + "No Not Reply Auth-Test " + .parse() + .unwrap() +}); + +pub fn start_emailer_service( + runtime: Handle, + assets_dir: &Path, + email_message_rx: Receiver, +) { + let new_user_confirmation_template_path = assets_dir.join("new-user-confirmation.html"); + // TODO: Validate that the new user confirmation template exists + + let mut new_user_confirmation_template_text = String::with_capacity(8000); + File::open(new_user_confirmation_template_path) + .and_then(|mut file| file.read_to_string(&mut new_user_confirmation_template_text)) + .unwrap(); + + thread::spawn(move || { + while let Some(message) = email_message_rx.iter().next() { + let new_user_confirmation_template_text = new_user_confirmation_template_text.clone(); + let UserConfirmationMessage { + email: recipient_email, + name, + auth_token, + } = message; + + runtime.spawn(async move { + send_new_user_confirmation_email( + recipient_email.as_str(), + new_user_confirmation_template_text.as_str(), + name.as_str(), + auth_token.as_str(), + ) + .await; + }); + } + }); +} + +async fn send_new_user_confirmation_email( + recipient_email: &str, + new_user_confirmation_template_text: &str, + name: &str, + auth_token: &str, +) { + let body = new_user_confirmation_template_text + .replace("$NAME", name) + .replace("$AUTH_TOKEN", auth_token); + + let message = Message::builder() + .from(FROM_MAILBOX.clone()) + .reply_to(FROM_MAILBOX.clone()) + .to(recipient_email.parse().unwrap()) + .subject(format!( + "You're registered, {name}, please confirm your email address" + )) + .header(ContentType::TEXT_HTML) + .body(body) + .unwrap(); + + let sender: AsyncSmtpTransport = + AsyncSmtpTransport::::starttls_relay("mail.dziura.cloud") + .unwrap() + .credentials(CREDENTIALS.clone()) + .authentication(vec![Mechanism::Plain]) + .pool_config(PoolConfig::new().max_size(*NUM_CPUS)) + .build(); + + let _ = sender.send(message).await; +} diff --git a/api/src/services/mailer/user_confirmation_message.rs b/api/src/services/mailer/user_confirmation_message.rs new file mode 100644 index 0000000..91283de --- /dev/null +++ b/api/src/services/mailer/user_confirmation_message.rs @@ -0,0 +1,15 @@ +pub struct UserConfirmationMessage { + pub email: String, + pub name: String, + pub auth_token: String, +} + +impl UserConfirmationMessage { + pub fn new(email: &str, name: &str, auth_token: &str) -> Self { + Self { + email: email.to_owned(), + name: name.to_owned(), + auth_token: auth_token.to_owned(), + } + } +} diff --git a/api/src/services/mod.rs b/api/src/services/mod.rs new file mode 100644 index 0000000..43f5d3b --- /dev/null +++ b/api/src/services/mod.rs @@ -0,0 +1,6 @@ +pub mod auth_token; +mod hasher; +mod mailer; + +pub use hasher::*; +pub use mailer::*;