diff --git a/Cargo.lock b/Cargo.lock index cce6841..faed9ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,6 +64,18 @@ version = "1.0.98" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "arrayvec" version = "0.7.6" @@ -255,6 +267,15 @@ dependencies = [ "wyz", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -363,6 +384,7 @@ name = "chat-app" version = "0.1.0" dependencies = [ "anyhow", + "argon2", "axum", "axum-valid", "axum_typed_multipart", @@ -1389,6 +1411,17 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pem" version = "3.0.5" diff --git a/Cargo.toml b/Cargo.toml index fa59767..217ef3a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,3 +17,4 @@ chrono = { version = "0.4.41", features = ["serde"] } jsonwebtoken = "9.3.1" sqlx = { version = "0.8.6", features = ["chrono", "postgres", "runtime-tokio"] } dotenv = "0.15.0" +argon2 = "0.5.3" diff --git a/src/auth.rs b/src/auth.rs index 8beba61..97ab290 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,17 +1,10 @@ use std::sync::LazyLock; -use axum::{ - extract::{Request, State}, - http::StatusCode, - middleware::Next, - response::Response, -}; +use axum::{extract::Request, http::StatusCode, middleware::Next, response::Response}; use chrono::Utc; -use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode}; +use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Validation, decode}; use serde::{Deserialize, Serialize}; -use sqlx::{FromRow, Postgres}; - -use crate::state::AppState; +use sqlx::FromRow; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -49,24 +42,9 @@ impl Claims { #[derive(Debug, Clone, FromRow)] pub struct User { - id: i64, -} - -pub async fn get_auth_token(State(state): State) -> Result { - let Ok(user) = sqlx::query_as::("INSERT INTO users DEFAULT VALUES RETURNING *") - .fetch_one(&state.pg_pool) - .await - else { - return Err(StatusCode::INTERNAL_SERVER_ERROR); - }; - - let claims = Claims::new(user.id); - - let Ok(token) = encode(&Header::new(Algorithm::RS512), &claims, &AUTH_SECRET_KEY) else { - return Err(StatusCode::INTERNAL_SERVER_ERROR); - }; - - Ok(token) + pub id: i64, + pub name: String, + pub hash: String, } pub fn verify_token(token: &str) -> Option { diff --git a/src/login.rs b/src/login.rs new file mode 100644 index 0000000..f84147f --- /dev/null +++ b/src/login.rs @@ -0,0 +1,57 @@ +use argon2::{Argon2, PasswordHash, PasswordVerifier}; +use axum::{extract::State, http::StatusCode}; +use axum_typed_multipart::{TryFromMultipart, TypedMultipart}; +use axum_valid::ValidifiedByRef; +use jsonwebtoken::{Algorithm, Header, encode}; +use sqlx::Postgres; +use validify::Validify; + +use crate::{ + auth::{AUTH_SECRET_KEY, Claims, User}, + state::AppState, +}; + +#[derive(Validify, TryFromMultipart)] +#[try_from_multipart(rename_all = "kebab-case")] +pub struct LoginData { + #[modify(trim)] + #[validate(length(min = 2, max = 24))] + name: String, + #[modify(trim)] + #[validate(length(min = 8, max = 64))] + password: String, +} + +pub async fn handler( + State(state): State, + ValidifiedByRef(TypedMultipart(data)): ValidifiedByRef>, +) -> Result { + let argon2 = Argon2::default(); + + let Ok(user) = sqlx::query_as::("SELECT * FROM users WHERE name = $1") + .bind(data.name) + .fetch_one(&state.pg_pool) + .await + else { + return Err(StatusCode::NOT_FOUND); + }; + + let Ok(hash) = PasswordHash::new(&user.hash) else { + return Err(StatusCode::INTERNAL_SERVER_ERROR); + }; + + if argon2 + .verify_password(data.password.as_bytes(), &hash) + .is_err() + { + return Err(StatusCode::UNAUTHORIZED); + } + + let claims = Claims::new(user.id); + + let Ok(token) = encode(&Header::new(Algorithm::RS512), &claims, &AUTH_SECRET_KEY) else { + return Err(StatusCode::INTERNAL_SERVER_ERROR); + }; + + Ok(token) +} diff --git a/src/main.rs b/src/main.rs index 932be63..6c9b03a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,9 @@ mod auth; +mod login; mod message; mod message_history; mod postgres; +mod register; mod send_message_handler; mod state; mod websockets; @@ -27,7 +29,8 @@ async fn main() -> anyhow::Result<()> { let unauthenticated_routes: Router = Router::new() .route("/ws", get(websockets::websocket_handler)) - .route("/auth", get(auth::get_auth_token)) + .route("/register", post(register::create_user_handler)) + .route("/login", post(login::handler)) .with_state(state.clone()); let authenticated_router: Router = Router::new() diff --git a/src/register.rs b/src/register.rs new file mode 100644 index 0000000..883d852 --- /dev/null +++ b/src/register.rs @@ -0,0 +1,57 @@ +use argon2::{Argon2, PasswordHasher, password_hash::SaltString}; +use axum::{extract::State, http::StatusCode}; +use axum_typed_multipart::{TryFromMultipart, TypedMultipart}; +use axum_valid::ValidifiedByRef; +use jsonwebtoken::{Algorithm, Header, encode}; +use sqlx::Postgres; +use validify::Validify; + +use crate::{ + auth::{AUTH_SECRET_KEY, Claims, User}, + state::AppState, +}; + +#[derive(Validify, TryFromMultipart)] +#[try_from_multipart(rename_all = "kebab-case")] +pub struct RegisterData { + #[modify(trim)] + #[validate(length(min = 2, max = 24))] + name: String, + #[modify(trim)] + #[validate(length(min = 8, max = 64))] + password: String, +} + +pub async fn create_user_handler( + State(state): State, + ValidifiedByRef(TypedMultipart(data)): ValidifiedByRef>, +) -> Result { + let argon2 = Argon2::default(); + let salt = SaltString::generate(argon2::password_hash::rand_core::OsRng); + + let Ok(hash) = argon2 + .hash_password(data.password.as_bytes(), &salt) + .map(|hash| hash.to_string()) + else { + return Err(StatusCode::INTERNAL_SERVER_ERROR); + }; + + let Ok(user) = sqlx::query_as::( + "INSERT INTO users (name, hash) VALUES ($1, $2) RETURNING *", + ) + .bind(data.name) + .bind(hash) + .fetch_one(&state.pg_pool) + .await + else { + return Err(StatusCode::INTERNAL_SERVER_ERROR); + }; + + let claims = Claims::new(user.id); + + let Ok(token) = encode(&Header::new(Algorithm::RS512), &claims, &AUTH_SECRET_KEY) else { + return Err(StatusCode::INTERNAL_SERVER_ERROR); + }; + + Ok(token) +}