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
PORT=42069
DOMAIN=http://api.debtpirate.app
RP_ID=api.debtpirate.app
RP_ID=debtpirate.app
TOKEN_KEY=k4.local.hWoS2ZulK9xPEATtXH1Dvj_iynzqfUv5ER5_IFTg5-Q
DATABASE_URL=postgres://debt_pirate:HRURqlUmtjIy@192.168.122.251/debt_pirate
ASSETS_DIR=/home/zcdziura/Documents/Projects/debt-pirate/api/assets

View file

@ -14,8 +14,9 @@ axum = { version = "0.7", features = [
base64 = "0.22"
dotenvy = "0.15"
futures = "0.3"
http = "1.0.0"
http = "1.0"
humantime = "2.1.0"
humantime-serde = "1.1"
hyper = { version = "1.1", features = ["full"] }
lettre = { version = "0.11", default-features = false, features = [
"builder",
@ -28,9 +29,10 @@ lettre = { version = "0.11", default-features = false, features = [
log = "0.4"
num_cpus = "1.16"
once_cell = "1.19"
pasetors = "0.6"
pasetors = "0.7"
serde = { version = "1.0", features = ["derive", "rc", "std"] }
serde_json = "1.0"
serde_with = "3.9"
sqlx = { version = "0.8", features = [
"default",
"chrono",
@ -38,8 +40,6 @@ sqlx = { version = "0.8", features = [
"runtime-tokio",
] }
tokio = { version = "1.35", features = ["full"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["full"] }
tower-sessions = "0.12.3"
tower = "0.5"
tower-http = { version = "0.6", features = ["full"] }
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
public.status (
id SERIAL NOT NULL PRIMARY KEY,
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name VARCHAR(255) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE NULL
@ -20,7 +20,7 @@ VALUES
CREATE TABLE IF NOT EXISTS
public.user (
id SERIAL NOT NULL PRIMARY KEY,
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
username VARCHAR(255) NOT NULL,
password 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
public.permission (
id SERIAL NOT NULL PRIMARY KEY,
id INTEGER GENERATED ALWAYS AS IDENTITY 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(),
@ -46,7 +46,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS permission_name_uniq_idx ON public.permission(
CREATE TABLE IF NOT EXISTS
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),
user_id INT NOT NULL REFERENCES public.user(id),
status_id INT NOT NULL REFERENCES status(id)

View file

@ -1,23 +1,15 @@
use sqlx::prelude::FromRow;
use uuid::Uuid;
use crate::models::AppError;
use super::DbPool;
#[derive(Debug)]
pub struct NewUserEntity {
pub username: String,
pub password: String,
pub email: 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)]
@ -26,40 +18,40 @@ pub struct UserEntity {
pub id: i32,
pub name: String,
pub email: String,
pub user_id: Uuid,
pub status_id: i32,
}
// pub async fn _insert_new_user(
// pool: &DbPool,
// new_user: NewUserEntity,
// ) -> Result<UserEntity, AppError> {
// let NewUserEntity {
// name,
// email,
// user_id,
// } = new_user;
pub async fn insert_new_user(
pool: &DbPool,
new_user: NewUserEntity,
) -> Result<UserEntity, AppError> {
let NewUserEntity {
username,
password,
email,
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)
// })
// }
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(|_| ())
// }
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(|_| ())
}

View file

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

View file

@ -1,10 +1,7 @@
mod user;
use std::sync::Arc;
use axum::Router;
use tokio::net::TcpListener;
use webauthn_rs::prelude::*;
use crate::{
db::DbPool,
@ -15,30 +12,11 @@ use crate::{
pub struct AppState {
pool: DbPool,
env: Environment,
webauthn: Arc<Webauthn>,
}
impl AppState {
pub fn new(pool: DbPool, env: Environment) -> Self {
let rp_id = env.rp_id();
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,
}
Self { pool, env }
}
pub fn pool(&self) -> &DbPool {
@ -48,10 +26,6 @@ impl AppState {
pub fn env(&self) -> &Environment {
&self.env
}
pub fn webauthn(&self) -> Arc<Webauthn> {
Arc::clone(&self.webauthn)
}
}
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)
.await
.inspect(|_| println!("Application started successfully."))
.map_err(AppError::app_startup)?;
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)]
#[serde(rename_all = "camelCase")]
pub struct UserRegistrationRequest {
pub name: String,
pub username: String,
pub password: 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 tower::ServiceBuilder;
use tower_sessions::{
cookie::{time::Duration, SameSite},
Expiry, MemoryStore, SessionManagerLayer,
};
use axum::{routing::post, Router};
use create::user_registration_post_handler;
use super::AppState;
pub mod new_user;
pub mod create;
// pub mod verify;
pub fn requests(app_state: AppState) -> Router {
let domain = app_state.env().domain().to_owned();
let user_requests_middleware = ServiceBuilder::new().layer(
SessionManagerLayer::new(MemoryStore::default())
.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)
Router::new().route(
"/user",
post(user_registration_post_handler).with_state(app_state.clone()),
)
}

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())
.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| {
if let Some(audience) = audience {

View file

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