Moving back to using passwords for authentication

This commit is contained in:
Z. Charles Dziura 2024-09-29 09:57:02 -04:00
parent 6a5bbfd2a8
commit 864ce0c13d
17 changed files with 169 additions and 198 deletions

View file

@ -1,7 +1,7 @@
HOSTNAME=localhost HOSTNAME=localhost
PORT=42069 PORT=42069
DOMAIN=http://api.debtpirate.app DOMAIN=http://api.debtpirate.app
RP_ID=api.debtpirate.app RP_ID=debtpirate.app
TOKEN_KEY=k4.local.hWoS2ZulK9xPEATtXH1Dvj_iynzqfUv5ER5_IFTg5-Q TOKEN_KEY=k4.local.hWoS2ZulK9xPEATtXH1Dvj_iynzqfUv5ER5_IFTg5-Q
DATABASE_URL=postgres://debt_pirate:HRURqlUmtjIy@192.168.122.251/debt_pirate DATABASE_URL=postgres://debt_pirate:HRURqlUmtjIy@192.168.122.251/debt_pirate
ASSETS_DIR=/home/zcdziura/Documents/Projects/debt-pirate/api/assets ASSETS_DIR=/home/zcdziura/Documents/Projects/debt-pirate/api/assets

View file

@ -14,8 +14,9 @@ axum = { version = "0.7", features = [
base64 = "0.22" base64 = "0.22"
dotenvy = "0.15" dotenvy = "0.15"
futures = "0.3" futures = "0.3"
http = "1.0.0" http = "1.0"
humantime = "2.1.0" humantime = "2.1.0"
humantime-serde = "1.1"
hyper = { version = "1.1", features = ["full"] } hyper = { version = "1.1", features = ["full"] }
lettre = { version = "0.11", default-features = false, features = [ lettre = { version = "0.11", default-features = false, features = [
"builder", "builder",
@ -28,9 +29,10 @@ lettre = { version = "0.11", default-features = false, features = [
log = "0.4" log = "0.4"
num_cpus = "1.16" num_cpus = "1.16"
once_cell = "1.19" once_cell = "1.19"
pasetors = "0.6" pasetors = "0.7"
serde = { version = "1.0", features = ["derive", "rc", "std"] } serde = { version = "1.0", features = ["derive", "rc", "std"] }
serde_json = "1.0" serde_json = "1.0"
serde_with = "3.9"
sqlx = { version = "0.8", features = [ sqlx = { version = "0.8", features = [
"default", "default",
"chrono", "chrono",
@ -38,8 +40,6 @@ sqlx = { version = "0.8", features = [
"runtime-tokio", "runtime-tokio",
] } ] }
tokio = { version = "1.35", features = ["full"] } tokio = { version = "1.35", features = ["full"] }
tower = "0.4" tower = "0.5"
tower-http = { version = "0.5", features = ["full"] } tower-http = { version = "0.6", features = ["full"] }
tower-sessions = "0.12.3"
uuid = { version = "1.10", features = ["serde", "v7"] } uuid = { version = "1.10", features = ["serde", "v7"] }
webauthn-rs = { version = "0.5.0", features = ["danger-allow-state-serialisation"] }

View file

@ -1,6 +1,6 @@
CREATE TABLE IF NOT EXISTS CREATE TABLE IF NOT EXISTS
public.status ( public.status (
id SERIAL NOT NULL PRIMARY KEY, id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE NULL updated_at TIMESTAMP WITH TIME ZONE NULL
@ -20,7 +20,7 @@ VALUES
CREATE TABLE IF NOT EXISTS CREATE TABLE IF NOT EXISTS
public.user ( public.user (
id SERIAL NOT NULL PRIMARY KEY, id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
username VARCHAR(255) NOT NULL, username VARCHAR(255) NOT NULL,
password VARCHAR(255) NOT NULL, password VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL,
@ -35,7 +35,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS user_email_uniq_idx ON public.user(email);
CREATE TABLE IF NOT EXISTS CREATE TABLE IF NOT EXISTS
public.permission ( public.permission (
id SERIAL NOT NULL PRIMARY KEY, id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
status_id INT NOT NULL REFERENCES status(id) DEFAULT 1, status_id INT NOT NULL REFERENCES status(id) DEFAULT 1,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
@ -46,7 +46,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS permission_name_uniq_idx ON public.permission(
CREATE TABLE IF NOT EXISTS CREATE TABLE IF NOT EXISTS
public.user_permission ( public.user_permission (
id SERIAL NOT NULL PRIMARY KEY, id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
permission_id INT NOT NULL REFERENCES permission(id), permission_id INT NOT NULL REFERENCES permission(id),
user_id INT NOT NULL REFERENCES public.user(id), user_id INT NOT NULL REFERENCES public.user(id),
status_id INT NOT NULL REFERENCES status(id) status_id INT NOT NULL REFERENCES status(id)

View file

@ -1,23 +1,15 @@
use sqlx::prelude::FromRow; use sqlx::prelude::FromRow;
use uuid::Uuid;
use crate::models::AppError;
use super::DbPool; use super::DbPool;
#[derive(Debug)] #[derive(Debug)]
pub struct NewUserEntity { pub struct NewUserEntity {
pub username: String,
pub password: String,
pub email: String, pub email: String,
pub name: String, pub name: String,
pub user_id: Uuid,
}
impl NewUserEntity {
pub fn new(name: String, email: String) -> Self {
Self {
name,
email,
user_id: Uuid::now_v7(),
}
}
} }
#[allow(dead_code)] #[allow(dead_code)]
@ -26,40 +18,40 @@ pub struct UserEntity {
pub id: i32, pub id: i32,
pub name: String, pub name: String,
pub email: String, pub email: String,
pub user_id: Uuid,
pub status_id: i32, pub status_id: i32,
} }
// pub async fn _insert_new_user( pub async fn insert_new_user(
// pool: &DbPool, pool: &DbPool,
// new_user: NewUserEntity, new_user: NewUserEntity,
// ) -> Result<UserEntity, AppError> { ) -> Result<UserEntity, AppError> {
// let NewUserEntity { let NewUserEntity {
// name, username,
// email, password,
// user_id, email,
// } = new_user; 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;") 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(username)
// .bind(email) .bind(email)
// .bind(password) .bind(password)
// .bind(name) .bind(name)
// .fetch_one(pool).await .fetch_one(pool).await
// .map_err(|err| { .map_err(|err| {
// eprintln!("Error inserting NewUserEntity {err:?}"); eprintln!("Error inserting NewUserEntity {err:?}");
// AppError::from(err) AppError::from(err)
// }) })
// } }
// pub async fn verify_user(pool: &DbPool, user_id: i32) -> Result<(), AppError> { 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;") sqlx::query("UPDATE public.user SET status_id = 1, updated_at = now() WHERE id = $1;")
// .bind(user_id) .bind(user_id)
// .execute(pool) .execute(pool)
// .await .await
// .map_err(|err| { .map_err(|err| {
// eprintln!("Error verifying user with id '{user_id}'."); eprintln!("Error verifying user with id '{user_id}'.");
// AppError::from(err) AppError::from(err)
// }) })
// .map(|_| ()) .map(|_| ())
// } }

View file

@ -7,7 +7,7 @@ mod models;
mod requests; mod requests;
mod services; mod services;
use db::create_connection_pool; use db::{create_connection_pool, run_migrations};
use requests::start_app; use requests::start_app;
use services::{start_emailer_service, UserConfirmationMessage}; use services::{start_emailer_service, UserConfirmationMessage};
use tokio::runtime::Handle; use tokio::runtime::Handle;
@ -25,10 +25,10 @@ async fn main() {
}; };
let pool = create_connection_pool(env.db_connection_uri()).await; let pool = create_connection_pool(env.db_connection_uri()).await;
// if let Err(err) = run_migrations(&pool).await { if let Err(err) = run_migrations(&pool).await {
// eprintln!("{err:?}"); eprintln!("{err:?}");
// process::exit(2); process::exit(2);
// } }
start_emailer_service(Handle::current(), env.assets_dir(), rx); start_emailer_service(Handle::current(), env.assets_dir(), rx);

View file

@ -1,10 +1,7 @@
mod user; mod user;
use std::sync::Arc;
use axum::Router; use axum::Router;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use webauthn_rs::prelude::*;
use crate::{ use crate::{
db::DbPool, db::DbPool,
@ -15,30 +12,11 @@ use crate::{
pub struct AppState { pub struct AppState {
pool: DbPool, pool: DbPool,
env: Environment, env: Environment,
webauthn: Arc<Webauthn>,
} }
impl AppState { impl AppState {
pub fn new(pool: DbPool, env: Environment) -> Self { pub fn new(pool: DbPool, env: Environment) -> Self {
let rp_id = env.rp_id(); Self { pool, env }
let rp_origin = env
.domain()
.parse::<Url>()
.expect("RP_ORIGIN must be in a valid domain name format");
let webauthn = Arc::new(
WebauthnBuilder::new(rp_id, &rp_origin)
.map(|builder| builder.allow_any_port(true))
.and_then(WebauthnBuilder::build)
.inspect_err(|err| eprintln!("{err}"))
.expect("Unable to build authenticator"),
);
Self {
pool,
env,
webauthn,
}
} }
pub fn pool(&self) -> &DbPool { pub fn pool(&self) -> &DbPool {
@ -48,10 +26,6 @@ impl AppState {
pub fn env(&self) -> &Environment { pub fn env(&self) -> &Environment {
&self.env &self.env
} }
pub fn webauthn(&self) -> Arc<Webauthn> {
Arc::clone(&self.webauthn)
}
} }
pub async fn start_app(pool: DbPool, env: Environment) -> Result<(), AppError> { pub async fn start_app(pool: DbPool, env: Environment) -> Result<(), AppError> {
@ -66,7 +40,6 @@ pub async fn start_app(pool: DbPool, env: Environment) -> Result<(), AppError> {
axum::serve(listener, app) axum::serve(listener, app)
.await .await
.inspect(|_| println!("Application started successfully."))
.map_err(AppError::app_startup)?; .map_err(AppError::app_startup)?;
Ok(()) Ok(())

View file

@ -0,0 +1,78 @@
use std::time::Duration;
use crate::{
db::{insert_new_user, NewUserEntity, UserEntity},
models::ApiResponse,
requests::AppState,
services::{auth_token::generate_token, hash_string, UserConfirmationMessage},
};
use axum::{
extract::State,
response::{IntoResponse, Response},
Json,
};
use http::StatusCode;
use super::models::{UserRegistrationRequest, UserRegistrationResponse};
static FIFTEEN_MINUTES: u64 = 60 * 15;
pub async fn user_registration_post_handler(
State(app_state): State<AppState>,
Json(request): Json<UserRegistrationRequest>,
) -> Result<Response, Response> {
let UserRegistrationRequest {
username,
password,
email,
name,
} = request;
let hashed_password = hash_string(password);
let new_user = NewUserEntity {
username,
password: hashed_password.to_string(),
email,
name,
};
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 signing_key = app_state.env().token_key();
let (auth_token, expiration) = generate_token(
signing_key,
user_id,
Some(Duration::from_secs(FIFTEEN_MINUTES)),
Some("user-verify.debtpirate.bikeshedengineering.internal"),
);
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,
ApiResponse::new(UserRegistrationResponse {
id: user_id,
expiration,
})
.into_json_response(),
);
Ok(response.into_response())
}

View file

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

View file

@ -0,0 +1,5 @@
mod registration_request;
mod registration_response;
pub use registration_request::*;
pub use registration_response::*;

View file

@ -3,6 +3,8 @@ use serde::Deserialize;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct UserRegistrationRequest { pub struct UserRegistrationRequest {
pub name: String, pub username: String,
pub password: String,
pub email: String, pub email: String,
pub name: String,
} }

View file

@ -0,0 +1,13 @@
use std::time::SystemTime;
use serde::Serialize;
use serde_with::serde_as;
#[serde_as]
#[derive(Debug, Serialize)]
pub struct UserRegistrationResponse {
pub id: i32,
#[serde(serialize_with = "humantime_serde::serialize")]
pub expiration: SystemTime,
}

View file

@ -1,29 +1,14 @@
use axum::Router; use axum::{routing::post, Router};
use tower::ServiceBuilder; use create::user_registration_post_handler;
use tower_sessions::{
cookie::{time::Duration, SameSite},
Expiry, MemoryStore, SessionManagerLayer,
};
use super::AppState; use super::AppState;
pub mod new_user; pub mod create;
// pub mod verify; // pub mod verify;
pub fn requests(app_state: AppState) -> Router { pub fn requests(app_state: AppState) -> Router {
let domain = app_state.env().domain().to_owned(); Router::new().route(
let user_requests_middleware = ServiceBuilder::new().layer( "/user",
SessionManagerLayer::new(MemoryStore::default()) post(user_registration_post_handler).with_state(app_state.clone()),
.with_domain(domain) )
.with_secure(false)
.with_same_site(SameSite::Strict)
.with_expiry(Expiry::OnInactivity(Duration::seconds(300))),
);
Router::new()
.nest(
"/user",
Router::new().merge(new_user::request(app_state.clone())), // .merge(verify::request(app_state.clone())),
)
.layer(user_requests_middleware)
} }

View file

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

View file

@ -1,3 +0,0 @@
mod registration_request;
pub use registration_request::*;

View file

@ -1,78 +0,0 @@
use std::time::Duration;
use axum::{
extract::State,
response::{IntoResponse, Response},
routing::post,
Json, Router,
};
use http::StatusCode;
use tower_sessions::Session;
use uuid::Uuid;
use webauthn_rs::prelude::CreationChallengeResponse;
use crate::{
db::NewUserEntity,
models::ApiResponse,
requests::AppState,
services::{auth_token::generate_token, UserConfirmationMessage},
};
use super::models::UserRegistrationRequest;
static FIFTEEN_MINUTES: u64 = 60 * 15;
pub fn request(app_state: AppState) -> Router {
Router::new()
.route("/", post(user_registration_post_handler))
.with_state(app_state)
}
async fn user_registration_post_handler(
State(app_state): State<AppState>,
session: Session,
Json(request): Json<UserRegistrationRequest>,
) -> Result<Response, Response> {
let _ = session.remove_value("reg_state").await;
let UserRegistrationRequest { name, email } = request;
let user_uuid = Uuid::now_v7();
// TODO: Fetch any already saved credentials for the given user for exclusion
let (creation_challenge_response, reg) = app_state
.webauthn()
.start_passkey_registration(user_uuid, email.as_str(), name.as_str(), None)
.expect("Invalid passkey registration");
// let new_user = NewUserEntity::new(email.clone(), 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 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 new_user_entity = NewUserEntity::new(name, email);
let response = (
StatusCode::OK,
Json(ApiResponse::<CreationChallengeResponse>::new(
creation_challenge_response,
)),
);
Ok(response.into_response())
}

View file

@ -78,7 +78,11 @@ pub fn generate_token(
.token_identifier(Uuid::now_v7().to_string().as_str()) .token_identifier(Uuid::now_v7().to_string().as_str())
.map(|_| claims) .map(|_| claims)
}) })
.and_then(|mut claims| claims.issuer("auth-test").map(|_| claims)) .and_then(|mut claims| {
claims
.issuer("debtpirate.bikeshedengineering.internal")
.map(|_| claims)
})
.and_then(|mut claims| claims.subject(user_id.to_string().as_str()).map(|_| claims)) .and_then(|mut claims| claims.subject(user_id.to_string().as_str()).map(|_| claims))
.and_then(|mut claims| { .and_then(|mut claims| {
if let Some(audience) = audience { if let Some(audience) = audience {

View file

@ -3,7 +3,7 @@ use argon2::{
Argon2, Argon2,
}; };
pub fn hash_string(string: &str) -> PasswordHashString { pub fn hash_string(string: String) -> PasswordHashString {
let algorithm = Argon2::default(); let algorithm = Argon2::default();
let salt = SaltString::generate(&mut OsRng); let salt = SaltString::generate(&mut OsRng);