Move app-test codebase into api directory
This commit is contained in:
parent
0bb3f64542
commit
c6ac975e97
33 changed files with 1343 additions and 0 deletions
4
api/.env
Normal file
4
api/.env
Normal 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
3
api/.env.template
Normal file
|
@ -0,0 +1,3 @@
|
|||
TOKEN_KEY=
|
||||
DB_CONNECTION_URI=
|
||||
ASSETS_DIR=
|
26
api/Cargo.toml
Normal file
26
api/Cargo.toml
Normal 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"] }
|
181
api/assets/new-user-confirmation.html
Normal file
181
api/assets/new-user-confirmation.html
Normal 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
3
api/build.rs
Normal file
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
println!("cargo:rerun-if-changed=migrations");
|
||||
}
|
9
api/migrations/20231221181946_create-tables.down.sql
Normal file
9
api/migrations/20231221181946_create-tables.down.sql
Normal 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;
|
53
api/migrations/20231221181946_create-tables.up.sql
Normal file
53
api/migrations/20231221181946_create-tables.up.sql
Normal 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
3
api/requests/user-post.sh
Executable 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
22
api/src/db/mod.rs
Normal 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
75
api/src/db/user.rs
Normal 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
135
api/src/error.rs
Normal 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
40
api/src/main.rs
Normal 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);
|
||||
}
|
||||
}
|
59
api/src/models/api_response.rs
Normal file
59
api/src/models/api_response.rs
Normal 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 {}
|
146
api/src/models/environment.rs
Normal file
146
api/src/models/environment.rs
Normal 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
5
api/src/models/mod.rs
Normal 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
44
api/src/requests/mod.rs
Normal 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(())
|
||||
}
|
15
api/src/requests/user/mod.rs
Normal file
15
api/src/requests/user/mod.rs
Normal 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())),
|
||||
)
|
||||
}
|
4
api/src/requests/user/new_user/mod.rs
Normal file
4
api/src/requests/user/new_user/mod.rs
Normal file
|
@ -0,0 +1,4 @@
|
|||
mod models;
|
||||
mod request;
|
||||
|
||||
pub use request::*;
|
5
api/src/requests/user/new_user/models/mod.rs
Normal file
5
api/src/requests/user/new_user/models/mod.rs
Normal file
|
@ -0,0 +1,5 @@
|
|||
mod post_request;
|
||||
mod post_response;
|
||||
|
||||
pub use post_request::*;
|
||||
pub use post_response::*;
|
9
api/src/requests/user/new_user/models/post_request.rs
Normal file
9
api/src/requests/user/new_user/models/post_request.rs
Normal 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,
|
||||
}
|
29
api/src/requests/user/new_user/models/post_response.rs
Normal file
29
api/src/requests/user/new_user/models/post_response.rs
Normal 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,
|
||||
}
|
76
api/src/requests/user/new_user/request.rs
Normal file
76
api/src/requests/user/new_user/request.rs
Normal 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())
|
||||
}
|
5
api/src/requests/user/verify/mod.rs
Normal file
5
api/src/requests/user/verify/mod.rs
Normal file
|
@ -0,0 +1,5 @@
|
|||
mod models;
|
||||
mod request;
|
||||
|
||||
pub use models::*;
|
||||
pub use request::*;
|
5
api/src/requests/user/verify/models/mod.rs
Normal file
5
api/src/requests/user/verify/models/mod.rs
Normal file
|
@ -0,0 +1,5 @@
|
|||
mod request;
|
||||
mod response;
|
||||
|
||||
pub use request::*;
|
||||
pub use response::*;
|
7
api/src/requests/user/verify/models/request.rs
Normal file
7
api/src/requests/user/verify/models/request.rs
Normal file
|
@ -0,0 +1,7 @@
|
|||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UserVerifyGetRequest {
|
||||
#[serde(alias = "t")]
|
||||
pub auth_token: String,
|
||||
}
|
35
api/src/requests/user/verify/models/response.rs
Normal file
35
api/src/requests/user/verify/models/response.rs
Normal 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,
|
||||
}
|
48
api/src/requests/user/verify/request.rs
Normal file
48
api/src/requests/user/verify/request.rs
Normal 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())
|
||||
}
|
161
api/src/services/auth_token.rs
Normal file
161
api/src/services/auth_token.rs
Normal 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()))
|
||||
);
|
||||
}
|
||||
}
|
14
api/src/services/hasher.rs
Normal file
14
api/src/services/hasher.rs
Normal 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()
|
||||
}
|
5
api/src/services/mailer/mod.rs
Normal file
5
api/src/services/mailer/mod.rs
Normal file
|
@ -0,0 +1,5 @@
|
|||
mod service;
|
||||
mod user_confirmation_message;
|
||||
|
||||
pub use service::*;
|
||||
pub use user_confirmation_message::*;
|
96
api/src/services/mailer/service.rs
Normal file
96
api/src/services/mailer/service.rs
Normal 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;
|
||||
}
|
15
api/src/services/mailer/user_confirmation_message.rs
Normal file
15
api/src/services/mailer/user_confirmation_message.rs
Normal 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
6
api/src/services/mod.rs
Normal file
|
@ -0,0 +1,6 @@
|
|||
pub mod auth_token;
|
||||
mod hasher;
|
||||
mod mailer;
|
||||
|
||||
pub use hasher::*;
|
||||
pub use mailer::*;
|
Loading…
Add table
Reference in a new issue