diff --git a/Cargo.lock b/Cargo.lock index 4212eca..6e73d97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,24 @@ dependencies = [ "libc", ] +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[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 = "atoi" version = "2.0.0" @@ -120,6 +138,15 @@ dependencies = [ "serde_core", ] +[[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" @@ -170,7 +197,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", + "serde", + "wasm-bindgen", "windows-link", ] @@ -445,6 +475,19 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -696,6 +739,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -725,6 +774,8 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown 0.17.0", + "serde", + "serde_core", ] [[package]] @@ -752,6 +803,12 @@ dependencies = [ "spin", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.185" @@ -945,6 +1002,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -1017,6 +1085,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1035,6 +1113,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.5" @@ -1062,7 +1146,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", ] [[package]] @@ -1104,7 +1188,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" name = "rhythm_backend" version = "0.1.0" dependencies = [ + "argon2", "axum", + "chrono", "dotenvy", "serde", "serde_json", @@ -1114,6 +1200,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "uuid", ] [[package]] @@ -1124,7 +1211,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -1202,6 +1289,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -1838,6 +1931,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "untrusted" version = "0.9.0" @@ -1868,7 +1967,9 @@ version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ + "getrandom 0.4.2", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -1896,6 +1997,24 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasite" version = "0.1.0" @@ -1947,6 +2066,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -2182,6 +2335,94 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "writeable" version = "0.6.3" diff --git a/Cargo.toml b/Cargo.toml index 7cdc6e9..583bd0f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,9 @@ version = "0.1.0" edition = "2024" [dependencies] +argon2 = "0.5.3" axum = "0.8.9" +chrono = { version = "0.4.42", features = ["serde"] } dotenvy = "0.15.7" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" @@ -14,3 +16,4 @@ tokio = { version = "1.52.0", features = ["macros", "rt-multi-thread"] } tracing = "0.1.44" tower-http = { version = "0.6.6", features = ["trace"] } tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } +uuid = { version = "1.18.1", features = ["serde", "v4"] } diff --git a/bruno/.gitignore b/bruno/.gitignore new file mode 100644 index 0000000..e19311f --- /dev/null +++ b/bruno/.gitignore @@ -0,0 +1,9 @@ +# Secrets +.env* + +# Dependencies +node_modules + +# OS files +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/bruno/Untitled.yml b/bruno/Untitled.yml new file mode 100644 index 0000000..c6b2e77 --- /dev/null +++ b/bruno/Untitled.yml @@ -0,0 +1,20 @@ +info: + name: Untitled + type: http + seq: 1 + +http: + method: POST + url: "{{host}}/api/auth/register" + auth: inherit + +runtime: + variables: + - name: host + value: http://localhost:8080 + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/bruno/opencollection.yml b/bruno/opencollection.yml new file mode 100644 index 0000000..3d96ac5 --- /dev/null +++ b/bruno/opencollection.yml @@ -0,0 +1,10 @@ +opencollection: 1.0.0 + +info: + name: rhythm +bundled: false +extensions: + bruno: + ignore: + - node_modules + - .git diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 0000000..b662d58 --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,95 @@ +# SQLx Migration Guide + +This project uses SQLx migrations and runs them on application startup. + +## 1) Install SQLx CLI + +### Prerequisites +- Rust toolchain installed (`rustup`) +- PostgreSQL reachable from your machine +- `DATABASE_URL` available + +### Install command +```bash +cargo install sqlx-cli --no-default-features --features rustls,postgres +``` + +Verify installation: +```bash +sqlx --version +``` + +## 2) Configure `DATABASE_URL` + +Set your database connection string: + +```bash +export DATABASE_URL=postgres://DB_USERNAME:DB_PASSWORD@DB_HOST:DB_PORT/DB_NAME +``` + +Example: +```bash +export DATABASE_URL=postgres://rhythm:rhythm@localhost:5432/rhythm_db +``` + +## 3) Create a new migration + +Create a migration file: + +```bash +sqlx migrate add create_users +``` + +This generates a timestamped SQL file under `migrations/`, for example: +- `migrations/20260416103000_create_users.sql` + +If you want reversible migrations (up/down): +```bash +sqlx migrate add -r create_users +``` + +## 4) Run migrations manually + +Apply pending migrations: + +```bash +sqlx migrate run +``` + +Show migration status: + +```bash +sqlx migrate info +``` + +Revert the most recent migration (only for reversible migrations): + +```bash +sqlx migrate revert +``` + +## 5) Startup migrations (application) + +Add this after creating the database pool in `src/main.rs`: + +```rust +sqlx::migrate!("./migrations").run(&pool).await?; +``` + +Behavior: +- Pending migrations are applied on every startup. +- Applied migrations are tracked in the `_sqlx_migrations` table. +- If a migration fails, the app fails fast and does not start listening. + +## 6) Team workflow + +- Create a new migration for every schema change. +- Commit migration files to git. +- Do not modify migration files that are already applied in shared environments. +- Add a new migration to evolve schema safely. + +## 7) Production notes + +- Running migrations on startup in production is supported. +- In multi-instance deployments, one instance may apply migrations while others wait/retry according to orchestration settings. +- Prefer backward-compatible migrations for rolling deployments. diff --git a/migrations/20260416112927_create_users_table.sql b/migrations/20260416112927_create_users_table.sql new file mode 100644 index 0000000..54e314f --- /dev/null +++ b/migrations/20260416112927_create_users_table.sql @@ -0,0 +1,9 @@ +-- Add migration script here + +CREATE TABLE USERS ( + ID UUID PRIMARY KEY DEFAULT gen_random_uuid(), + EMAIL VARCHAR(255) NOT NULL UNIQUE, + PASSWORD VARCHAR(255) NOT NULL, + CREATED_AT TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UPDATED_AT TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/src/db/mod.rs b/src/db/mod.rs index 8fd0a6b..0b04d1b 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1 +1,3 @@ pub mod database; +pub mod model; +pub mod user_repo; diff --git a/src/db/model/mod.rs b/src/db/model/mod.rs new file mode 100644 index 0000000..22d12a3 --- /dev/null +++ b/src/db/model/mod.rs @@ -0,0 +1 @@ +pub mod user; diff --git a/src/db/model/user/mod.rs b/src/db/model/user/mod.rs new file mode 100644 index 0000000..926dfac --- /dev/null +++ b/src/db/model/user/mod.rs @@ -0,0 +1 @@ +pub mod user_dto; diff --git a/src/db/model/user/user_dto.rs b/src/db/model/user/user_dto.rs new file mode 100644 index 0000000..82e3019 --- /dev/null +++ b/src/db/model/user/user_dto.rs @@ -0,0 +1,8 @@ +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct User { + pub id: uuid::Uuid, + pub email: String, + pub password: String, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} diff --git a/src/db/user_repo.rs b/src/db/user_repo.rs new file mode 100644 index 0000000..6b2783c --- /dev/null +++ b/src/db/user_repo.rs @@ -0,0 +1,42 @@ +use crate::db::model::user::user_dto::User; +use crate::error::AppError; +use sqlx::{Postgres, Transaction}; + +pub async fn email_exists( + tx: &mut Transaction<'_, Postgres>, + email: &str, +) -> Result { + let existing = sqlx::query_scalar::<_, bool>( + r#" + SELECT TRUE + FROM users + WHERE email = $1 + LIMIT 1 + "#, + ) + .bind(email) + .fetch_optional(&mut **tx) + .await?; + + Ok(existing.unwrap_or(false)) +} + +pub async fn create_user_in_tx( + tx: &mut Transaction<'_, Postgres>, + email: &str, + password_hash: &str, +) -> Result { + let user = sqlx::query_as::<_, User>( + r#" + INSERT INTO users (email, password) + VALUES ($1, $2) + RETURNING id, email, password, created_at, updated_at + "#, + ) + .bind(email) + .bind(password_hash) + .fetch_one(&mut **tx) + .await?; + + Ok(user) +} diff --git a/src/error.rs b/src/error.rs index 1c7fd2d..83a8732 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,3 +1,9 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde::Serialize; use thiserror::Error; #[derive(Debug, Error)] @@ -8,4 +14,31 @@ pub enum AppError { Db(#[from] sqlx::Error), #[error(transparent)] Io(#[from] std::io::Error), + #[error(transparent)] + Migration(#[from] sqlx::migrate::MigrateError), + #[error("validation error: {0}")] + Validation(String), + #[error("conflict: {0}")] + Conflict(String), +} + +#[derive(Serialize)] +struct ErrorBody { + error: String, +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let status = match self { + Self::Validation(_) => StatusCode::BAD_REQUEST, + Self::Conflict(_) => StatusCode::CONFLICT, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }; + + let body = Json(ErrorBody { + error: self.to_string(), + }); + + (status, body).into_response() + } } diff --git a/src/http/auth_router.rs b/src/http/auth_router.rs index c4795f7..9f1891a 100644 --- a/src/http/auth_router.rs +++ b/src/http/auth_router.rs @@ -1,17 +1,25 @@ -use axum::{Json, Router, routing::post}; -use serde::Serialize; +use crate::http::model::register_user_req::RegisterUserReq; +use crate::http::model::register_user_res::RegisterUserRes; +use crate::service::auth_service; +use axum::{Json, Router, extract::State, routing::post}; use crate::app_state::AppState; - -#[derive(Serialize)] -struct HealthResponse { - status: &'static str, -} +use crate::error::AppError; pub fn router() -> Router { - Router::new().route("/register", post(register)) + Router::new() + .route("/register", post(register)) + .route("/login", post(login)) } -async fn register() -> Json { - Json(HealthResponse { status: "ok" }) +async fn register( + State(state): State, + Json(body): Json, +) -> Result, AppError> { + let response = auth_service::register(&state.db, body).await?; + Ok(Json(response)) +} + +async fn login() -> &'static str { + "login not implemented yet" } diff --git a/src/http/mod.rs b/src/http/mod.rs index 4c87eef..cd007ad 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -1,3 +1,4 @@ pub mod api_router; -mod auth_router; +pub mod auth_router; mod health_router; +pub mod model; diff --git a/src/http/model/mod.rs b/src/http/model/mod.rs new file mode 100644 index 0000000..38581ce --- /dev/null +++ b/src/http/model/mod.rs @@ -0,0 +1,2 @@ +pub mod register_user_req; +pub mod register_user_res; diff --git a/src/http/model/register_user_req.rs b/src/http/model/register_user_req.rs new file mode 100644 index 0000000..86e1957 --- /dev/null +++ b/src/http/model/register_user_req.rs @@ -0,0 +1,7 @@ +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct RegisterUserReq { + pub email: String, + pub password: String, +} diff --git a/src/http/model/register_user_res.rs b/src/http/model/register_user_res.rs new file mode 100644 index 0000000..84dc867 --- /dev/null +++ b/src/http/model/register_user_res.rs @@ -0,0 +1,7 @@ +use serde::Serialize; + +#[derive(Serialize)] +pub struct RegisterUserRes { + pub id: uuid::Uuid, + pub email: String, +} diff --git a/src/main.rs b/src/main.rs index 45d7d92..e81d72f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,11 +3,13 @@ mod config; mod db; mod error; mod http; +mod service; use crate::db::database; use crate::error::AppError; use crate::http::api_router; use axum::Router; +use sqlx::migrate; use tower_http::trace::{DefaultOnRequest, DefaultOnResponse, TraceLayer}; use tracing::{Level, info}; use tracing_subscriber::{EnvFilter, fmt}; @@ -27,6 +29,8 @@ async fn main() -> Result<(), AppError> { let pool = database::create_pool(&cfg.database_url()).await?; info!("database connection established"); + migrate!().run(&pool).await?; + let state = app_state::AppState { db: pool }; let app = Router::new() .nest("/api", api_router::router()) diff --git a/src/service/auth_service.rs b/src/service/auth_service.rs new file mode 100644 index 0000000..0039666 --- /dev/null +++ b/src/service/auth_service.rs @@ -0,0 +1,86 @@ +use argon2::{ + Argon2, + password_hash::{PasswordHasher, SaltString, rand_core::OsRng}, +}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use tokio::time::sleep; + +use crate::db::user_repo; +use crate::error::AppError; +use crate::http::model::{register_user_req::RegisterUserReq, register_user_res::RegisterUserRes}; + +pub async fn register( + pool: &sqlx::PgPool, + req: RegisterUserReq, +) -> Result { + let started = Instant::now(); + let result = register_inner(pool, req).await; + apply_obfuscation_delay(started).await; + result +} + +async fn register_inner( + pool: &sqlx::PgPool, + req: RegisterUserReq, +) -> Result { + let email = req.email.trim().to_lowercase(); + if email.is_empty() { + return Err(AppError::Validation("email is required".to_string())); + } + + if req.password.len() < 8 { + return Err(AppError::Validation( + "password must be at least 8 characters".to_string(), + )); + } + + let mut tx = pool.begin().await?; + if user_repo::email_exists(&mut tx, &email).await? { + return Err(AppError::Validation( + "invalid registration request".to_string(), + )); + } + + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + let password_hash = argon2 + .hash_password(req.password.as_bytes(), &salt) + .map_err(|e| AppError::Validation(format!("invalid password: {e}")))? + .to_string(); + + let user = match user_repo::create_user_in_tx(&mut tx, &email, &password_hash).await { + Ok(user) => user, + Err(AppError::Db(sqlx::Error::Database(db_err))) + if db_err.code().as_deref() == Some("23505") => + { + return Err(AppError::Validation( + "invalid registration request".to_string(), + )); + } + Err(err) => return Err(err), + }; + + tx.commit().await?; + + Ok(RegisterUserRes { + id: user.id, + email: user.email, + }) +} + +async fn apply_obfuscation_delay(started: Instant) { + let min_ms = 120; + let max_ms = 320; + + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.subsec_nanos()) + .unwrap_or(0); + let jitter = (nanos as u64) % (max_ms - min_ms + 1); + let target = Duration::from_millis(min_ms + jitter); + + let elapsed = started.elapsed(); + if target > elapsed { + sleep(target - elapsed).await; + } +} diff --git a/src/service/mod.rs b/src/service/mod.rs new file mode 100644 index 0000000..3fe88a6 --- /dev/null +++ b/src/service/mod.rs @@ -0,0 +1 @@ +pub mod auth_service;