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.
+ |
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+ 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::*;