From ff62ce1761f8bac8f9a65ccee59bf754beb1f950 Mon Sep 17 00:00:00 2001 From: Kristofers Solo Date: Tue, 11 Feb 2025 11:14:28 +0200 Subject: [PATCH] feat(migrations): migrate old code --- ...628f4ff2e8623c00e0675f2b5866cda7c49bf.json | 15 ++++ Cargo.toml | 11 ++- migrations/.gitkeep | 0 migrations/20250125123853_init.down.sql | 11 +++ migrations/20250125123853_init.up.sql | 28 ++++++ scripts/init_db | 2 +- src/domain/mod.rs | 2 + src/domain/user/mod.rs | 3 + src/domain/user/new_user.rs | 7 ++ src/domain/user/user_code.rs | 73 +++++++++++++++ src/domain/user/username.rs | 73 +++++++++++++++ src/errors/app.rs | 88 +++++++++++++++++++ src/errors/mod.rs | 2 + src/errors/user.rs | 22 +++++ src/repositories/mod.rs | 2 + src/repositories/user.rs | 36 ++++++++ src/routes/api/mod.rs | 9 ++ src/routes/api/v1/auth.rs | 71 +++++++++++++++ src/routes/api/v1/mod.rs | 9 ++ src/routes/mod.rs | 2 + 20 files changed, 464 insertions(+), 2 deletions(-) create mode 100644 .sqlx/query-76811378181edbd741685f253a2628f4ff2e8623c00e0675f2b5866cda7c49bf.json delete mode 100644 migrations/.gitkeep create mode 100644 migrations/20250125123853_init.down.sql create mode 100644 migrations/20250125123853_init.up.sql mode change 100644 => 100755 scripts/init_db create mode 100644 src/domain/user/mod.rs create mode 100644 src/domain/user/new_user.rs create mode 100644 src/domain/user/user_code.rs create mode 100644 src/domain/user/username.rs create mode 100644 src/errors/app.rs create mode 100644 src/errors/user.rs create mode 100644 src/repositories/user.rs create mode 100644 src/routes/api/mod.rs create mode 100644 src/routes/api/v1/auth.rs create mode 100644 src/routes/api/v1/mod.rs diff --git a/.sqlx/query-76811378181edbd741685f253a2628f4ff2e8623c00e0675f2b5866cda7c49bf.json b/.sqlx/query-76811378181edbd741685f253a2628f4ff2e8623c00e0675f2b5866cda7c49bf.json new file mode 100644 index 0000000..89d9f8e --- /dev/null +++ b/.sqlx/query-76811378181edbd741685f253a2628f4ff2e8623c00e0675f2b5866cda7c49bf.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO \"user\" (username, code)\n VALUES ($1, $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "76811378181edbd741685f253a2628f4ff2e8623c00e0675f2b5866cda7c49bf" +} diff --git a/Cargo.toml b/Cargo.toml index 242c290..4822a65 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ axum = "0.8" chrono = { version = "0.4", features = ["serde", "clock"] } config = { version = "0.15", features = ["toml"], default-features = false } serde = { version = "1", features = ["derive"] } +serde_json = "1" sqlx = { version = "0.8", default-features = false, features = [ "runtime-tokio", "tls-rustls", @@ -31,7 +32,7 @@ tokio = { version = "1.39", features = [ "tracing", "rt-multi-thread", ] } -uuid = { version = "1.8", features = ["v4", "serde"] } +uuid = { version = "1.13", features = ["v4", "serde"] } tracing = { version = "0.1", features = ["log"] } tracing-subscriber = { version = "0.3", features = ["registry", "env-filter"] } tower-http = { version = "0.6", features = ["trace"] } @@ -44,6 +45,14 @@ reqwest = { version = "0.12", default-features = false, features = [ "rustls-tls", ] } askama = { version = "0.12", features = ["with-axum"] } +validator = "0.20" +unicode-segmentation = "1" +rand = "0.8" +argon2 = "0.5" +password-hash = "0.5" +hex = "0.4" +anyhow = "1" +thiserror = "2" [dev-dependencies] diff --git a/migrations/.gitkeep b/migrations/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/migrations/20250125123853_init.down.sql b/migrations/20250125123853_init.down.sql new file mode 100644 index 0000000..679391c --- /dev/null +++ b/migrations/20250125123853_init.down.sql @@ -0,0 +1,11 @@ +-- Add down migration script here +-- Drop indexes first +DROP INDEX IF EXISTS idx_scores_user_score; + +DROP INDEX IF EXISTS idx_users_login; + +-- Drop tables in reverse order of creation +DROP TABLE IF EXISTS scores; + +DROP TABLE IF EXISTS users; + diff --git a/migrations/20250125123853_init.up.sql b/migrations/20250125123853_init.up.sql new file mode 100644 index 0000000..a82fe10 --- /dev/null +++ b/migrations/20250125123853_init.up.sql @@ -0,0 +1,28 @@ +-- Add up migration script here +-- Enable UUID support +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +-- Users table with login codes +CREATE TABLE IF NOT EXISTS "user" ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid (), + username varchar(255) NOT NULL UNIQUE, + code varchar(255) NOT NULL UNIQUE, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Scores table with detailed game stats +CREATE TABLE IF NOT EXISTS score ( + id bigserial PRIMARY KEY, + user_id uuid NOT NULL, + score integer NOT NULL, + floor_reached integer NOT NULL, + play_time_seconds integer NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES "user" (id) +); + +-- Indexes for performance +CREATE INDEX idx_user_login ON "user" (code); + +CREATE INDEX idx_scores_user_score ON score (user_id, score DESC); + diff --git a/scripts/init_db b/scripts/init_db old mode 100644 new mode 100755 index 234a274..68163c4 --- a/scripts/init_db +++ b/scripts/init_db @@ -16,7 +16,7 @@ fi DB_USER="${POSTGRES_USER:=postgres}" DB_PASSWORD="${POSTGRES_PASSWORD:=password}" -DB_NAME="${POSTGRES_DB:=newsletter}" +DB_NAME="${POSTGRES_DB:=echoes-of-ascension}" DB_PORT="${POSTGRES_PORT:=5432}" DB_HOST="${POSTGRES_HOST:=localhost}" diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 81613e2..1bbb0a2 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -22,3 +22,5 @@ //! //! pub struct UserId(pub String); //! ``` + +pub mod user; diff --git a/src/domain/user/mod.rs b/src/domain/user/mod.rs new file mode 100644 index 0000000..566a020 --- /dev/null +++ b/src/domain/user/mod.rs @@ -0,0 +1,3 @@ +pub mod new_user; +mod user_code; +mod username; diff --git a/src/domain/user/new_user.rs b/src/domain/user/new_user.rs new file mode 100644 index 0000000..fe70c35 --- /dev/null +++ b/src/domain/user/new_user.rs @@ -0,0 +1,7 @@ +use super::{user_code::UserCode, username::Username}; + +#[derive(Debug, Clone, Default)] +pub struct NewUser { + pub username: Username, + pub code: UserCode, +} diff --git a/src/domain/user/user_code.rs b/src/domain/user/user_code.rs new file mode 100644 index 0000000..51bcbce --- /dev/null +++ b/src/domain/user/user_code.rs @@ -0,0 +1,73 @@ +use argon2::Argon2; +use password_hash::SaltString; +use std::ops::Deref; + +use rand::{rngs::OsRng, thread_rng, Rng}; +use secrecy::{ExposeSecret, SecretString}; + +use crate::errors::user::UserError; + +#[derive(Debug, Clone)] +pub struct UserCode(SecretString); + +impl UserCode { + pub fn hash(&self) -> Result { + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + + let mut output_key_material = [0u8; 32]; + argon2 + .hash_password_into( + self.expose_secret().as_bytes(), + salt.as_str().as_bytes(), + &mut output_key_material, + ) + .map_err(|e| UserError::HashingError(e.to_string()))?; + Ok(format!( + "{}${}", + salt.as_str(), + hex::encode(output_key_material) + )) + } + + pub fn verify(stored: &str, code: &str) -> Result { + let argon2 = Argon2::default(); + + // Split stored value into salt and hash + let parts: Vec<&str> = stored.split('$').collect(); + if parts.len() != 2 { + return Err(UserError::HashingError("Invalid hash format".to_string())); + } + + let salt = parts[0]; + let stored_hash = + hex::decode(parts[1]).map_err(|e| UserError::HashingError(e.to_string()))?; + + let mut output = [0u8; 32]; + argon2 + .hash_password_into(code.as_bytes(), salt.as_bytes(), &mut output) + .map_err(|e| UserError::HashingError(e.to_string()))?; + + Ok(output.as_slice() == stored_hash.as_slice()) + } +} + +impl Default for UserCode { + fn default() -> Self { + let mut rng = thread_rng(); + + let code = (0..16) + .map(|_| rng.gen_range(0..10).to_string()) + .collect::(); + + Self(code.into()) + } +} + +impl Deref for UserCode { + type Target = SecretString; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} diff --git a/src/domain/user/username.rs b/src/domain/user/username.rs new file mode 100644 index 0000000..1b6794a --- /dev/null +++ b/src/domain/user/username.rs @@ -0,0 +1,73 @@ +use rand::{seq::SliceRandom, thread_rng, Rng}; +use std::{fmt::Display, str::FromStr}; +use unicode_segmentation::UnicodeSegmentation; + +use crate::errors::user::UserError; + +#[derive(Debug, Clone)] +pub struct Username(String); + +impl TryFrom for Username { + type Error = UserError; + fn try_from(value: String) -> Result { + let is_empty_or_whitespace = value.trim().is_empty(); + let is_too_long = value.graphemes(true).count() > 256; + let forbidden_characters = ['/', '(', ')', '"', '<', '>', '\\', '{', '}']; + let contains_forbidden_characters = + value.chars().any(|c| forbidden_characters.contains(&c)); + if is_empty_or_whitespace || is_too_long || contains_forbidden_characters { + return Err(UserError::UsernameValidation(value)); + } + Ok(Self(value)) + } +} + +impl Default for Username { + fn default() -> Self { + let adjectives = [ + "swift", "bright", "clever", "brave", "mighty", "noble", "wise", "calm", "kind", + "bold", "quick", "sharp", "smart", "keen", "fair", + ]; + + let nouns = [ + "wolf", "eagle", "lion", "hawk", "bear", "tiger", "fox", "owl", "deer", "seal", + "raven", "crane", "dove", "swan", "falcon", + ]; + + let mut rng = thread_rng(); + + let adjective = adjectives.choose(&mut rng).unwrap_or(&"swift"); + let noun = nouns.choose(&mut rng).unwrap_or(&"wolf"); + + let number = rng.gen_range(100..1000); + + let username = format!("{adjective}_{noun}_{number}"); + + Self(username) + } +} + +impl FromStr for Username { + type Err = UserError; + fn from_str(s: &str) -> Result { + Self::try_from(s.to_owned()) + } +} + +impl AsRef for Username { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Display for Username { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From for String { + fn from(value: Username) -> Self { + value.0 + } +} diff --git a/src/errors/app.rs b/src/errors/app.rs new file mode 100644 index 0000000..6a3eca2 --- /dev/null +++ b/src/errors/app.rs @@ -0,0 +1,88 @@ +use axum::{http::StatusCode, response::IntoResponse, Json}; +use serde::Serialize; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum AppError { + // Authentication/Authorization errors + #[error("Unauthorized")] + Unauthorized, + + #[error("Forbidden")] + Forbidden, + + // Validation errors + #[error("Validation error: {0}")] + Validation(String), + + // Resource errors + #[error("Resource not found: {0}")] + NotFound(String), + + #[error("{resource} already exists: {id}")] + AlreadyExists { resource: &'static str, id: String }, + + // Database errors + #[error("Database error")] + Database(#[from] sqlx::Error), + + // Internal errors + #[error("Internal server error")] + Internal(#[from] anyhow::Error), +} +#[derive(Debug, Serialize)] +pub struct ErrorResponse { + pub error: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +impl IntoResponse for AppError { + fn into_response(self) -> axum::response::Response { + let (status, error_message, details) = match self { + // Auth errors + Self::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized".to_string(), None), + Self::Forbidden => (StatusCode::FORBIDDEN, "Forbidden".to_string(), None), + // Validation errors + Self::Validation(msg) => ( + StatusCode::BAD_REQUEST, + "Validation error".to_string(), + Some(msg), + ), + // Resource errors + Self::NotFound(resource) => ( + StatusCode::NOT_FOUND, + "Resource not found".to_string(), + Some(resource), + ), + Self::AlreadyExists { resource, id } => ( + StatusCode::CONFLICT, + format!("{resource} already exists"), + Some(id), + ), + // Database/Internal errors + Self::Database(e) => { + tracing::error!("Database error: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal server error".to_string(), + None, + ) + } + Self::Internal(e) => { + tracing::error!("Internal error: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal server error".to_string(), + None, + ) + } + }; + + let body = Json(ErrorResponse { + error: error_message, + details, + }); + (status, body).into_response() + } +} diff --git a/src/errors/mod.rs b/src/errors/mod.rs index e69de29..836e47e 100644 --- a/src/errors/mod.rs +++ b/src/errors/mod.rs @@ -0,0 +1,2 @@ +pub mod app; +pub mod user; diff --git a/src/errors/user.rs b/src/errors/user.rs new file mode 100644 index 0000000..90d9325 --- /dev/null +++ b/src/errors/user.rs @@ -0,0 +1,22 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum UserError { + #[error("Username validation failed: {0}")] + UsernameValidation(String), + + #[error("Code hashing failed: {0}")] + HashingError(String), + + #[error("Username already taken: {0}")] + UsernameTaken(String), + + #[error("Invalid code format")] + InvalidCode, + + #[error("Authentication failed")] + AuthenticationFailed, + + #[error("Internal server error: {0}")] + Internal(String), +} diff --git a/src/repositories/mod.rs b/src/repositories/mod.rs index 3f8706b..8458b58 100644 --- a/src/repositories/mod.rs +++ b/src/repositories/mod.rs @@ -23,3 +23,5 @@ //! Ok(()) //! } //! ``` + +pub mod user; diff --git a/src/repositories/user.rs b/src/repositories/user.rs new file mode 100644 index 0000000..726ac2e --- /dev/null +++ b/src/repositories/user.rs @@ -0,0 +1,36 @@ +use sqlx::PgPool; +use thiserror::Error; + +use crate::{domain::user::new_user::NewUser, errors::user::UserError}; + +#[derive(Debug, Error)] +pub enum ServerUserError { + #[error("Database error: {0}")] + Database(#[from] sqlx::Error), + #[error("Database error: {0}")] + User(#[from] UserError), +} + +#[tracing::instrument(name = "Saving new user details in the database", skip(pool, new_user))] +pub async fn insert_user(pool: &PgPool, new_user: &NewUser) -> Result<(), ServerUserError> { + sqlx::query!( + r#" + INSERT INTO "user" (username, code) + VALUES ($1, $2) + "#, + new_user.username.as_ref(), + new_user.code.hash()? + ) + .execute(pool) + .await + .map_err(|e| { + tracing::error!("Failed to execute query: {:?}", e); + match e { + sqlx::Error::Database(ref dbe) if dbe.constraint() == Some("user_username_key") => { + ServerUserError::User(UserError::UsernameTaken(new_user.username.to_string())) + } + _ => ServerUserError::Database(e), + } + })?; + Ok(()) +} diff --git a/src/routes/api/mod.rs b/src/routes/api/mod.rs new file mode 100644 index 0000000..9b18325 --- /dev/null +++ b/src/routes/api/mod.rs @@ -0,0 +1,9 @@ +mod v1; + +use axum::Router; + +use crate::startup::AppState; + +pub fn routes() -> Router { + Router::new().nest("/v1", v1::routes()) +} diff --git a/src/routes/api/v1/auth.rs b/src/routes/api/v1/auth.rs new file mode 100644 index 0000000..750eb37 --- /dev/null +++ b/src/routes/api/v1/auth.rs @@ -0,0 +1,71 @@ +use crate::{ + domain::user::new_user::NewUser, + errors::{app::AppError, user::UserError}, + repositories::user::{insert_user, ServerUserError}, + startup::AppState, +}; +use anyhow::anyhow; +use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; +use secrecy::ExposeSecret; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize)] +pub struct FormData { + pub username: String, +} + +#[derive(Debug, Serialize)] +pub struct Response { + pub username: String, + pub code: String, +} + +#[tracing::instrument( + name = "Creating new user", + skip(state, payload), + fields( + username= %payload.username, + ) +)] +pub async fn register( + State(state): State, + Json(payload): Json, +) -> Result { + let new_user = payload + .try_into() + .map_err(|e: UserError| AppError::Validation(e.to_string()))?; + + match insert_user(&state.pool, &new_user).await { + Ok(()) => Ok((StatusCode::CREATED, Json(Response::from(new_user)))), + Err(ServerUserError::User(UserError::UsernameTaken(username))) => { + Err(AppError::AlreadyExists { + resource: "User", + id: username, + }) + } + Err(e) => { + tracing::error!("Failed to register user: {}", e); + Err(AppError::Internal(anyhow!(e))) + } + } +} + +impl TryFrom for NewUser { + type Error = UserError; + fn try_from(value: FormData) -> Result { + let username = value.username.try_into()?; + Ok(Self { + username, + ..Default::default() + }) + } +} + +impl From for Response { + fn from(value: NewUser) -> Self { + Self { + username: value.username.into(), + code: value.code.expose_secret().into(), + } + } +} diff --git a/src/routes/api/v1/mod.rs b/src/routes/api/v1/mod.rs new file mode 100644 index 0000000..b4d8e9c --- /dev/null +++ b/src/routes/api/v1/mod.rs @@ -0,0 +1,9 @@ +mod auth; + +use axum::{routing::post, Router}; + +use crate::startup::AppState; + +pub fn routes() -> Router { + Router::new().route("/register", post(auth::register)) +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index f45395d..57e8058 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,3 +1,4 @@ +mod api; mod health_check; use axum::{routing::get, Router}; @@ -19,6 +20,7 @@ use uuid::Uuid; pub fn route(state: AppState) -> Router { Router::new() .route("/health_check", get(health_check)) + .nest("/api", api::routes()) .with_state(state) .layer( TraceLayer::new_for_http()