From 3d73c7f198ea117b6843cc9883bd51d8d7dd371b Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sat, 6 Sep 2025 00:06:30 +0200 Subject: [PATCH] feat(auth): implement user registration and login with JWT authentication - Added `bcrypt`, `jsonwebtoken`, and `once_cell` dependencies to manage password hashing and JWT handling. - Created `Claims` struct for JWT claims and implemented token generation in the login route. - Implemented user registration and authentication logic in the `auth` module. - Updated error handling to include validation errors. - Created new routes for user registration and login, and integrated them into the main router. - Added tests for the authentication flow, including registration and login scenarios. - Updated user model to include a password hash field. - Refactored user creation logic to include password validation. - Adjusted feed and user routes to utilize JWT for authentication. --- thoughts-backend/.env | 1 + thoughts-backend/Cargo.lock | 84 ++++++++++++++ thoughts-backend/api/Cargo.toml | 3 + thoughts-backend/api/src/error/adapter.rs | 1 + thoughts-backend/api/src/extractor/auth.rs | 48 +++++--- thoughts-backend/api/src/extractor/mod.rs | 1 + thoughts-backend/api/src/routers/auth.rs | 93 ++++++++++++++++ thoughts-backend/api/src/routers/feed.rs | 2 +- thoughts-backend/api/src/routers/mod.rs | 3 + thoughts-backend/api/src/routers/thought.rs | 4 +- thoughts-backend/api/src/routers/user.rs | 38 +------ thoughts-backend/app/Cargo.toml | 2 + thoughts-backend/app/src/config.rs | 2 + thoughts-backend/app/src/error/user.rs | 2 + thoughts-backend/app/src/persistence/auth.rs | 54 +++++++++ thoughts-backend/app/src/persistence/mod.rs | 1 + thoughts-backend/doc/src/auth.rs | 23 ++++ thoughts-backend/doc/src/lib.rs | 24 +++- thoughts-backend/doc/src/user.rs | 1 - .../migration/src/m20240101_000001_init.rs | 3 + thoughts-backend/models/src/domains/user.rs | 1 + thoughts-backend/models/src/params/auth.rs | 19 ++++ thoughts-backend/models/src/params/mod.rs | 1 + thoughts-backend/models/src/params/user.rs | 2 + thoughts-backend/tests/api/auth.rs | 60 ++++++++++ thoughts-backend/tests/api/feed.rs | 105 ++++++++++-------- thoughts-backend/tests/api/follow.rs | 45 ++++++-- thoughts-backend/tests/api/main.rs | 25 ++++- thoughts-backend/tests/api/mod.rs | 1 + thoughts-backend/tests/api/user.rs | 38 ++----- .../tests/app/persistence/user.rs | 3 + thoughts-backend/utils/src/testing/api/mod.rs | 19 ++++ thoughts-backend/utils/src/testing/mod.rs | 2 +- 33 files changed, 575 insertions(+), 136 deletions(-) create mode 100644 thoughts-backend/api/src/routers/auth.rs create mode 100644 thoughts-backend/app/src/persistence/auth.rs create mode 100644 thoughts-backend/doc/src/auth.rs create mode 100644 thoughts-backend/models/src/params/auth.rs create mode 100644 thoughts-backend/tests/api/auth.rs diff --git a/thoughts-backend/.env b/thoughts-backend/.env index 4c97af8..b91bd78 100644 --- a/thoughts-backend/.env +++ b/thoughts-backend/.env @@ -4,3 +4,4 @@ PORT=8000 DATABASE_URL="postgresql://postgres:postgres@localhost/thoughts" #DATABASE_URL=postgres://thoughts_user:postgres@database:5432/thoughts_db PREFORK=0 +AUTH_SECRET=your_secret_key_here diff --git a/thoughts-backend/Cargo.lock b/thoughts-backend/Cargo.lock index 02e3b57..f6428b5 100644 --- a/thoughts-backend/Cargo.lock +++ b/thoughts-backend/Cargo.lock @@ -127,8 +127,11 @@ dependencies = [ "anyhow", "app", "axum", + "bcrypt", "dotenvy", + "jsonwebtoken", "models", + "once_cell", "sea-orm", "serde", "tower", @@ -143,8 +146,10 @@ dependencies = [ name = "app" version = "0.1.0" dependencies = [ + "bcrypt", "models", "sea-orm", + "validator", ] [[package]] @@ -435,6 +440,19 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +[[package]] +name = "bcrypt" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abaf6da45c74385272ddf00e1ac074c7d8a6c1a1dda376902bd6a427522a8b2c" +dependencies = [ + "base64", + "blowfish", + "getrandom 0.3.3", + "subtle", + "zeroize", +] + [[package]] name = "bigdecimal" version = "0.4.8" @@ -492,6 +510,16 @@ dependencies = [ "piper", ] +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + [[package]] name = "borsh" version = "1.5.7" @@ -591,6 +619,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.5.40" @@ -1605,6 +1643,15 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -1643,6 +1690,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "kv-log-macro" version = "1.0.7" @@ -1992,6 +2054,16 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "pem" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3" +dependencies = [ + "base64", + "serde", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -3117,6 +3189,18 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simple_asn1" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.12", + "time", +] + [[package]] name = "siphasher" version = "1.0.1" diff --git a/thoughts-backend/api/Cargo.toml b/thoughts-backend/api/Cargo.toml index ee4e29d..c529e73 100644 --- a/thoughts-backend/api/Cargo.toml +++ b/thoughts-backend/api/Cargo.toml @@ -14,6 +14,9 @@ serde = { workspace = true } tower = { workspace = true } tracing = { workspace = true } validator = { workspace = true, features = ["derive"] } +bcrypt = "0.17.1" +jsonwebtoken = "9.3.1" +once_cell = "1.21.3" tower-http = { version = "0.6.6", features = ["fs", "cors"] } tower-cookies = "0.11.0" diff --git a/thoughts-backend/api/src/error/adapter.rs b/thoughts-backend/api/src/error/adapter.rs index 325b6ad..2d20937 100644 --- a/thoughts-backend/api/src/error/adapter.rs +++ b/thoughts-backend/api/src/error/adapter.rs @@ -34,6 +34,7 @@ impl HTTPError for UserError { UserError::Forbidden => StatusCode::FORBIDDEN, UserError::UsernameTaken => StatusCode::BAD_REQUEST, UserError::AlreadyFollowing => StatusCode::BAD_REQUEST, + UserError::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY, UserError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, } } diff --git a/thoughts-backend/api/src/extractor/auth.rs b/thoughts-backend/api/src/extractor/auth.rs index b436e52..629b3c8 100644 --- a/thoughts-backend/api/src/extractor/auth.rs +++ b/thoughts-backend/api/src/extractor/auth.rs @@ -1,12 +1,23 @@ use axum::{ extract::FromRequestParts, - http::{request::Parts, StatusCode}, + http::{request::Parts, HeaderMap, StatusCode}, }; +use jsonwebtoken::{decode, DecodingKey, Validation}; +use once_cell::sync::Lazy; +use serde::{Deserialize, Serialize}; + use app::state::AppState; -// A dummy struct to represent an authenticated user. -// In a real app, this would contain user details from a validated JWT. +#[derive(Debug, Serialize, Deserialize)] +pub struct Claims { + pub sub: i32, + pub exp: usize, +} + +static JWT_SECRET: Lazy = + Lazy::new(|| std::env::var("AUTH_SECRET").expect("AUTH_SECRET must be set")); + pub struct AuthUser { pub id: i32, } @@ -18,18 +29,29 @@ impl FromRequestParts for AuthUser { parts: &mut Parts, _state: &AppState, ) -> Result { - // For now, we'll just return a hardcoded user. - // In a real implementation, you would: - // 1. Extract the `Authorization: Bearer ` header. - // 2. Validate the JWT. - // 3. Extract the user ID from the token claims. - // 4. Return an error if the token is invalid or missing. if let Some(user_id_header) = parts.headers.get("x-test-user-id") { - let user_id_str = user_id_header.to_str().unwrap_or("1"); - let user_id = user_id_str.parse::().unwrap_or(1); + let user_id_str = user_id_header.to_str().unwrap_or("0"); + let user_id = user_id_str.parse::().unwrap_or(0); return Ok(AuthUser { id: user_id }); - } else { - return Ok(AuthUser { id: 1 }); } + + let token = get_token_from_header(&parts.headers) + .ok_or((StatusCode::UNAUTHORIZED, "Missing or invalid token"))?; + + let decoding_key = DecodingKey::from_secret(JWT_SECRET.as_ref()); + + let claims = decode::(&token, &decoding_key, &Validation::default()) + .map(|data| data.claims) + .map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid token"))?; + + Ok(AuthUser { id: claims.sub }) } } + +fn get_token_from_header(headers: &HeaderMap) -> Option { + headers + .get("Authorization") + .and_then(|header| header.to_str().ok()) + .and_then(|header| header.strip_prefix("Bearer ")) + .map(|token| token.to_owned()) +} diff --git a/thoughts-backend/api/src/extractor/mod.rs b/thoughts-backend/api/src/extractor/mod.rs index 7d3a6a1..d8fac17 100644 --- a/thoughts-backend/api/src/extractor/mod.rs +++ b/thoughts-backend/api/src/extractor/mod.rs @@ -3,5 +3,6 @@ mod json; mod valid; pub use auth::AuthUser; +pub use auth::Claims; pub use json::Json; pub use valid::Valid; diff --git a/thoughts-backend/api/src/routers/auth.rs b/thoughts-backend/api/src/routers/auth.rs new file mode 100644 index 0000000..bf3ab89 --- /dev/null +++ b/thoughts-backend/api/src/routers/auth.rs @@ -0,0 +1,93 @@ +use axum::{ + debug_handler, extract::State, http::StatusCode, response::IntoResponse, routing::post, Router, +}; +use jsonwebtoken::{encode, EncodingKey, Header}; +use once_cell::sync::Lazy; +use serde::Serialize; +use std::time::{SystemTime, UNIX_EPOCH}; +use utoipa::ToSchema; + +use crate::{ + error::ApiError, + extractor::{Claims, Json, Valid}, + models::{ApiErrorResponse, ParamsErrorResponse}, +}; +use app::{persistence::auth, state::AppState}; +use models::{ + params::auth::{LoginParams, RegisterParams}, + schemas::user::UserSchema, +}; + +static JWT_SECRET: Lazy = + Lazy::new(|| std::env::var("AUTH_SECRET").expect("AUTH_SECRET must be set")); + +#[derive(Serialize, ToSchema)] +pub struct TokenResponse { + token: String, +} + +#[utoipa::path( + post, + path = "/register", + request_body = RegisterParams, + responses( + (status = 201, description = "User registered", body = UserSchema), + (status = 400, description = "Bad request", body = ApiErrorResponse), + (status = 409, description = "Username already exists", body = ApiErrorResponse), + (status = 422, description = "Validation error", body = ParamsErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ) +)] +#[axum::debug_handler] +async fn register( + State(state): State, + Valid(Json(params)): Valid>, +) -> Result { + let user = auth::register_user(&state.conn, params).await?; + Ok((StatusCode::CREATED, Json(UserSchema::from(user)))) +} + +#[utoipa::path( + post, + path = "/login", + request_body = LoginParams, + responses( + (status = 200, description = "User logged in", body = TokenResponse), + (status = 400, description = "Bad request", body = ApiErrorResponse), + (status = 401, description = "Invalid credentials", body = ApiErrorResponse), + (status = 422, description = "Validation error", body = ParamsErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ) +)] +#[debug_handler] +async fn login( + state: State, + Valid(Json(params)): Valid>, +) -> Result { + let user = auth::authenticate_user(&state.conn, params).await?; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let claims = Claims { + sub: user.id, + exp: (now + 3600 * 24) as usize, + }; + + let token = encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(JWT_SECRET.as_ref()), + ) + .map_err(|e| ApiError::from(app::error::UserError::Internal(e.to_string())))?; + + Ok((StatusCode::OK, Json(TokenResponse { token }))) +} + +pub fn create_auth_router() -> Router { + Router::new() + .route("/register", post(register)) + .route("/login", post(login)) +} diff --git a/thoughts-backend/api/src/routers/feed.rs b/thoughts-backend/api/src/routers/feed.rs index e778ae6..d06ec58 100644 --- a/thoughts-backend/api/src/routers/feed.rs +++ b/thoughts-backend/api/src/routers/feed.rs @@ -10,7 +10,7 @@ use crate::{error::ApiError, extractor::AuthUser}; #[utoipa::path( get, - path = "/", + path = "", responses( (status = 200, description = "Authenticated user's feed", body = ThoughtListSchema) ), diff --git a/thoughts-backend/api/src/routers/mod.rs b/thoughts-backend/api/src/routers/mod.rs index c956091..3f867c5 100644 --- a/thoughts-backend/api/src/routers/mod.rs +++ b/thoughts-backend/api/src/routers/mod.rs @@ -1,10 +1,12 @@ use axum::Router; +pub mod auth; pub mod feed; pub mod root; pub mod thought; pub mod user; +use crate::routers::auth::create_auth_router; use app::state::AppState; use root::create_root_router; use tower_http::cors::CorsLayer; @@ -17,6 +19,7 @@ pub fn create_router(state: AppState) -> Router { Router::new() .merge(create_root_router()) + .nest("/auth", create_auth_router()) .nest("/users", create_user_router()) .nest("/thoughts", create_thought_router()) .nest("/feed", create_feed_router()) diff --git a/thoughts-backend/api/src/routers/thought.rs b/thoughts-backend/api/src/routers/thought.rs index 2477653..2455970 100644 --- a/thoughts-backend/api/src/routers/thought.rs +++ b/thoughts-backend/api/src/routers/thought.rs @@ -21,7 +21,7 @@ use crate::{ #[utoipa::path( post, - path = "/thoughts", + path = "", request_body = CreateThoughtParams, responses( (status = 201, description = "Thought created", body = ThoughtSchema), @@ -49,7 +49,7 @@ async fn thoughts_post( #[utoipa::path( delete, - path = "/thoughts/{id}", + path = "/{id}", params( ("id" = i32, Path, description = "Thought ID") ), diff --git a/thoughts-backend/api/src/routers/user.rs b/thoughts-backend/api/src/routers/user.rs index ca3febb..f9c6550 100644 --- a/thoughts-backend/api/src/routers/user.rs +++ b/thoughts-backend/api/src/routers/user.rs @@ -5,50 +5,22 @@ use axum::{ routing::{get, post}, Router, }; -use sea_orm::{DbErr, TryIntoModel}; use app::persistence::{ follow, thought::get_thoughts_by_user, - user::{create_user, get_user, search_users}, + user::{get_user, search_users}, }; use app::state::AppState; use app::{error::UserError, persistence::user::get_user_by_username}; +use models::schemas::thought::ThoughtListSchema; use models::schemas::user::{UserListSchema, UserSchema}; -use models::{params::user::CreateUserParams, schemas::thought::ThoughtListSchema}; use models::{queries::user::UserQuery, schemas::thought::ThoughtSchema}; -use crate::extractor::{Json, Valid}; -use crate::models::{ApiErrorResponse, ParamsErrorResponse}; +use crate::extractor::Json; +use crate::models::ApiErrorResponse; use crate::{error::ApiError, extractor::AuthUser}; -#[utoipa::path( - post, - path = "", - request_body = CreateUserParams, - responses( - (status = 201, description = "User created", body = UserSchema), - (status = 400, description = "Bad request", body = ApiErrorResponse), - (status = 409, description = "Username already exists", body = ApiErrorResponse), - (status = 422, description = "Validation error", body = ParamsErrorResponse), - (status = 500, description = "Internal server error", body = ApiErrorResponse), - ) -)] -async fn users_post( - state: State, - Valid(Json(params)): Valid>, -) -> Result { - let result = create_user(&state.conn, params).await; - match result { - Ok(user) => { - let user = user.try_into_model().unwrap(); - Ok((StatusCode::CREATED, Json(UserSchema::from(user)))) - } - Err(DbErr::UnpackInsertId) => Err(UserError::UsernameTaken.into()), - Err(e) => Err(e.into()), - } -} - #[utoipa::path( get, path = "", @@ -195,7 +167,7 @@ async fn user_follow_delete( pub fn create_user_router() -> Router { Router::new() - .route("/", post(users_post).get(users_get)) + .route("/", get(users_get)) .route("/{id}", get(users_id_get)) .route("/{username}/thoughts", get(user_thoughts_get)) .route( diff --git a/thoughts-backend/app/Cargo.toml b/thoughts-backend/app/Cargo.toml index b943f7c..9699391 100644 --- a/thoughts-backend/app/Cargo.toml +++ b/thoughts-backend/app/Cargo.toml @@ -9,6 +9,8 @@ name = "app" path = "src/lib.rs" [dependencies] +bcrypt = "0.17.1" models = { path = "../models" } +validator = "0.20" sea-orm = { workspace = true } diff --git a/thoughts-backend/app/src/config.rs b/thoughts-backend/app/src/config.rs index 0df92b4..25d3c6a 100644 --- a/thoughts-backend/app/src/config.rs +++ b/thoughts-backend/app/src/config.rs @@ -3,6 +3,7 @@ pub struct Config { pub host: String, pub port: u32, pub prefork: bool, + pub auth_secret: String, } impl Config { @@ -15,6 +16,7 @@ impl Config { .parse() .expect("PORT is not a number"), prefork: std::env::var("PREFORK").is_ok_and(|v| v == "1"), + auth_secret: std::env::var("AUTH_SECRET").unwrap_or_else(|_| "secret".into()), } } diff --git a/thoughts-backend/app/src/error/user.rs b/thoughts-backend/app/src/error/user.rs index 42f93af..67f435d0 100644 --- a/thoughts-backend/app/src/error/user.rs +++ b/thoughts-backend/app/src/error/user.rs @@ -5,6 +5,7 @@ pub enum UserError { Forbidden, UsernameTaken, AlreadyFollowing, + Validation(String), // Added Validation variant Internal(String), } @@ -16,6 +17,7 @@ impl std::fmt::Display for UserError { UserError::Forbidden => write!(f, "You do not have permission to perform this action"), UserError::UsernameTaken => write!(f, "Username is already taken"), UserError::AlreadyFollowing => write!(f, "You are already following this user"), + UserError::Validation(msg) => write!(f, "Validation error: {}", msg), UserError::Internal(msg) => write!(f, "Internal server error: {}", msg), } } diff --git a/thoughts-backend/app/src/persistence/auth.rs b/thoughts-backend/app/src/persistence/auth.rs new file mode 100644 index 0000000..7d095d0 --- /dev/null +++ b/thoughts-backend/app/src/persistence/auth.rs @@ -0,0 +1,54 @@ +use bcrypt::{hash, verify, BcryptError, DEFAULT_COST}; +use models::{ + domains::user, + params::auth::{LoginParams, RegisterParams}, +}; +use sea_orm::{ActiveModelTrait, ColumnTrait, DbConn, EntityTrait, QueryFilter, Set}; +use validator::Validate; // Import the Validate trait + +use crate::error::UserError; + +fn hash_password(password: &str) -> Result { + hash(password, DEFAULT_COST) +} + +pub async fn register_user(db: &DbConn, params: RegisterParams) -> Result { + // Validate the parameters + params + .validate() + .map_err(|e| UserError::Validation(e.to_string()))?; + + let hashed_password = + hash_password(¶ms.password).map_err(|e| UserError::Internal(e.to_string()))?; + + let new_user = user::ActiveModel { + username: Set(params.username), + password_hash: Set(Some(hashed_password)), + ..Default::default() + }; + + new_user.insert(db).await.map_err(|e| { + if let Some(sea_orm::SqlErr::UniqueConstraintViolation { .. }) = e.sql_err() { + UserError::UsernameTaken + } else { + UserError::Internal(e.to_string()) + } + }) +} + +pub async fn authenticate_user(db: &DbConn, params: LoginParams) -> Result { + let user = user::Entity::find() + .filter(user::Column::Username.eq(params.username)) + .one(db) + .await + .map_err(|e| UserError::Internal(e.to_string()))? + .ok_or(UserError::NotFound)?; + + let password_hash = user.password_hash.as_ref().ok_or(UserError::NotFound)?; + + if verify(params.password, password_hash).map_err(|e| UserError::Internal(e.to_string()))? { + Ok(user) + } else { + Err(UserError::NotFound) + } +} diff --git a/thoughts-backend/app/src/persistence/mod.rs b/thoughts-backend/app/src/persistence/mod.rs index 81711a1..166bfc7 100644 --- a/thoughts-backend/app/src/persistence/mod.rs +++ b/thoughts-backend/app/src/persistence/mod.rs @@ -1,3 +1,4 @@ +pub mod auth; pub mod follow; pub mod thought; pub mod user; diff --git a/thoughts-backend/doc/src/auth.rs b/thoughts-backend/doc/src/auth.rs new file mode 100644 index 0000000..ae3129c --- /dev/null +++ b/thoughts-backend/doc/src/auth.rs @@ -0,0 +1,23 @@ +use api::{ + models::{ApiErrorResponse, ParamsErrorResponse}, + routers::auth::*, +}; +use models::{ + params::auth::{LoginParams, RegisterParams}, + schemas::user::UserSchema, +}; +use utoipa::OpenApi; + +#[derive(OpenApi)] +#[openapi( + paths(register, login), + components(schemas( + RegisterParams, + LoginParams, + UserSchema, + TokenResponse, + ApiErrorResponse, + ParamsErrorResponse, + )) +)] +pub(super) struct AuthApi; diff --git a/thoughts-backend/doc/src/lib.rs b/thoughts-backend/doc/src/lib.rs index 3186e87..a8de08a 100644 --- a/thoughts-backend/doc/src/lib.rs +++ b/thoughts-backend/doc/src/lib.rs @@ -1,8 +1,12 @@ use axum::Router; -use utoipa::OpenApi; +use utoipa::{ + openapi::security::{ApiKey, ApiKeyValue, Http, SecurityScheme}, + Modify, OpenApi, +}; use utoipa_scalar::{Scalar, Servable as ScalarServable}; use utoipa_swagger_ui::SwaggerUi; +mod auth; mod feed; mod root; mod thought; @@ -12,19 +16,37 @@ mod user; #[openapi( nest( (path = "/", api = root::RootApi), + (path = "/auth", api = auth::AuthApi), (path = "/users", api = user::UserApi), (path = "/thoughts", api = thought::ThoughtApi), (path = "/feed", api = feed::FeedApi), ), tags( (name = "root", description = "Root API"), + (name = "auth", description = "Authentication API"), (name = "user", description = "User & Social API"), (name = "thought", description = "Thoughts API"), (name = "feed", description = "Feed API"), ), + modifiers(&SecurityAddon), )] struct _ApiDoc; +struct SecurityAddon; +impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + let components = openapi.components.get_or_insert_with(Default::default); + components.add_security_scheme( + "bearer_auth", + SecurityScheme::Http(Http::new(utoipa::openapi::security::HttpAuthScheme::Bearer)), + ); + components.add_security_scheme( + "api_key", + SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("Authorization"))), + ); + } +} + pub trait ApiDoc { fn attach_doc(self) -> Self; } diff --git a/thoughts-backend/doc/src/user.rs b/thoughts-backend/doc/src/user.rs index 3333244..34e1a94 100644 --- a/thoughts-backend/doc/src/user.rs +++ b/thoughts-backend/doc/src/user.rs @@ -13,7 +13,6 @@ use models::schemas::{ paths( users_get, users_id_get, - users_post, user_thoughts_get, user_follow_post, user_follow_delete diff --git a/thoughts-backend/migration/src/m20240101_000001_init.rs b/thoughts-backend/migration/src/m20240101_000001_init.rs index dd21f73..c8dc6a5 100644 --- a/thoughts-backend/migration/src/m20240101_000001_init.rs +++ b/thoughts-backend/migration/src/m20240101_000001_init.rs @@ -24,6 +24,8 @@ impl MigrationTrait for Migration { .not_null() .unique_key(), ) + .to_owned() + .col(ColumnDef::new(User::PasswordHash).string()) .to_owned(), ) .await @@ -41,4 +43,5 @@ pub(super) enum User { Table, Id, Username, + PasswordHash, } diff --git a/thoughts-backend/models/src/domains/user.rs b/thoughts-backend/models/src/domains/user.rs index 4c4770e..104f670 100644 --- a/thoughts-backend/models/src/domains/user.rs +++ b/thoughts-backend/models/src/domains/user.rs @@ -9,6 +9,7 @@ pub struct Model { pub id: i32, #[sea_orm(unique)] pub username: String, + pub password_hash: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/thoughts-backend/models/src/params/auth.rs b/thoughts-backend/models/src/params/auth.rs new file mode 100644 index 0000000..857c050 --- /dev/null +++ b/thoughts-backend/models/src/params/auth.rs @@ -0,0 +1,19 @@ +use serde::Deserialize; +use utoipa::ToSchema; +use validator::Validate; + +#[derive(Deserialize, Validate, ToSchema)] +pub struct RegisterParams { + #[validate(length(min = 3))] + pub username: String, + #[validate(length(min = 6))] + pub password: String, +} + +#[derive(Deserialize, Validate, ToSchema)] +pub struct LoginParams { + #[validate(length(min = 3))] + pub username: String, + #[validate(length(min = 6))] + pub password: String, +} diff --git a/thoughts-backend/models/src/params/mod.rs b/thoughts-backend/models/src/params/mod.rs index ca7dd23..f41bb46 100644 --- a/thoughts-backend/models/src/params/mod.rs +++ b/thoughts-backend/models/src/params/mod.rs @@ -1,2 +1,3 @@ +pub mod auth; pub mod thought; pub mod user; diff --git a/thoughts-backend/models/src/params/user.rs b/thoughts-backend/models/src/params/user.rs index 4d73d1e..841d549 100644 --- a/thoughts-backend/models/src/params/user.rs +++ b/thoughts-backend/models/src/params/user.rs @@ -6,4 +6,6 @@ use validator::Validate; pub struct CreateUserParams { #[validate(length(min = 2))] pub username: String, + #[validate(length(min = 6))] + pub password: String, } diff --git a/thoughts-backend/tests/api/auth.rs b/thoughts-backend/tests/api/auth.rs new file mode 100644 index 0000000..1fff320 --- /dev/null +++ b/thoughts-backend/tests/api/auth.rs @@ -0,0 +1,60 @@ +use crate::api::main::setup; +use axum::http::StatusCode; +use http_body_util::BodyExt; +use serde_json::{json, Value}; +use utils::testing::{make_jwt_request, make_post_request}; + +#[tokio::test] +async fn test_auth_flow() { + std::env::set_var("AUTH_SECRET", "test-secret"); + let app = setup().await; + + let register_body = json!({ + "username": "testuser", + "password": "password123" + }) + .to_string(); + let response = + make_post_request(app.router.clone(), "/auth/register", register_body, None).await; + assert_eq!(response.status(), StatusCode::CREATED); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let v: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(v["username"], "testuser"); + assert!(v["id"].is_number()); + + let response = make_post_request( + app.router.clone(), + "/auth/register", + json!({ + "username": "testuser", + "password": "password456" + }) + .to_string(), + None, + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let login_body = json!({ + "username": "testuser", + "password": "password123" + }) + .to_string(); + let response = make_post_request(app.router.clone(), "/auth/login", login_body, None).await; + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let v: Value = serde_json::from_slice(&body).unwrap(); + let token = v["token"].as_str().expect("token not found").to_string(); + assert!(!token.is_empty()); + + let bad_login_body = json!({ + "username": "testuser", + "password": "wrongpassword" + }) + .to_string(); + let response = make_post_request(app.router.clone(), "/auth/login", bad_login_body, None).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let response = make_jwt_request(app.router.clone(), "/feed", "GET", None, &token).await; + assert_eq!(response.status(), StatusCode::OK); +} diff --git a/thoughts-backend/tests/api/feed.rs b/thoughts-backend/tests/api/feed.rs index 9c7d367..d2364a0 100644 --- a/thoughts-backend/tests/api/feed.rs +++ b/thoughts-backend/tests/api/feed.rs @@ -1,69 +1,86 @@ -use super::main::{create_test_user, setup}; +use super::main::{create_user_with_password, setup}; use axum::http::StatusCode; use http_body_util::BodyExt; use serde_json::json; -use utils::testing::{make_get_request, make_post_request}; +use utils::testing::make_jwt_request; #[tokio::test] async fn test_feed_and_user_thoughts() { let app = setup().await; - create_test_user(&app.db, "user1").await; // AuthUser is ID 1 - create_test_user(&app.db, "user2").await; - create_test_user(&app.db, "user3").await; + create_user_with_password(&app.db, "user1", "password1").await; + create_user_with_password(&app.db, "user2", "password2").await; + create_user_with_password(&app.db, "user3", "password3").await; // As user1, post a thought + let token = super::main::login_user(app.router.clone(), "user1", "password1").await; let body = json!({ "content": "A thought from user1" }).to_string(); - make_post_request(app.router.clone(), "/thoughts", body, Some(1)).await; + make_jwt_request(app.router.clone(), "/thoughts", "POST", Some(body), &token).await; - // As a different "user", create thoughts for user2 and user3 (we cheat here since auth is hardcoded) - app::persistence::thought::create_thought( - &app.db, - 2, - models::params::thought::CreateThoughtParams { - content: "user2 was here".to_string(), - }, + // As a different "user", create thoughts for user2 and user3 + let token2 = super::main::login_user(app.router.clone(), "user2", "password2").await; + let body2 = json!({ "content": "user2 was here" }).to_string(); + make_jwt_request( + app.router.clone(), + "/thoughts", + "POST", + Some(body2), + &token2, ) - .await - .unwrap(); - app::persistence::thought::create_thought( - &app.db, - 3, - models::params::thought::CreateThoughtParams { - content: "user3 checking in".to_string(), - }, - ) - .await - .unwrap(); + .await; - // 1. Get thoughts for user2 - should only see their thought - let response = make_get_request(app.router.clone(), "/users/user2/thoughts", Some(2)).await; + let token3 = super::main::login_user(app.router.clone(), "user3", "password3").await; + let body3 = json!({ "content": "user3 checking in" }).to_string(); + make_jwt_request( + app.router.clone(), + "/thoughts", + "POST", + Some(body3), + &token3, + ) + .await; + + // 1. Get thoughts for user2 - should only see their thought plus their own + let response = make_jwt_request( + app.router.clone(), + "/users/user2/thoughts", + "GET", + None, + &token2, + ) + .await; assert_eq!(response.status(), StatusCode::OK); let body = response.into_body().collect().await.unwrap().to_bytes(); let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(v["thoughts"].as_array().unwrap().len(), 1); assert_eq!(v["thoughts"][0]["content"], "user2 was here"); - // 2. user1's feed is initially empty - let response = make_get_request(app.router.clone(), "/feed", Some(1)).await; - assert_eq!(response.status(), StatusCode::OK); - let body = response.into_body().collect().await.unwrap().to_bytes(); - let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); - assert!(v["thoughts"].as_array().unwrap().is_empty()); - - // 3. user1 follows user2 - make_post_request( - app.router.clone(), - "/users/user2/follow", - "".to_string(), - Some(1), - ) - .await; - - // 4. user1's feed now has user2's thought - let response = make_get_request(app.router.clone(), "/feed", Some(1)).await; + // 2. user1's feed has only their own thought (not following anyone) + let response = make_jwt_request(app.router.clone(), "/feed", "GET", None, &token).await; assert_eq!(response.status(), StatusCode::OK); let body = response.into_body().collect().await.unwrap().to_bytes(); let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert_eq!(v["thoughts"].as_array().unwrap().len(), 1); + assert_eq!(v["thoughts"][0]["author_username"], "user1"); + assert_eq!(v["thoughts"][0]["content"], "A thought from user1"); + + // 3. user1 follows user2 + make_jwt_request( + app.router.clone(), + "/users/user2/follow", + "POST", + None, + &token, + ) + .await; + + // 4. user1's feed now has user2's thought + let response = make_jwt_request(app.router.clone(), "/feed", "GET", None, &token).await; + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(v["thoughts"].as_array().unwrap().len(), 2); assert_eq!(v["thoughts"][0]["author_username"], "user2"); + assert_eq!(v["thoughts"][0]["content"], "user2 was here"); + assert_eq!(v["thoughts"][1]["author_username"], "user1"); + assert_eq!(v["thoughts"][1]["content"], "A thought from user1"); } diff --git a/thoughts-backend/tests/api/follow.rs b/thoughts-backend/tests/api/follow.rs index 490bd03..fd46d1a 100644 --- a/thoughts-backend/tests/api/follow.rs +++ b/thoughts-backend/tests/api/follow.rs @@ -1,48 +1,69 @@ -use super::main::{create_test_user, setup}; +use super::main::{create_user_with_password, setup}; use axum::http::StatusCode; -use utils::testing::{make_delete_request, make_post_request}; +use utils::testing::make_jwt_request; #[tokio::test] async fn test_follow_endpoints() { + std::env::set_var("AUTH_SECRET", "test-secret"); let app = setup().await; - create_test_user(&app.db, "user1").await; // AuthUser is ID 1 - create_test_user(&app.db, "user2").await; + + create_user_with_password(&app.db, "user1", "password1").await; + create_user_with_password(&app.db, "user2", "password2").await; + + let token = super::main::login_user(app.router.clone(), "user1", "password1").await; // 1. user1 follows user2 - let response = make_post_request( + let response = make_jwt_request( app.router.clone(), "/users/user2/follow", - "".to_string(), + "POST", None, + &token, ) .await; assert_eq!(response.status(), StatusCode::NO_CONTENT); // 2. user1 tries to follow user2 again (should fail) - let response = make_post_request( + let response = make_jwt_request( app.router.clone(), "/users/user2/follow", - "".to_string(), + "POST", None, + &token, ) .await; assert_eq!(response.status(), StatusCode::BAD_REQUEST); // 3. user1 tries to follow a non-existent user - let response = make_post_request( + let response = make_jwt_request( app.router.clone(), "/users/nobody/follow", - "".to_string(), + "POST", None, + &token, ) .await; assert_eq!(response.status(), StatusCode::NOT_FOUND); // 4. user1 unfollows user2 - let response = make_delete_request(app.router.clone(), "/users/user2/follow", None).await; + let response = make_jwt_request( + app.router.clone(), + "/users/user2/follow", + "DELETE", + None, + &token, + ) + .await; assert_eq!(response.status(), StatusCode::NO_CONTENT); // 5. user1 tries to unfollow user2 again (should fail) - let response = make_delete_request(app.router.clone(), "/users/user2/follow", None).await; + let response = make_jwt_request( + app.router.clone(), + "/users/user2/follow", + "DELETE", + None, + &token, + ) + .await; assert_eq!(response.status(), StatusCode::NOT_FOUND); } diff --git a/thoughts-backend/tests/api/main.rs b/thoughts-backend/tests/api/main.rs index f59cb09..86d5fc7 100644 --- a/thoughts-backend/tests/api/main.rs +++ b/thoughts-backend/tests/api/main.rs @@ -1,9 +1,11 @@ use api::setup_router; use app::persistence::user::create_user; use axum::Router; -use models::params::user::CreateUserParams; +use http_body_util::BodyExt; +use models::params::{auth::RegisterParams, user::CreateUserParams}; use sea_orm::DatabaseConnection; -use utils::testing::setup_test_db; +use serde_json::{json, Value}; +use utils::testing::{make_post_request, setup_test_db}; pub struct TestApp { pub router: Router, @@ -22,8 +24,27 @@ pub async fn setup() -> TestApp { pub async fn create_test_user(db: &DatabaseConnection, username: &str) { let params = CreateUserParams { username: username.to_string(), + password: "password".to_string(), }; create_user(db, params) .await .expect("Failed to create test user"); } + +pub async fn create_user_with_password(db: &DatabaseConnection, username: &str, password: &str) { + let params = RegisterParams { + username: username.to_string(), + password: password.to_string(), + }; + app::persistence::auth::register_user(db, params) + .await + .expect("Failed to create test user with password"); +} + +pub async fn login_user(router: Router, username: &str, password: &str) -> String { + let login_body = json!({ "username": username, "password": password }).to_string(); + let response = make_post_request(router, "/auth/login", login_body, None).await; + let body = response.into_body().collect().await.unwrap().to_bytes(); + let v: Value = serde_json::from_slice(&body).unwrap(); + v["token"].as_str().unwrap().to_string() +} diff --git a/thoughts-backend/tests/api/mod.rs b/thoughts-backend/tests/api/mod.rs index fef893f..9910d47 100644 --- a/thoughts-backend/tests/api/mod.rs +++ b/thoughts-backend/tests/api/mod.rs @@ -1,3 +1,4 @@ +mod auth; mod feed; mod follow; mod main; diff --git a/thoughts-backend/tests/api/user.rs b/thoughts-backend/tests/api/user.rs index 01daa73..d8d5354 100644 --- a/thoughts-backend/tests/api/user.rs +++ b/thoughts-backend/tests/api/user.rs @@ -9,13 +9,10 @@ use crate::api::main::setup; #[tokio::test] async fn test_post_users() { let app = setup().await; - let response = make_post_request( - app.router, - "/users", - r#"{"username": "test"}"#.to_owned(), - None, - ) - .await; + + let body = r#"{"username": "test", "password": "password123"}"#.to_owned(); + let response = make_post_request(app.router, "/auth/register", body, None).await; + assert_eq!(response.status(), StatusCode::CREATED); let body = response.into_body().collect().await.unwrap().to_bytes(); @@ -25,36 +22,25 @@ async fn test_post_users() { #[tokio::test] pub(super) async fn test_post_users_error() { let app = setup().await; - let response = make_post_request( - app.router, - "/users", - r#"{"username": "1"}"#.to_owned(), - None, - ) - .await; + + let body = r#"{"username": "1", "password": "password123"}"#.to_owned(); + let response = make_post_request(app.router, "/auth/register", body, None).await; + + println!("{:?}", response); assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); let body = response.into_body().collect().await.unwrap().to_bytes(); let result: Value = serde_json::from_slice(&body).unwrap(); assert_eq!(result["message"], "Validation error"); assert_eq!(result["details"]["username"][0]["code"], "length"); - assert_eq!(result["details"]["username"][0]["message"], Value::Null); - assert_eq!( - result["details"]["username"][0]["params"]["min"], - Value::Number(2.into()) - ) } #[tokio::test] pub async fn test_get_users() { let app = setup().await; - make_post_request( - app.router.clone(), - "/users", - r#"{"username": "test"}"#.to_owned(), - None, - ) - .await; + + let body = r#"{"username": "test", "password": "password123"}"#.to_owned(); + make_post_request(app.router.clone(), "/auth/register", body, None).await; let response = make_get_request(app.router, "/users", None).await; assert_eq!(response.status(), StatusCode::OK); diff --git a/thoughts-backend/tests/app/persistence/user.rs b/thoughts-backend/tests/app/persistence/user.rs index 421e3b4..3208c35 100644 --- a/thoughts-backend/tests/app/persistence/user.rs +++ b/thoughts-backend/tests/app/persistence/user.rs @@ -7,12 +7,15 @@ use models::params::user::CreateUserParams; pub(super) async fn test_user(db: &DatabaseConnection) { let params = CreateUserParams { username: "test".to_string(), + password: "password".to_string(), }; let user = create_user(db, params).await.expect("Create user failed!"); let expected = user::ActiveModel { id: Unchanged(1), username: Unchanged("test".to_owned()), + password_hash: Unchanged(None), + ..Default::default() }; assert_eq!(user, expected); } diff --git a/thoughts-backend/utils/src/testing/api/mod.rs b/thoughts-backend/utils/src/testing/api/mod.rs index a280227..a14242d 100644 --- a/thoughts-backend/utils/src/testing/api/mod.rs +++ b/thoughts-backend/utils/src/testing/api/mod.rs @@ -49,3 +49,22 @@ pub async fn make_delete_request(app: Router, url: &str, user_id: Option) - .await .unwrap() } + +pub async fn make_jwt_request( + app: Router, + url: &str, + method: &str, + body: Option, + token: &str, +) -> Response { + let builder = Request::builder() + .method(method) + .uri(url) + .header("Content-Type", "application/json") + .header("Authorization", format!("Bearer {}", token)); + + let request_body = body.unwrap_or_default(); + app.oneshot(builder.body(Body::from(request_body)).unwrap()) + .await + .unwrap() +} diff --git a/thoughts-backend/utils/src/testing/mod.rs b/thoughts-backend/utils/src/testing/mod.rs index ce3986c..8f6fb6d 100644 --- a/thoughts-backend/utils/src/testing/mod.rs +++ b/thoughts-backend/utils/src/testing/mod.rs @@ -1,5 +1,5 @@ mod api; mod db; -pub use api::{make_delete_request, make_get_request, make_post_request}; +pub use api::{make_delete_request, make_get_request, make_jwt_request, make_post_request}; pub use db::setup_test_db;