Move app-test codebase into api directory

This commit is contained in:
Z. Charles Dziura 2024-08-06 11:08:15 -04:00
parent 0bb3f64542
commit c6ac975e97
33 changed files with 1343 additions and 0 deletions

4
api/.env Normal file
View file

@ -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

3
api/.env.template Normal file
View file

@ -0,0 +1,3 @@
TOKEN_KEY=
DB_CONNECTION_URI=
ASSETS_DIR=

26
api/Cargo.toml Normal file
View file

@ -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"] }

View file

@ -0,0 +1,181 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<title>Email Confirmation</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
/**
* Avoid browser level font resizing.
* 1. Windows Mobile
* 2. iOS / OSX
*/
body,
table,
td,
a {
-ms-text-size-adjust: 100%; /* 1 */
-webkit-text-size-adjust: 100%; /* 2 */
}
/**
* Remove extra space added to tables and cells in Outlook.
*/
table,
td {
mso-table-rspace: 0pt;
mso-table-lspace: 0pt;
}
/**
* Better fluid images in Internet Explorer.
*/
img {
-ms-interpolation-mode: bicubic;
}
/**
* Remove blue links for iOS devices.
*/
a[x-apple-data-detectors] {
font-family: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
color: inherit !important;
text-decoration: none !important;
}
/**
* Fix centering issues in Android 4.4.
*/
div[style*="margin: 16px 0;"] {
margin: 0 !important;
}
body {
width: 100% !important;
height: 100% !important;
padding: 0 !important;
margin: 0 !important;
}
/**
* Collapse table borders to avoid space between cells.
*/
table {
border-collapse: collapse !important;
}
a {
color: #1a82e2;
}
img {
height: auto;
line-height: 100%;
text-decoration: none;
border: 0;
outline: none;
}
</style>
</head>
<body style="background-color: #e9ecef;">
<!-- start preheader -->
<div class="preheader" style="display: none; max-width: 0; max-height: 0; overflow: hidden; font-size: 1px; line-height: 1px; color: #fff; opacity: 0;">
A preheader is the short summary text that follows the subject line when an email is viewed in the inbox.
</div>
<!-- end preheader -->
<!-- start body -->
<table border="0" cellpadding="0" cellspacing="0" width="100%">
<!-- start hero -->
<tr>
<td align="center" bgcolor="#e9ecef">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellpadding="0" cellspacing="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;">
<tr>
<td align="left" style="padding: 36px 24px 0; font-family: sans-serif; border-top: 3px solid #d4dadf;">
<h1 style="margin: 0 0 12px 0; font-size: 32px; font-weight: 700; letter-spacing: -1px; line-height: 48px; text-align: center;">Welcome, $NAME!</h1>
<h2 style="margin: 0 0 16px 0; font-size: 24px; font-weight: 700; letter-spacing: -1px; line-height: 32px; text-align: center;">Please confirm your email address.</h2>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
<!-- end hero -->
<!-- start copy block -->
<tr>
<td align="center" bgcolor="#e9ecef">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellpadding="0" cellspacing="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 350px;">
<!-- start copy -->
<tr>
<td align="left" bgcolor="#ffffff" style="padding: 24px; font-family: sans-serif; font-size: 16px; line-height: 24px;">
<p style="margin: 0;">Click or tap the button below to confirm your email address. If you didn't create an account with <a href="#">Auth-Test</a>, you can safely delete this email.</p>
</td>
</tr>
<!-- end copy -->
<!-- start button -->
<tr>
<td align="left" bgcolor="#ffffff">
<table border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td align="center" bgcolor="#ffffff" style="padding: 12px;">
<table border="0" cellpadding="0" cellspacing="0">
<tr>
<td align="center" bgcolor="#1a82e2" style="border-radius: 6px;">
<a href="#$AUTH_TOKEN" target="_blank" style="display: inline-block; padding: 16px 36px; font-family: sans-serif; font-size: 16px; color: #ffffff; text-decoration: none; border-radius: 6px;">Confirm Email Address</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<!-- end button -->
<!-- start copy -->
<tr>
<td align="left" bgcolor="#ffffff" style="padding: 24px; font-family: sans-serif; font-size: 16px; line-height: 24px;">
<p style="margin: 0;">If that doesn't work, copy and paste the following link in your browser:</p>
<p style="margin: 0;"><a href="#$AUTH_TOKEN" target="_blank">$AUTH_TOKEN</a></p>
</td>
</tr>
<!-- end copy -->
<!-- start copy -->
<tr>
<td align="left" bgcolor="#ffffff" style="padding: 24px; font-family: sans-serif; font-size: 16px; line-height: 24px; border-bottom: 3px solid #d4dadf">
<p style="margin: 0;">Thank you,<br> The Auth-Test Team</p>
</td>
</tr>
<!-- end copy -->
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
<!-- end copy block -->
</table>
<!-- end body -->
</body>
</html>

3
api/build.rs Normal file
View file

@ -0,0 +1,3 @@
fn main() {
println!("cargo:rerun-if-changed=migrations");
}

View file

@ -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;

View file

@ -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)
);

3
api/requests/user-post.sh Executable file
View file

@ -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

22
api/src/db/mod.rs Normal file
View file

@ -0,0 +1,22 @@
mod user;
use sqlx::{self, postgres::PgPoolOptions, Pool, Postgres};
pub use user::*;
use crate::error::AppError;
pub type DbPool = Pool<Postgres>;
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)
}

75
api/src/db/user.rs Normal file
View file

@ -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<UserEntity, AppError> {
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(|_| ())
}

135
api/src/error.rs Normal file
View file

@ -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<ErrorKind> for AppError {
fn from(kind: ErrorKind) -> Self {
Self::new(kind)
}
}
impl From<MigrateError> for AppError {
fn from(other: MigrateError) -> Self {
Self::new(ErrorKind::DbMigration(other))
}
}
impl From<SqlxError> 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<str>) -> ErrorKind {
const UNIQUE_CONSTRAINT_VIOLATION: &str = "23505";
if code == UNIQUE_CONSTRAINT_VIOLATION {
ErrorKind::DuplicateRecord
} else {
ErrorKind::Database
}
}

40
api/src/main.rs Normal file
View file

@ -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::<UserConfirmationMessage>();
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);
}
}

View file

@ -0,0 +1,59 @@
use axum::Json;
use serde::Serialize;
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ApiResponse<T> {
#[serde(rename = "_meta")]
#[serde(skip_serializing_if = "Option::is_none")]
pub meta: Option<ApiResponseMetadata>,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<T>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<&'static str>,
}
impl<T> ApiResponse<T> {
pub fn new(data: T) -> ApiResponse<T> {
Self {
meta: None,
data: Some(data),
error: None,
}
}
pub fn _new_empty() -> ApiResponse<T> {
Self {
meta: None,
data: None,
error: None,
}
}
pub fn _new_with_metadata(data: T, meta: ApiResponseMetadata) -> ApiResponse<T> {
Self {
meta: Some(meta),
data: Some(data),
error: None,
}
}
pub fn into_json_response(self) -> Json<ApiResponse<T>> {
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 {}

View file

@ -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<HashSet<String>> = Lazy::new(|| {
HashSet::<String>::from_iter(
["TOKEN_KEY", "DATABASE_URL", "ASSETS_DIR"]
.into_iter()
.map(ToString::to_string),
)
});
#[derive(Clone)]
pub struct Environment {
token_key: SymmetricKey<V4>,
database_url: String,
email_sender: Sender<UserConfirmationMessage>,
assets_dir: PathBuf,
}
impl Environment {
pub fn init(email_sender: Sender<UserConfirmationMessage>) -> Result<Self, AppError> {
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<V4> {
&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<UserConfirmationMessage> {
&self.email_sender
}
}
impl From<EnvironmentObjectBuilder> 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<SymmetricKey<V4>>,
pub database_url: Option<String>,
pub email_sender: Option<Sender<UserConfirmationMessage>>,
pub assets_dir: Option<PathBuf>,
}
impl EnvironmentObjectBuilder {
pub fn new(email_sender: Sender<UserConfirmationMessage>) -> Self {
Self {
email_sender: Some(email_sender),
..Default::default()
}
}
pub fn uninitialized_variables(&self) -> Option<Vec<&'static str>> {
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::<V4>::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);
}
}

5
api/src/models/mod.rs Normal file
View file

@ -0,0 +1,5 @@
mod api_response;
mod environment;
pub use api_response::*;
pub use environment::*;

44
api/src/requests/mod.rs Normal file
View file

@ -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(())
}

View file

@ -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())),
)
}

View file

@ -0,0 +1,4 @@
mod models;
mod request;
pub use request::*;

View file

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

View file

@ -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,
}

View file

@ -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,
}

View file

@ -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<AppState>,
Json(request): Json<UserPostRequest>,
) -> Result<Response, Response> {
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::<UserPostResponse>::new(UserPostResponse::new(
user_id, auth_token, expiration,
))),
);
Ok(response.into_response())
}

View file

@ -0,0 +1,5 @@
mod models;
mod request;
pub use models::*;
pub use request::*;

View file

@ -0,0 +1,5 @@
mod request;
mod response;
pub use request::*;
pub use response::*;

View file

@ -0,0 +1,7 @@
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct UserVerifyGetRequest {
#[serde(alias = "t")]
pub auth_token: String,
}

View file

@ -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<V4>, 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,
}

View file

@ -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<AppState>,
Path(user_id): Path<i32>,
Query(request): Query<UserVerifyGetRequest>,
) -> Result<Response, Response> {
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::<UserVerifyGetResponse>::new(response)),
)
.into_response())
}

View file

@ -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<V4>,
token: &str,
validation_rules: Option<ClaimsValidationRules>,
) -> 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<V4>, 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<V4>, user_id: i32) -> (String, SystemTime) {
generate_token(key, user_id, None, None)
}
pub fn generate_token(
key: &SymmetricKey<V4>,
user_id: i32,
duration: Option<Duration>,
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::<V4>::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()))
);
}
}

View file

@ -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()
}

View file

@ -0,0 +1,5 @@
mod service;
mod user_confirmation_message;
pub use service::*;
pub use user_confirmation_message::*;

View file

@ -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<Credentials> = Lazy::new(|| {
Credentials::new(
"donotreply@mail.dziura.cloud".to_owned(),
"hunter2".to_owned(),
)
});
static NUM_CPUS: Lazy<u32> = Lazy::new(|| num_cpus::get() as u32);
static FROM_MAILBOX: Lazy<Mailbox> = Lazy::new(|| {
"No Not Reply Auth-Test <donotreply@mail.dziura.cloud>"
.parse()
.unwrap()
});
pub fn start_emailer_service(
runtime: Handle,
assets_dir: &Path,
email_message_rx: Receiver<UserConfirmationMessage>,
) {
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<Tokio1Executor> =
AsyncSmtpTransport::<Tokio1Executor>::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;
}

View file

@ -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(),
}
}
}

6
api/src/services/mod.rs Normal file
View file

@ -0,0 +1,6 @@
pub mod auth_token;
mod hasher;
mod mailer;
pub use hasher::*;
pub use mailer::*;