Adding a service to handle caching
This commit is contained in:
parent
8d4a987f0d
commit
d41a92d07a
28 changed files with 466 additions and 115 deletions
1
api/.env
1
api/.env
|
@ -1,4 +1,5 @@
|
|||
ASSETS_DIR=/home/zcdziura/Documents/Projects/debt-pirate/api/assets
|
||||
CACHE_URL=redis://debt_pirate:H553jOui2734@192.168.122.251:6379
|
||||
DATABASE_URL=postgres://debt_pirate:HRURqlUmtjIy@192.168.122.251/debt_pirate
|
||||
HOSTNAME=localhost
|
||||
MAINTENANCE_USER_ACCOUNT=debt_pirate:HRURqlUmtjIy
|
||||
|
|
|
@ -11,10 +11,12 @@ axum = { version = "0.7", features = [
|
|||
"ws",
|
||||
] }
|
||||
base64 = "0.22"
|
||||
bb8-redis = "0.17"
|
||||
blake3 = { version = "1.5", features = ["serde"] }
|
||||
dotenvy = "0.15"
|
||||
futures = "0.3"
|
||||
http = "1.0"
|
||||
humantime = "2.1.0"
|
||||
humantime = "2.1"
|
||||
humantime-serde = "1.1"
|
||||
hyper = { version = "1.1", features = ["full"] }
|
||||
lettre = { version = "0.11", default-features = false, features = [
|
||||
|
@ -27,6 +29,7 @@ lettre = { version = "0.11", default-features = false, features = [
|
|||
] }
|
||||
num_cpus = "1.16"
|
||||
pasetors = "0.7"
|
||||
redis = { version = "0.27", features = ["aio"] }
|
||||
serde = { version = "1.0", features = ["derive", "rc", "std"] }
|
||||
serde_json = "1.0"
|
||||
serde_with = "3.9"
|
||||
|
@ -37,11 +40,11 @@ sqlx = { version = "0.8", features = [
|
|||
"runtime-tokio",
|
||||
] }
|
||||
syslog-tracing = "0.3.1"
|
||||
time = { version = "0.3.36", features = ["formatting", "macros"] }
|
||||
time = { version = "0.3", features = ["formatting", "macros"] }
|
||||
tokio = { version = "1.35", features = ["full"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["full"] }
|
||||
tracing = "0.1.40"
|
||||
tracing-subscriber = { version = "0.3.18", features = ["env-filter", "time"] }
|
||||
ulid = "1.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "time"] }
|
||||
url = { version = "2.5.2", features = ["expose_internals"] }
|
||||
uuid = { version = "1.10", features = ["serde", "v7"] }
|
||||
|
|
|
@ -7,12 +7,12 @@ use crate::models::AppError;
|
|||
|
||||
pub type DbPool = Pool<Postgres>;
|
||||
|
||||
pub async fn create_connection_pool(connection_uri: &str) -> DbPool {
|
||||
pub async fn create_db_connection_pool(connection_uri: String) -> DbPool {
|
||||
let num_cpus = num_cpus::get() as u32;
|
||||
|
||||
PgPoolOptions::new()
|
||||
.max_connections(num_cpus)
|
||||
.connect(connection_uri)
|
||||
.connect(connection_uri.as_str())
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
|
|
@ -59,13 +59,35 @@ pub async fn insert_new_user(
|
|||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
pub struct UserIdAndHashedPassword {
|
||||
pub id: i32,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
pub async fn get_username_and_password_by_username(
|
||||
pool: &DbPool,
|
||||
username: String,
|
||||
) -> Result<UserIdAndHashedPassword, AppError> {
|
||||
sqlx::query_as::<_, UserIdAndHashedPassword>(
|
||||
"SELECT id, password FROM public.user WHERE username = $1;",
|
||||
)
|
||||
.bind(username)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!(%err, "Unable to find user");
|
||||
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}'.");
|
||||
error!(%err, user_id, "Error verifying user");
|
||||
AppError::from(err)
|
||||
})
|
||||
.map(|_| ())
|
||||
|
|
|
@ -7,11 +7,13 @@ mod models;
|
|||
mod requests;
|
||||
mod services;
|
||||
|
||||
use db::{create_connection_pool, run_migrations};
|
||||
use db::{create_db_connection_pool, run_migrations};
|
||||
use requests::start_app;
|
||||
use services::{initialize_logger, start_emailer_service, UserConfirmationMessage};
|
||||
use services::{
|
||||
create_cache_connection_pool, initialize_logger, start_emailer_service, UserConfirmationMessage,
|
||||
};
|
||||
use tokio::runtime::Handle;
|
||||
use tracing::info;
|
||||
use tracing::{error, info};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
|
@ -28,11 +30,18 @@ async fn main() {
|
|||
initialize_logger(&env);
|
||||
|
||||
info!("Initializing database connection pool...");
|
||||
let pool = create_connection_pool(env.db_connection_uri()).await;
|
||||
let db_pool = create_db_connection_pool(env.db_connection_uri().to_string()).await;
|
||||
info!("Database connection pool created successfully.");
|
||||
|
||||
info!("Initializing cache service connection pool...");
|
||||
let cache_pool = create_cache_connection_pool(env.cache_url().to_string())
|
||||
.await
|
||||
.inspect_err(|err| error!(?err))
|
||||
.unwrap();
|
||||
info!("Cache service connection pool created successfully.");
|
||||
|
||||
info!("Running database schema migrations...");
|
||||
if let Err(err) = run_migrations(&pool).await {
|
||||
if let Err(err) = run_migrations(&db_pool).await {
|
||||
eprintln!("{err:?}");
|
||||
process::exit(2);
|
||||
}
|
||||
|
@ -42,7 +51,7 @@ async fn main() {
|
|||
start_emailer_service(Handle::current(), env.assets_dir(), rx);
|
||||
info!("Email service started successfully.");
|
||||
|
||||
if let Err(err) = start_app(pool, env).await {
|
||||
if let Err(err) = start_app(db_pool, cache_pool, env).await {
|
||||
eprintln!("{err:?}");
|
||||
process::exit(3);
|
||||
}
|
||||
|
|
|
@ -50,7 +50,7 @@ impl ApiResponse<()> {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn error(error: &'static str) -> Self {
|
||||
pub fn _error(error: &'static str) -> Self {
|
||||
Self {
|
||||
meta: None,
|
||||
data: None,
|
||||
|
|
|
@ -4,6 +4,8 @@ use std::{
|
|||
};
|
||||
|
||||
use pasetors::{keys::SymmetricKey, version4::V4};
|
||||
use tracing::trace;
|
||||
use url::Url;
|
||||
|
||||
use crate::services::UserConfirmationMessage;
|
||||
|
||||
|
@ -12,7 +14,8 @@ use super::AppError;
|
|||
#[derive(Clone)]
|
||||
pub struct Environment {
|
||||
assets_dir: PathBuf,
|
||||
database_url: String,
|
||||
cache_url: Url,
|
||||
database_url: Url,
|
||||
email_sender: Sender<UserConfirmationMessage>,
|
||||
hostname: String,
|
||||
port: u32,
|
||||
|
@ -29,6 +32,7 @@ impl Environment {
|
|||
.filter_map(|item| item.ok())
|
||||
.for_each(|(key, value)| match key.as_str() {
|
||||
"ASSETS_DIR" => builder.with_assets_dir(value),
|
||||
"CACHE_URL" => builder.with_cache_url(value),
|
||||
"DATABASE_URL" => builder.with_database_url(value),
|
||||
"HOSTNAME" => builder.with_hostname(value),
|
||||
"PORT" => builder.with_port(value),
|
||||
|
@ -46,6 +50,22 @@ impl Environment {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn assets_dir(&self) -> &Path {
|
||||
self.assets_dir.as_path()
|
||||
}
|
||||
|
||||
pub fn cache_url(&self) -> &Url {
|
||||
&self.cache_url
|
||||
}
|
||||
|
||||
pub fn db_connection_uri(&self) -> &Url {
|
||||
&self.database_url
|
||||
}
|
||||
|
||||
pub fn email_sender(&self) -> &Sender<UserConfirmationMessage> {
|
||||
&self.email_sender
|
||||
}
|
||||
|
||||
pub fn hostname(&self) -> &str {
|
||||
self.hostname.as_str()
|
||||
}
|
||||
|
@ -54,22 +74,6 @@ impl Environment {
|
|||
self.port
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
pub fn rust_log(&self) -> &str {
|
||||
self.rust_log.as_str()
|
||||
}
|
||||
|
@ -77,12 +81,17 @@ impl Environment {
|
|||
pub fn send_verification_email(&self) -> bool {
|
||||
self.send_verification_email
|
||||
}
|
||||
|
||||
pub fn token_key(&self) -> &SymmetricKey<V4> {
|
||||
&self.token_key
|
||||
}
|
||||
}
|
||||
|
||||
impl From<EnvironmentObjectBuilder> for Environment {
|
||||
fn from(builder: EnvironmentObjectBuilder) -> Self {
|
||||
let EnvironmentObjectBuilder {
|
||||
assets_dir,
|
||||
cache_url,
|
||||
database_url,
|
||||
email_sender,
|
||||
hostname,
|
||||
|
@ -94,6 +103,7 @@ impl From<EnvironmentObjectBuilder> for Environment {
|
|||
|
||||
Self {
|
||||
assets_dir: assets_dir.unwrap(),
|
||||
cache_url: cache_url.unwrap(),
|
||||
database_url: database_url.unwrap(),
|
||||
email_sender: email_sender.unwrap(),
|
||||
hostname: hostname.unwrap(),
|
||||
|
@ -108,7 +118,8 @@ impl From<EnvironmentObjectBuilder> for Environment {
|
|||
#[derive(Debug, Default)]
|
||||
pub struct EnvironmentObjectBuilder {
|
||||
pub assets_dir: Option<PathBuf>,
|
||||
pub database_url: Option<String>,
|
||||
pub cache_url: Option<Url>,
|
||||
pub database_url: Option<Url>,
|
||||
pub email_sender: Option<Sender<UserConfirmationMessage>>,
|
||||
pub hostname: Option<String>,
|
||||
pub port: Option<u32>,
|
||||
|
@ -128,13 +139,20 @@ impl EnvironmentObjectBuilder {
|
|||
pub fn uninitialized_variables(&self) -> Option<Vec<&'static str>> {
|
||||
let mut missing_vars = [
|
||||
("HOSTNAME", self.hostname.as_deref()),
|
||||
("DATABASE_URL", self.database_url.as_deref()),
|
||||
("RUST_LOG", self.rust_log.as_deref()),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(key, value)| value.map(|_| key).xor(Some(key)))
|
||||
.collect::<Vec<&'static str>>();
|
||||
|
||||
if self.cache_url.is_none() {
|
||||
missing_vars.push("CACHE_URL");
|
||||
}
|
||||
|
||||
if self.database_url.is_none() {
|
||||
missing_vars.push("DATABASE_URL");
|
||||
}
|
||||
|
||||
if self.token_key.is_none() {
|
||||
missing_vars.push("TOKEN_KEY");
|
||||
}
|
||||
|
@ -170,8 +188,28 @@ impl EnvironmentObjectBuilder {
|
|||
};
|
||||
}
|
||||
|
||||
pub fn with_cache_url(&mut self, url: String) {
|
||||
trace!(?url);
|
||||
|
||||
let cache_url = url
|
||||
.parse::<Url>()
|
||||
.expect("The 'CACHE_URL' variable is not in valid URI format");
|
||||
|
||||
trace!(?cache_url);
|
||||
|
||||
if cache_url.scheme().to_lowercase() != "redis" {
|
||||
panic!("The 'CACHE_URL' must be a valid Redis connection string; it must use the 'redis://' scheme");
|
||||
}
|
||||
|
||||
self.cache_url = Some(cache_url);
|
||||
}
|
||||
|
||||
pub fn with_database_url(&mut self, url: String) {
|
||||
self.database_url = Some(url);
|
||||
let database_url = url
|
||||
.parse::<Url>()
|
||||
.expect("The 'DATABASE_URL' variable is not in valid URI format");
|
||||
|
||||
self.database_url = Some(database_url);
|
||||
}
|
||||
|
||||
pub fn with_assets_dir(&mut self, assets_dir_path: String) {
|
||||
|
|
|
@ -1,8 +1,11 @@
|
|||
use std::{borrow::Cow, error::Error, fmt::Display, io};
|
||||
|
||||
use axum::response::IntoResponse;
|
||||
use bb8_redis::bb8::RunError;
|
||||
use http::StatusCode;
|
||||
use redis::RedisError;
|
||||
use sqlx::{error::DatabaseError, migrate::MigrateError, Error as SqlxError};
|
||||
use tracing::trace;
|
||||
|
||||
use super::ApiResponse;
|
||||
|
||||
|
@ -11,6 +14,8 @@ pub struct AppError {
|
|||
kind: ErrorKind,
|
||||
}
|
||||
|
||||
impl Error for AppError {}
|
||||
|
||||
impl AppError {
|
||||
fn new(kind: ErrorKind) -> Self {
|
||||
Self { kind }
|
||||
|
@ -20,17 +25,20 @@ impl AppError {
|
|||
Self::new(ErrorKind::AppStartupError(error))
|
||||
}
|
||||
|
||||
pub fn connection_info(service_name: &'static str) -> Self {
|
||||
Self::new(ErrorKind::ConnectionInfo(service_name))
|
||||
}
|
||||
|
||||
pub fn duplicate_record(message: &str) -> Self {
|
||||
Self::new(ErrorKind::DuplicateRecord(message.to_owned()))
|
||||
}
|
||||
|
||||
pub fn invalid_token() -> Self {
|
||||
Self::new(ErrorKind::InvalidToken)
|
||||
pub fn invalid_password() -> Self {
|
||||
Self::new(ErrorKind::InvalidPassword)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn invalid_token_audience(audience: &str) -> Self {
|
||||
Self::new(ErrorKind::InvalidTokenAudience(audience.to_owned()))
|
||||
pub fn invalid_token() -> Self {
|
||||
Self::new(ErrorKind::InvalidToken)
|
||||
}
|
||||
|
||||
pub fn missing_environment_variables(missing_vars: Vec<&'static str>) -> Self {
|
||||
|
@ -74,11 +82,30 @@ impl From<SqlxError> for AppError {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<RedisError> for AppError {
|
||||
fn from(other: RedisError) -> Self {
|
||||
trace!(err = ?other, "Cache error");
|
||||
Self::new(ErrorKind::Cache(other.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RunError<RedisError>> for AppError {
|
||||
fn from(other: RunError<RedisError>) -> Self {
|
||||
trace!(err = ?other, "Cache pool error");
|
||||
Self::new(ErrorKind::Cache(other.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
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::Cache(err) => write!(f, "{err}"),
|
||||
ErrorKind::ConnectionInfo(service) => write!(
|
||||
f,
|
||||
"Unable to connect to '{service}' service; invalid connection string"
|
||||
),
|
||||
ErrorKind::Database => write!(f, "Unknown database error occurred"),
|
||||
ErrorKind::DbMigration(err) => write!(
|
||||
f,
|
||||
"Error occurred while initializing connection to database: {err}"
|
||||
|
@ -86,11 +113,8 @@ impl Display for AppError {
|
|||
ErrorKind::DuplicateRecord(message) => {
|
||||
write!(f, "Duplicate database record: {message}")
|
||||
}
|
||||
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::InvalidPassword => write!(f, "Invalid password"),
|
||||
ErrorKind::InvalidToken => write!(f, "The provided token is invalid"),
|
||||
ErrorKind::MissingEnvironmentVariables(missing_vars) => write!(
|
||||
f,
|
||||
"Missing required environment variables: {}",
|
||||
|
@ -99,22 +123,22 @@ impl Display for AppError {
|
|||
ErrorKind::Sqlx(err) => write!(f, "{err}"),
|
||||
ErrorKind::TokenKey => write!(
|
||||
f,
|
||||
"Invalid PASETO symmetric key; must be in valid PASERK format."
|
||||
"Invalid PASETO symmetric key; must be in valid PASERK format"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for AppError {}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum ErrorKind {
|
||||
AppStartupError(io::Error),
|
||||
Cache(String),
|
||||
ConnectionInfo(&'static str),
|
||||
Database,
|
||||
DbMigration(MigrateError),
|
||||
DuplicateRecord(String),
|
||||
InvalidPassword,
|
||||
InvalidToken,
|
||||
InvalidTokenAudience(String),
|
||||
MissingEnvironmentVariables(Vec<&'static str>),
|
||||
Sqlx(SqlxError),
|
||||
TokenKey,
|
||||
|
@ -127,7 +151,7 @@ impl IntoResponse for AppError {
|
|||
StatusCode::CONFLICT,
|
||||
ApiResponse::new_with_error(self).into_json_response(),
|
||||
),
|
||||
&ErrorKind::InvalidToken | &ErrorKind::InvalidTokenAudience(_) => (
|
||||
&ErrorKind::InvalidPassword | &ErrorKind::InvalidToken => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
ApiResponse::new_with_error(self).into_json_response(),
|
||||
),
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
mod api_response;
|
||||
mod environment;
|
||||
mod error;
|
||||
mod session;
|
||||
|
||||
pub use api_response::*;
|
||||
pub use environment::*;
|
||||
pub use error::*;
|
||||
pub use session::*;
|
||||
|
|
32
api/src/models/session.rs
Normal file
32
api/src/models/session.rs
Normal file
|
@ -0,0 +1,32 @@
|
|||
use std::time::SystemTime;
|
||||
|
||||
use humantime::format_rfc3339;
|
||||
use redis::ToRedisArgs;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Session {
|
||||
pub user_id: i32,
|
||||
pub username: String,
|
||||
pub created_at: SystemTime,
|
||||
pub expires_at: SystemTime,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn to_cacheable_object<'a>(
|
||||
self,
|
||||
) -> Vec<(&'static str, impl ToRedisArgs + Send + Sync + 'a)> {
|
||||
let Self {
|
||||
user_id,
|
||||
username,
|
||||
created_at,
|
||||
expires_at,
|
||||
} = self;
|
||||
|
||||
vec![
|
||||
("userId", user_id.to_string()),
|
||||
("username", username),
|
||||
("createdAt", format_rfc3339(created_at).to_string()),
|
||||
("expiresAt", format_rfc3339(expires_at).to_string()),
|
||||
]
|
||||
}
|
||||
}
|
|
@ -1,11 +1,39 @@
|
|||
use axum::{
|
||||
debug_handler,
|
||||
extract::State,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use tracing::debug;
|
||||
|
||||
use crate::{
|
||||
db::{get_username_and_password_by_username, DbPool, UserIdAndHashedPassword},
|
||||
models::AppError,
|
||||
requests::AppState,
|
||||
services::verify_password,
|
||||
};
|
||||
|
||||
use crate::models::AppError;
|
||||
use super::models::AuthLoginRequest;
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn auth_login_post_handler() -> Result<Response, AppError> {
|
||||
pub async fn auth_login_post_handler(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<AuthLoginRequest>,
|
||||
) -> Result<Response, AppError> {
|
||||
let pool = state.db_pool();
|
||||
auth_login_request(pool, body).await
|
||||
}
|
||||
|
||||
async fn auth_login_request(pool: &DbPool, body: AuthLoginRequest) -> Result<Response, AppError> {
|
||||
debug!(?body);
|
||||
|
||||
let AuthLoginRequest { username, password } = body;
|
||||
let UserIdAndHashedPassword {
|
||||
id: _id,
|
||||
password: hashed_password,
|
||||
} = get_username_and_password_by_username(pool, username).await?;
|
||||
|
||||
verify_password(password, hashed_password)?;
|
||||
|
||||
Ok(().into_response())
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
mod handler;
|
||||
mod models;
|
||||
|
||||
pub use handler::*;
|
||||
|
|
5
api/src/requests/auth/login/models/mod.rs
Normal file
5
api/src/requests/auth/login/models/mod.rs
Normal file
|
@ -0,0 +1,5 @@
|
|||
mod request;
|
||||
mod response;
|
||||
|
||||
pub use request::*;
|
||||
pub use response::*;
|
20
api/src/requests/auth/login/models/request.rs
Normal file
20
api/src/requests/auth/login/models/request.rs
Normal file
|
@ -0,0 +1,20 @@
|
|||
use std::fmt::Debug;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_with::serde_as;
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Deserialize)]
|
||||
pub struct AuthLoginRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl Debug for AuthLoginRequest {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AuthLoginRequest")
|
||||
.field("username", &self.username)
|
||||
.field("password", &"********")
|
||||
.finish()
|
||||
}
|
||||
}
|
19
api/src/requests/auth/login/models/response.rs
Normal file
19
api/src/requests/auth/login/models/response.rs
Normal file
|
@ -0,0 +1,19 @@
|
|||
use std::time::SystemTime;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthLoginResponse {
|
||||
pub user_id: i32,
|
||||
pub access: AuthLoginTokenData,
|
||||
pub auth: AuthLoginTokenData,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AuthLoginTokenData {
|
||||
pub token: String,
|
||||
|
||||
#[serde(serialize_with = "humantime_serde::serialize")]
|
||||
pub expiration: SystemTime,
|
||||
}
|
|
@ -12,26 +12,36 @@ use humantime::format_duration;
|
|||
use tokio::net::TcpListener;
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing::{error, info, info_span, warn, Span};
|
||||
use ulid::Ulid;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
db::DbPool,
|
||||
models::{AppError, Environment},
|
||||
services::CachePool,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pool: DbPool,
|
||||
db_pool: DbPool,
|
||||
cache_pool: CachePool,
|
||||
env: Environment,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(pool: DbPool, env: Environment) -> Self {
|
||||
Self { pool, env }
|
||||
pub fn new(db_pool: DbPool, cache_pool: CachePool, env: Environment) -> Self {
|
||||
Self {
|
||||
db_pool,
|
||||
cache_pool,
|
||||
env,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pool(&self) -> &DbPool {
|
||||
&self.pool
|
||||
pub fn db_pool(&self) -> &DbPool {
|
||||
&self.db_pool
|
||||
}
|
||||
|
||||
pub fn cache_pool(&self) -> &CachePool {
|
||||
&self.cache_pool
|
||||
}
|
||||
|
||||
pub fn env(&self) -> &Environment {
|
||||
|
@ -39,7 +49,11 @@ impl AppState {
|
|||
}
|
||||
}
|
||||
|
||||
pub async fn start_app(pool: DbPool, env: Environment) -> Result<(), AppError> {
|
||||
pub async fn start_app(
|
||||
db_pool: DbPool,
|
||||
cache_pool: CachePool,
|
||||
env: Environment,
|
||||
) -> Result<(), AppError> {
|
||||
let address = env.hostname();
|
||||
let port = env.port();
|
||||
|
||||
|
@ -55,7 +69,7 @@ pub async fn start_app(pool: DbPool, env: Environment) -> Result<(), AppError> {
|
|||
.get::<MatchedPath>()
|
||||
.map(MatchedPath::as_str).unwrap_or(request.uri().path());
|
||||
|
||||
info_span!("api_request", request_id = %Ulid::new(), method = %request.method(), %path, status = tracing::field::Empty)
|
||||
info_span!("api_request", request_id = %Uuid::now_v7(), method = %request.method(), %path, status = tracing::field::Empty)
|
||||
})
|
||||
.on_response(|response: &Response, duration: Duration, span: &Span| {
|
||||
let status = response.status();
|
||||
|
@ -68,7 +82,7 @@ pub async fn start_app(pool: DbPool, env: Environment) -> Result<(), AppError> {
|
|||
}
|
||||
});
|
||||
|
||||
let state = AppState::new(pool, env);
|
||||
let state = AppState::new(db_pool, cache_pool, env);
|
||||
let app = Router::new()
|
||||
.merge(user::requests(state.clone()))
|
||||
.merge(auth::requests(state.clone()))
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
use std::sync::mpsc::Sender;
|
||||
use std::{sync::mpsc::Sender, time::SystemTime};
|
||||
|
||||
use crate::{
|
||||
db::{insert_new_user, DbPool, NewUserEntity, UserEntity},
|
||||
models::{ApiResponse, AppError},
|
||||
models::{ApiResponse, AppError, Session},
|
||||
requests::AppState,
|
||||
services::{auth_token::generate_new_user_token, hash_string, UserConfirmationMessage},
|
||||
services::{
|
||||
self, auth_token::generate_new_user_token, hash_password, CachePool,
|
||||
UserConfirmationMessage,
|
||||
},
|
||||
};
|
||||
use axum::{
|
||||
debug_handler,
|
||||
|
@ -27,7 +30,8 @@ pub async fn user_registration_post_handler(
|
|||
|
||||
register_new_user_request(
|
||||
request,
|
||||
state.pool(),
|
||||
state.db_pool(),
|
||||
state.cache_pool(),
|
||||
env.token_key(),
|
||||
env.send_verification_email(),
|
||||
env.email_sender(),
|
||||
|
@ -37,7 +41,8 @@ pub async fn user_registration_post_handler(
|
|||
|
||||
async fn register_new_user_request(
|
||||
body: UserRegistrationRequest,
|
||||
pool: &DbPool,
|
||||
db_pool: &DbPool,
|
||||
cache_pool: &CachePool,
|
||||
signing_key: &SymmetricKey<V4>,
|
||||
send_verification_email: bool,
|
||||
email_sender: &Sender<UserConfirmationMessage>,
|
||||
|
@ -51,10 +56,10 @@ async fn register_new_user_request(
|
|||
name,
|
||||
} = body;
|
||||
|
||||
let hashed_password = hash_string(password);
|
||||
let hashed_password = hash_password(password);
|
||||
|
||||
let new_user = NewUserEntity {
|
||||
username,
|
||||
username: username.clone(),
|
||||
password: hashed_password.to_string(),
|
||||
email,
|
||||
name,
|
||||
|
@ -65,7 +70,7 @@ async fn register_new_user_request(
|
|||
name,
|
||||
email,
|
||||
..
|
||||
} = insert_new_user(pool, new_user).await.map_err(|err| {
|
||||
} = insert_new_user(db_pool, new_user).await.map_err(|err| {
|
||||
if err.is_duplicate_record() {
|
||||
AppError::duplicate_record(
|
||||
"There is already an account associated with this username or email address.",
|
||||
|
@ -75,7 +80,23 @@ async fn register_new_user_request(
|
|||
}
|
||||
})?;
|
||||
|
||||
let (verification_token, expiration) = generate_new_user_token(signing_key, user_id);
|
||||
let (verification_token, token_id, expires_at) = generate_new_user_token(signing_key, user_id);
|
||||
|
||||
let new_user_session = Session {
|
||||
user_id,
|
||||
username,
|
||||
created_at: SystemTime::now(),
|
||||
expires_at,
|
||||
};
|
||||
|
||||
let expires_in = expires_at.duration_since(SystemTime::now()).unwrap();
|
||||
services::user_session::store_user_session(
|
||||
cache_pool,
|
||||
token_id,
|
||||
new_user_session,
|
||||
Some(expires_in),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let response_body = if send_verification_email {
|
||||
let new_user_confirmation_message = UserConfirmationMessage {
|
||||
|
@ -92,13 +113,13 @@ async fn register_new_user_request(
|
|||
|
||||
UserRegistrationResponse {
|
||||
id: user_id,
|
||||
expiration,
|
||||
expires_at,
|
||||
verification_token: None,
|
||||
}
|
||||
} else {
|
||||
UserRegistrationResponse {
|
||||
id: user_id,
|
||||
expiration,
|
||||
expires_at,
|
||||
verification_token: Some(verification_token),
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
mod registration_request;
|
||||
mod registration_response;
|
||||
mod request;
|
||||
mod response;
|
||||
|
||||
pub use registration_request::*;
|
||||
pub use registration_response::*;
|
||||
pub use request::*;
|
||||
pub use response::*;
|
||||
|
|
|
@ -11,7 +11,7 @@ pub struct UserRegistrationResponse {
|
|||
pub id: i32,
|
||||
|
||||
#[serde(serialize_with = "humantime_serde::serialize")]
|
||||
pub expiration: SystemTime,
|
||||
pub expires_at: SystemTime,
|
||||
|
||||
pub verification_token: Option<String>,
|
||||
}
|
|
@ -23,7 +23,7 @@ pub async fn user_verification_get_handler(
|
|||
Path(user_id): Path<i32>,
|
||||
Query(query): Query<UserVerifyGetParams>,
|
||||
) -> Result<Response, AppError> {
|
||||
let pool = state.pool();
|
||||
let pool = state.db_pool();
|
||||
let env = state.env();
|
||||
|
||||
let UserVerifyGetParams { verification_token } = query;
|
||||
|
@ -42,7 +42,6 @@ async fn verify_new_user_request(
|
|||
let validation_rules = {
|
||||
let mut rules = ClaimsValidationRules::new();
|
||||
rules.validate_audience_with(format!("/user/{user_id}/verify").as_str());
|
||||
|
||||
rules
|
||||
};
|
||||
|
||||
|
|
|
@ -12,8 +12,8 @@ pub struct UserVerifyGetResponse {
|
|||
|
||||
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);
|
||||
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 {
|
||||
|
|
|
@ -13,7 +13,7 @@ use uuid::Uuid;
|
|||
|
||||
use crate::models::AppError;
|
||||
|
||||
static FOURTY_FIVE_DAYS: Duration = Duration::from_secs(3_888_000);
|
||||
static ONE_DAY: Duration = Duration::from_secs(86_400);
|
||||
static ONE_HOUR: Duration = Duration::from_secs(3_600);
|
||||
static FIFTEEN_MINUTES: Duration = Duration::from_secs(900);
|
||||
|
||||
|
@ -51,19 +51,19 @@ pub fn verify_token(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub fn generate_access_token(key: &SymmetricKey<V4>, user_id: i32) -> (String, SystemTime) {
|
||||
generate_token(key, user_id, Some(FOURTY_FIVE_DAYS), None)
|
||||
pub fn generate_access_token(key: &SymmetricKey<V4>, user_id: i32) -> (String, Uuid, SystemTime) {
|
||||
generate_token(key, user_id, ONE_HOUR, None)
|
||||
}
|
||||
|
||||
pub fn generate_auth_token(key: &SymmetricKey<V4>, user_id: i32) -> (String, SystemTime) {
|
||||
generate_token(key, user_id, None, None)
|
||||
pub fn generate_auth_token(key: &SymmetricKey<V4>, user_id: i32) -> (String, Uuid, SystemTime) {
|
||||
generate_token(key, user_id, ONE_DAY, None)
|
||||
}
|
||||
|
||||
pub fn generate_new_user_token(key: &SymmetricKey<V4>, user_id: i32) -> (String, SystemTime) {
|
||||
pub fn generate_new_user_token(key: &SymmetricKey<V4>, user_id: i32) -> (String, Uuid, SystemTime) {
|
||||
generate_token(
|
||||
key,
|
||||
user_id,
|
||||
Some(FIFTEEN_MINUTES),
|
||||
FIFTEEN_MINUTES,
|
||||
Some(format!("/user/{user_id}/verify").as_str()),
|
||||
)
|
||||
}
|
||||
|
@ -71,20 +71,16 @@ pub fn generate_new_user_token(key: &SymmetricKey<V4>, user_id: i32) -> (String,
|
|||
fn generate_token(
|
||||
key: &SymmetricKey<V4>,
|
||||
user_id: i32,
|
||||
duration: Option<Duration>,
|
||||
expires_in: Duration,
|
||||
audience: Option<&str>,
|
||||
) -> (String, SystemTime) {
|
||||
) -> (String, Uuid, SystemTime) {
|
||||
let now = SystemTime::now();
|
||||
let expiration = if let Some(duration) = duration {
|
||||
duration
|
||||
} else {
|
||||
ONE_HOUR
|
||||
};
|
||||
let token_id = Uuid::now_v7();
|
||||
|
||||
let token = Claims::new_expires_in(&expiration)
|
||||
let token = Claims::new_expires_in(&expires_in)
|
||||
.and_then(|mut claims| {
|
||||
claims
|
||||
.token_identifier(Uuid::now_v7().to_string().as_str())
|
||||
.token_identifier(token_id.to_string().as_str())
|
||||
.map(|_| claims)
|
||||
})
|
||||
.and_then(|mut claims| {
|
||||
|
@ -116,7 +112,7 @@ fn generate_token(
|
|||
})
|
||||
.unwrap();
|
||||
|
||||
(token, now + expiration)
|
||||
(token, token_id, now + expires_in)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
@ -136,7 +132,7 @@ mod tests {
|
|||
.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 token = generate_token(&key, 1, Duration::from_secs(60), Some("testing")).0;
|
||||
|
||||
let footer = {
|
||||
let mut footer = Footer::new();
|
||||
|
|
64
api/src/services/cache.rs
Normal file
64
api/src/services/cache.rs
Normal file
|
@ -0,0 +1,64 @@
|
|||
use std::{fmt::Debug, time::Duration};
|
||||
|
||||
use bb8_redis::{
|
||||
bb8::{Pool, PooledConnection},
|
||||
RedisConnectionManager,
|
||||
};
|
||||
use redis::{AsyncCommands, IntoConnectionInfo, ToRedisArgs};
|
||||
|
||||
use crate::models::AppError;
|
||||
|
||||
pub type CachePool = Pool<RedisConnectionManager>;
|
||||
|
||||
pub async fn create_cache_connection_pool(
|
||||
connection_info: impl IntoConnectionInfo + Clone + Debug,
|
||||
) -> Result<CachePool, AppError> {
|
||||
let manager = RedisConnectionManager::new(connection_info)
|
||||
.map_err(|_| AppError::connection_info("cache"))?;
|
||||
let pool = Pool::builder().build(manager).await.unwrap();
|
||||
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
pub async fn store_object<
|
||||
'a,
|
||||
F: ToRedisArgs + Send + Sync + 'a,
|
||||
V: ToRedisArgs + Send + Sync + 'a,
|
||||
O: AsRef<[(F, V)]>,
|
||||
>(
|
||||
cache_pool: &CachePool,
|
||||
key: &str,
|
||||
object: O,
|
||||
) -> Result<(), AppError> {
|
||||
let mut conn = get_connection_from_pool(cache_pool).await?;
|
||||
|
||||
let _: () = conn.hset_multiple(key, object.as_ref()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn store_object_with_expiration<
|
||||
'a,
|
||||
F: ToRedisArgs + Send + Sync + 'a,
|
||||
V: ToRedisArgs + Send + Sync + 'a,
|
||||
O: AsRef<[(F, V)]>,
|
||||
>(
|
||||
cache_pool: &CachePool,
|
||||
key: &str,
|
||||
object: O,
|
||||
expiration: Duration,
|
||||
) -> Result<(), AppError> {
|
||||
let mut conn = get_connection_from_pool(cache_pool).await?;
|
||||
|
||||
let _: () = conn.hset_multiple(key, object.as_ref()).await?;
|
||||
let _: () = conn
|
||||
.expire(key, expiration.as_secs().try_into().unwrap())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_connection_from_pool(
|
||||
cache_pool: &CachePool,
|
||||
) -> Result<PooledConnection<'_, RedisConnectionManager>, AppError> {
|
||||
cache_pool.get().await.map_err(From::from)
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
use argon2::{
|
||||
password_hash::{rand_core::OsRng, PasswordHash, PasswordHashString, SaltString},
|
||||
Argon2,
|
||||
};
|
||||
|
||||
pub fn hash_string(string: String) -> 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()
|
||||
}
|
|
@ -1,8 +1,12 @@
|
|||
pub mod auth_token;
|
||||
mod hasher;
|
||||
mod cache;
|
||||
mod logger;
|
||||
mod mailer;
|
||||
mod password_hasher;
|
||||
pub mod user_session;
|
||||
|
||||
pub use hasher::*;
|
||||
pub use logger::*;
|
||||
pub use mailer::*;
|
||||
pub use password_hasher::*;
|
||||
|
||||
pub use cache::{create_cache_connection_pool, CachePool};
|
||||
|
|
25
api/src/services/password_hasher.rs
Normal file
25
api/src/services/password_hasher.rs
Normal file
|
@ -0,0 +1,25 @@
|
|||
use argon2::{
|
||||
password_hash::{rand_core::OsRng, PasswordHash, PasswordHashString, SaltString},
|
||||
Argon2, PasswordVerifier,
|
||||
};
|
||||
|
||||
use crate::models::AppError;
|
||||
|
||||
pub fn hash_password(password: String) -> PasswordHashString {
|
||||
let algorithm = Argon2::default();
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
|
||||
let hashed_password =
|
||||
PasswordHash::generate(algorithm, password.as_bytes(), salt.as_salt()).unwrap();
|
||||
|
||||
hashed_password.serialize()
|
||||
}
|
||||
|
||||
pub fn verify_password(password: String, hashed_password: String) -> Result<(), AppError> {
|
||||
let algorithm = Argon2::default();
|
||||
let hash = PasswordHash::new(hashed_password.as_str()).unwrap();
|
||||
|
||||
algorithm
|
||||
.verify_password(password.as_bytes(), &hash)
|
||||
.map_err(|_| AppError::invalid_password())
|
||||
}
|
38
api/src/services/user_session.rs
Normal file
38
api/src/services/user_session.rs
Normal file
|
@ -0,0 +1,38 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
models::{AppError, Session},
|
||||
services::cache::CachePool,
|
||||
};
|
||||
|
||||
use super::cache;
|
||||
|
||||
static USER_SESSION_CACHE_KEY_PREFIX: &'static str = "debt_pirate:session:";
|
||||
|
||||
pub async fn store_user_session(
|
||||
cache_pool: &CachePool,
|
||||
token_id: Uuid,
|
||||
session: Session,
|
||||
expiration: Option<Duration>,
|
||||
) -> Result<(), AppError> {
|
||||
let hashed_token = blake3::hash(token_id.as_bytes());
|
||||
let key = format!("{USER_SESSION_CACHE_KEY_PREFIX}{hashed_token}");
|
||||
let cacheable_object = session.to_cacheable_object();
|
||||
|
||||
match expiration {
|
||||
Some(expiration) => {
|
||||
cache::store_object_with_expiration(
|
||||
cache_pool,
|
||||
key.as_str(),
|
||||
cacheable_object,
|
||||
expiration,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
None => cache::store_object(cache_pool, key.as_str(), cacheable_object).await?,
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
Loading…
Add table
Reference in a new issue