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.
This commit is contained in:
2025-09-06 00:06:30 +02:00
parent d70015c887
commit 3d73c7f198
33 changed files with 575 additions and 136 deletions

View File

@@ -4,3 +4,4 @@ PORT=8000
DATABASE_URL="postgresql://postgres:postgres@localhost/thoughts" DATABASE_URL="postgresql://postgres:postgres@localhost/thoughts"
#DATABASE_URL=postgres://thoughts_user:postgres@database:5432/thoughts_db #DATABASE_URL=postgres://thoughts_user:postgres@database:5432/thoughts_db
PREFORK=0 PREFORK=0
AUTH_SECRET=your_secret_key_here

View File

@@ -127,8 +127,11 @@ dependencies = [
"anyhow", "anyhow",
"app", "app",
"axum", "axum",
"bcrypt",
"dotenvy", "dotenvy",
"jsonwebtoken",
"models", "models",
"once_cell",
"sea-orm", "sea-orm",
"serde", "serde",
"tower", "tower",
@@ -143,8 +146,10 @@ dependencies = [
name = "app" name = "app"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"bcrypt",
"models", "models",
"sea-orm", "sea-orm",
"validator",
] ]
[[package]] [[package]]
@@ -435,6 +440,19 @@ version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" 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]] [[package]]
name = "bigdecimal" name = "bigdecimal"
version = "0.4.8" version = "0.4.8"
@@ -492,6 +510,16 @@ dependencies = [
"piper", "piper",
] ]
[[package]]
name = "blowfish"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7"
dependencies = [
"byteorder",
"cipher",
]
[[package]] [[package]]
name = "borsh" name = "borsh"
version = "1.5.7" version = "1.5.7"
@@ -591,6 +619,16 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "clap" name = "clap"
version = "4.5.40" version = "4.5.40"
@@ -1605,6 +1643,15 @@ dependencies = [
"syn 2.0.104", "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]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.11.0" version = "2.11.0"
@@ -1643,6 +1690,21 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "kv-log-macro" name = "kv-log-macro"
version = "1.0.7" version = "1.0.7"
@@ -1992,6 +2054,16 @@ dependencies = [
"windows-targets 0.52.6", "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]] [[package]]
name = "pem-rfc7468" name = "pem-rfc7468"
version = "0.7.0" version = "0.7.0"
@@ -3117,6 +3189,18 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" 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]] [[package]]
name = "siphasher" name = "siphasher"
version = "1.0.1" version = "1.0.1"

View File

@@ -14,6 +14,9 @@ serde = { workspace = true }
tower = { workspace = true } tower = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
validator = { workspace = true, features = ["derive"] } 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-http = { version = "0.6.6", features = ["fs", "cors"] }
tower-cookies = "0.11.0" tower-cookies = "0.11.0"

View File

@@ -34,6 +34,7 @@ impl HTTPError for UserError {
UserError::Forbidden => StatusCode::FORBIDDEN, UserError::Forbidden => StatusCode::FORBIDDEN,
UserError::UsernameTaken => StatusCode::BAD_REQUEST, UserError::UsernameTaken => StatusCode::BAD_REQUEST,
UserError::AlreadyFollowing => StatusCode::BAD_REQUEST, UserError::AlreadyFollowing => StatusCode::BAD_REQUEST,
UserError::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY,
UserError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, UserError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
} }
} }

View File

@@ -1,12 +1,23 @@
use axum::{ use axum::{
extract::FromRequestParts, 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; use app::state::AppState;
// A dummy struct to represent an authenticated user. #[derive(Debug, Serialize, Deserialize)]
// In a real app, this would contain user details from a validated JWT. pub struct Claims {
pub sub: i32,
pub exp: usize,
}
static JWT_SECRET: Lazy<String> =
Lazy::new(|| std::env::var("AUTH_SECRET").expect("AUTH_SECRET must be set"));
pub struct AuthUser { pub struct AuthUser {
pub id: i32, pub id: i32,
} }
@@ -18,18 +29,29 @@ impl FromRequestParts<AppState> for AuthUser {
parts: &mut Parts, parts: &mut Parts,
_state: &AppState, _state: &AppState,
) -> Result<Self, Self::Rejection> { ) -> Result<Self, Self::Rejection> {
// For now, we'll just return a hardcoded user.
// In a real implementation, you would:
// 1. Extract the `Authorization: Bearer <token>` 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") { 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_str = user_id_header.to_str().unwrap_or("0");
let user_id = user_id_str.parse::<i32>().unwrap_or(1); let user_id = user_id_str.parse::<i32>().unwrap_or(0);
return Ok(AuthUser { id: user_id }); 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::<Claims>(&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<String> {
headers
.get("Authorization")
.and_then(|header| header.to_str().ok())
.and_then(|header| header.strip_prefix("Bearer "))
.map(|token| token.to_owned())
}

View File

@@ -3,5 +3,6 @@ mod json;
mod valid; mod valid;
pub use auth::AuthUser; pub use auth::AuthUser;
pub use auth::Claims;
pub use json::Json; pub use json::Json;
pub use valid::Valid; pub use valid::Valid;

View File

@@ -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<String> =
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<AppState>,
Valid(Json(params)): Valid<Json<RegisterParams>>,
) -> Result<impl IntoResponse, ApiError> {
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<AppState>,
Valid(Json(params)): Valid<Json<LoginParams>>,
) -> Result<impl IntoResponse, ApiError> {
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<AppState> {
Router::new()
.route("/register", post(register))
.route("/login", post(login))
}

View File

@@ -10,7 +10,7 @@ use crate::{error::ApiError, extractor::AuthUser};
#[utoipa::path( #[utoipa::path(
get, get,
path = "/", path = "",
responses( responses(
(status = 200, description = "Authenticated user's feed", body = ThoughtListSchema) (status = 200, description = "Authenticated user's feed", body = ThoughtListSchema)
), ),

View File

@@ -1,10 +1,12 @@
use axum::Router; use axum::Router;
pub mod auth;
pub mod feed; pub mod feed;
pub mod root; pub mod root;
pub mod thought; pub mod thought;
pub mod user; pub mod user;
use crate::routers::auth::create_auth_router;
use app::state::AppState; use app::state::AppState;
use root::create_root_router; use root::create_root_router;
use tower_http::cors::CorsLayer; use tower_http::cors::CorsLayer;
@@ -17,6 +19,7 @@ pub fn create_router(state: AppState) -> Router {
Router::new() Router::new()
.merge(create_root_router()) .merge(create_root_router())
.nest("/auth", create_auth_router())
.nest("/users", create_user_router()) .nest("/users", create_user_router())
.nest("/thoughts", create_thought_router()) .nest("/thoughts", create_thought_router())
.nest("/feed", create_feed_router()) .nest("/feed", create_feed_router())

View File

@@ -21,7 +21,7 @@ use crate::{
#[utoipa::path( #[utoipa::path(
post, post,
path = "/thoughts", path = "",
request_body = CreateThoughtParams, request_body = CreateThoughtParams,
responses( responses(
(status = 201, description = "Thought created", body = ThoughtSchema), (status = 201, description = "Thought created", body = ThoughtSchema),
@@ -49,7 +49,7 @@ async fn thoughts_post(
#[utoipa::path( #[utoipa::path(
delete, delete,
path = "/thoughts/{id}", path = "/{id}",
params( params(
("id" = i32, Path, description = "Thought ID") ("id" = i32, Path, description = "Thought ID")
), ),

View File

@@ -5,50 +5,22 @@ use axum::{
routing::{get, post}, routing::{get, post},
Router, Router,
}; };
use sea_orm::{DbErr, TryIntoModel};
use app::persistence::{ use app::persistence::{
follow, follow,
thought::get_thoughts_by_user, thought::get_thoughts_by_user,
user::{create_user, get_user, search_users}, user::{get_user, search_users},
}; };
use app::state::AppState; use app::state::AppState;
use app::{error::UserError, persistence::user::get_user_by_username}; use app::{error::UserError, persistence::user::get_user_by_username};
use models::schemas::thought::ThoughtListSchema;
use models::schemas::user::{UserListSchema, UserSchema}; use models::schemas::user::{UserListSchema, UserSchema};
use models::{params::user::CreateUserParams, schemas::thought::ThoughtListSchema};
use models::{queries::user::UserQuery, schemas::thought::ThoughtSchema}; use models::{queries::user::UserQuery, schemas::thought::ThoughtSchema};
use crate::extractor::{Json, Valid}; use crate::extractor::Json;
use crate::models::{ApiErrorResponse, ParamsErrorResponse}; use crate::models::ApiErrorResponse;
use crate::{error::ApiError, extractor::AuthUser}; 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<AppState>,
Valid(Json(params)): Valid<Json<CreateUserParams>>,
) -> Result<impl IntoResponse, ApiError> {
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( #[utoipa::path(
get, get,
path = "", path = "",
@@ -195,7 +167,7 @@ async fn user_follow_delete(
pub fn create_user_router() -> Router<AppState> { pub fn create_user_router() -> Router<AppState> {
Router::new() Router::new()
.route("/", post(users_post).get(users_get)) .route("/", get(users_get))
.route("/{id}", get(users_id_get)) .route("/{id}", get(users_id_get))
.route("/{username}/thoughts", get(user_thoughts_get)) .route("/{username}/thoughts", get(user_thoughts_get))
.route( .route(

View File

@@ -9,6 +9,8 @@ name = "app"
path = "src/lib.rs" path = "src/lib.rs"
[dependencies] [dependencies]
bcrypt = "0.17.1"
models = { path = "../models" } models = { path = "../models" }
validator = "0.20"
sea-orm = { workspace = true } sea-orm = { workspace = true }

View File

@@ -3,6 +3,7 @@ pub struct Config {
pub host: String, pub host: String,
pub port: u32, pub port: u32,
pub prefork: bool, pub prefork: bool,
pub auth_secret: String,
} }
impl Config { impl Config {
@@ -15,6 +16,7 @@ impl Config {
.parse() .parse()
.expect("PORT is not a number"), .expect("PORT is not a number"),
prefork: std::env::var("PREFORK").is_ok_and(|v| v == "1"), prefork: std::env::var("PREFORK").is_ok_and(|v| v == "1"),
auth_secret: std::env::var("AUTH_SECRET").unwrap_or_else(|_| "secret".into()),
} }
} }

View File

@@ -5,6 +5,7 @@ pub enum UserError {
Forbidden, Forbidden,
UsernameTaken, UsernameTaken,
AlreadyFollowing, AlreadyFollowing,
Validation(String), // Added Validation variant
Internal(String), 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::Forbidden => write!(f, "You do not have permission to perform this action"),
UserError::UsernameTaken => write!(f, "Username is already taken"), UserError::UsernameTaken => write!(f, "Username is already taken"),
UserError::AlreadyFollowing => write!(f, "You are already following this user"), 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), UserError::Internal(msg) => write!(f, "Internal server error: {}", msg),
} }
} }

View File

@@ -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<String, BcryptError> {
hash(password, DEFAULT_COST)
}
pub async fn register_user(db: &DbConn, params: RegisterParams) -> Result<user::Model, UserError> {
// Validate the parameters
params
.validate()
.map_err(|e| UserError::Validation(e.to_string()))?;
let hashed_password =
hash_password(&params.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<user::Model, UserError> {
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)
}
}

View File

@@ -1,3 +1,4 @@
pub mod auth;
pub mod follow; pub mod follow;
pub mod thought; pub mod thought;
pub mod user; pub mod user;

View File

@@ -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;

View File

@@ -1,8 +1,12 @@
use axum::Router; 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_scalar::{Scalar, Servable as ScalarServable};
use utoipa_swagger_ui::SwaggerUi; use utoipa_swagger_ui::SwaggerUi;
mod auth;
mod feed; mod feed;
mod root; mod root;
mod thought; mod thought;
@@ -12,19 +16,37 @@ mod user;
#[openapi( #[openapi(
nest( nest(
(path = "/", api = root::RootApi), (path = "/", api = root::RootApi),
(path = "/auth", api = auth::AuthApi),
(path = "/users", api = user::UserApi), (path = "/users", api = user::UserApi),
(path = "/thoughts", api = thought::ThoughtApi), (path = "/thoughts", api = thought::ThoughtApi),
(path = "/feed", api = feed::FeedApi), (path = "/feed", api = feed::FeedApi),
), ),
tags( tags(
(name = "root", description = "Root API"), (name = "root", description = "Root API"),
(name = "auth", description = "Authentication API"),
(name = "user", description = "User & Social API"), (name = "user", description = "User & Social API"),
(name = "thought", description = "Thoughts API"), (name = "thought", description = "Thoughts API"),
(name = "feed", description = "Feed API"), (name = "feed", description = "Feed API"),
), ),
modifiers(&SecurityAddon),
)] )]
struct _ApiDoc; 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 { pub trait ApiDoc {
fn attach_doc(self) -> Self; fn attach_doc(self) -> Self;
} }

View File

@@ -13,7 +13,6 @@ use models::schemas::{
paths( paths(
users_get, users_get,
users_id_get, users_id_get,
users_post,
user_thoughts_get, user_thoughts_get,
user_follow_post, user_follow_post,
user_follow_delete user_follow_delete

View File

@@ -24,6 +24,8 @@ impl MigrationTrait for Migration {
.not_null() .not_null()
.unique_key(), .unique_key(),
) )
.to_owned()
.col(ColumnDef::new(User::PasswordHash).string())
.to_owned(), .to_owned(),
) )
.await .await
@@ -41,4 +43,5 @@ pub(super) enum User {
Table, Table,
Id, Id,
Username, Username,
PasswordHash,
} }

View File

@@ -9,6 +9,7 @@ pub struct Model {
pub id: i32, pub id: i32,
#[sea_orm(unique)] #[sea_orm(unique)]
pub username: String, pub username: String,
pub password_hash: Option<String>,
} }
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View File

@@ -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,
}

View File

@@ -1,2 +1,3 @@
pub mod auth;
pub mod thought; pub mod thought;
pub mod user; pub mod user;

View File

@@ -6,4 +6,6 @@ use validator::Validate;
pub struct CreateUserParams { pub struct CreateUserParams {
#[validate(length(min = 2))] #[validate(length(min = 2))]
pub username: String, pub username: String,
#[validate(length(min = 6))]
pub password: String,
} }

View File

@@ -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);
}

View File

@@ -1,69 +1,86 @@
use super::main::{create_test_user, setup}; use super::main::{create_user_with_password, setup};
use axum::http::StatusCode; use axum::http::StatusCode;
use http_body_util::BodyExt; use http_body_util::BodyExt;
use serde_json::json; use serde_json::json;
use utils::testing::{make_get_request, make_post_request}; use utils::testing::make_jwt_request;
#[tokio::test] #[tokio::test]
async fn test_feed_and_user_thoughts() { async fn test_feed_and_user_thoughts() {
let app = setup().await; let app = setup().await;
create_test_user(&app.db, "user1").await; // AuthUser is ID 1 create_user_with_password(&app.db, "user1", "password1").await;
create_test_user(&app.db, "user2").await; create_user_with_password(&app.db, "user2", "password2").await;
create_test_user(&app.db, "user3").await; create_user_with_password(&app.db, "user3", "password3").await;
// As user1, post a thought // 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(); 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) // As a different "user", create thoughts for user2 and user3
app::persistence::thought::create_thought( let token2 = super::main::login_user(app.router.clone(), "user2", "password2").await;
&app.db, let body2 = json!({ "content": "user2 was here" }).to_string();
2, make_jwt_request(
models::params::thought::CreateThoughtParams { app.router.clone(),
content: "user2 was here".to_string(), "/thoughts",
}, "POST",
Some(body2),
&token2,
) )
.await .await;
.unwrap();
app::persistence::thought::create_thought(
&app.db,
3,
models::params::thought::CreateThoughtParams {
content: "user3 checking in".to_string(),
},
)
.await
.unwrap();
// 1. Get thoughts for user2 - should only see their thought let token3 = super::main::login_user(app.router.clone(), "user3", "password3").await;
let response = make_get_request(app.router.clone(), "/users/user2/thoughts", Some(2)).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); assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes(); let body = response.into_body().collect().await.unwrap().to_bytes();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(v["thoughts"].as_array().unwrap().len(), 1); assert_eq!(v["thoughts"].as_array().unwrap().len(), 1);
assert_eq!(v["thoughts"][0]["content"], "user2 was here"); assert_eq!(v["thoughts"][0]["content"], "user2 was here");
// 2. user1's feed is initially empty // 2. user1's feed has only their own thought (not following anyone)
let response = make_get_request(app.router.clone(), "/feed", Some(1)).await; 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!(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;
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes(); let body = response.into_body().collect().await.unwrap().to_bytes();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(v["thoughts"].as_array().unwrap().len(), 1); 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]["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");
} }

View File

@@ -1,48 +1,69 @@
use super::main::{create_test_user, setup}; use super::main::{create_user_with_password, setup};
use axum::http::StatusCode; use axum::http::StatusCode;
use utils::testing::{make_delete_request, make_post_request}; use utils::testing::make_jwt_request;
#[tokio::test] #[tokio::test]
async fn test_follow_endpoints() { async fn test_follow_endpoints() {
std::env::set_var("AUTH_SECRET", "test-secret");
let app = setup().await; 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 // 1. user1 follows user2
let response = make_post_request( let response = make_jwt_request(
app.router.clone(), app.router.clone(),
"/users/user2/follow", "/users/user2/follow",
"".to_string(), "POST",
None, None,
&token,
) )
.await; .await;
assert_eq!(response.status(), StatusCode::NO_CONTENT); assert_eq!(response.status(), StatusCode::NO_CONTENT);
// 2. user1 tries to follow user2 again (should fail) // 2. user1 tries to follow user2 again (should fail)
let response = make_post_request( let response = make_jwt_request(
app.router.clone(), app.router.clone(),
"/users/user2/follow", "/users/user2/follow",
"".to_string(), "POST",
None, None,
&token,
) )
.await; .await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST); assert_eq!(response.status(), StatusCode::BAD_REQUEST);
// 3. user1 tries to follow a non-existent user // 3. user1 tries to follow a non-existent user
let response = make_post_request( let response = make_jwt_request(
app.router.clone(), app.router.clone(),
"/users/nobody/follow", "/users/nobody/follow",
"".to_string(), "POST",
None, None,
&token,
) )
.await; .await;
assert_eq!(response.status(), StatusCode::NOT_FOUND); assert_eq!(response.status(), StatusCode::NOT_FOUND);
// 4. user1 unfollows user2 // 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); assert_eq!(response.status(), StatusCode::NO_CONTENT);
// 5. user1 tries to unfollow user2 again (should fail) // 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); assert_eq!(response.status(), StatusCode::NOT_FOUND);
} }

View File

@@ -1,9 +1,11 @@
use api::setup_router; use api::setup_router;
use app::persistence::user::create_user; use app::persistence::user::create_user;
use axum::Router; 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 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 struct TestApp {
pub router: Router, pub router: Router,
@@ -22,8 +24,27 @@ pub async fn setup() -> TestApp {
pub async fn create_test_user(db: &DatabaseConnection, username: &str) { pub async fn create_test_user(db: &DatabaseConnection, username: &str) {
let params = CreateUserParams { let params = CreateUserParams {
username: username.to_string(), username: username.to_string(),
password: "password".to_string(),
}; };
create_user(db, params) create_user(db, params)
.await .await
.expect("Failed to create test user"); .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()
}

View File

@@ -1,3 +1,4 @@
mod auth;
mod feed; mod feed;
mod follow; mod follow;
mod main; mod main;

View File

@@ -9,13 +9,10 @@ use crate::api::main::setup;
#[tokio::test] #[tokio::test]
async fn test_post_users() { async fn test_post_users() {
let app = setup().await; let app = setup().await;
let response = make_post_request(
app.router, let body = r#"{"username": "test", "password": "password123"}"#.to_owned();
"/users", let response = make_post_request(app.router, "/auth/register", body, None).await;
r#"{"username": "test"}"#.to_owned(),
None,
)
.await;
assert_eq!(response.status(), StatusCode::CREATED); assert_eq!(response.status(), StatusCode::CREATED);
let body = response.into_body().collect().await.unwrap().to_bytes(); let body = response.into_body().collect().await.unwrap().to_bytes();
@@ -25,36 +22,25 @@ async fn test_post_users() {
#[tokio::test] #[tokio::test]
pub(super) async fn test_post_users_error() { pub(super) async fn test_post_users_error() {
let app = setup().await; let app = setup().await;
let response = make_post_request(
app.router, let body = r#"{"username": "1", "password": "password123"}"#.to_owned();
"/users", let response = make_post_request(app.router, "/auth/register", body, None).await;
r#"{"username": "1"}"#.to_owned(),
None, println!("{:?}", response);
)
.await;
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
let body = response.into_body().collect().await.unwrap().to_bytes(); let body = response.into_body().collect().await.unwrap().to_bytes();
let result: Value = serde_json::from_slice(&body).unwrap(); let result: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(result["message"], "Validation error"); assert_eq!(result["message"], "Validation error");
assert_eq!(result["details"]["username"][0]["code"], "length"); 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] #[tokio::test]
pub async fn test_get_users() { pub async fn test_get_users() {
let app = setup().await; let app = setup().await;
make_post_request(
app.router.clone(), let body = r#"{"username": "test", "password": "password123"}"#.to_owned();
"/users", make_post_request(app.router.clone(), "/auth/register", body, None).await;
r#"{"username": "test"}"#.to_owned(),
None,
)
.await;
let response = make_get_request(app.router, "/users", None).await; let response = make_get_request(app.router, "/users", None).await;
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);

View File

@@ -7,12 +7,15 @@ use models::params::user::CreateUserParams;
pub(super) async fn test_user(db: &DatabaseConnection) { pub(super) async fn test_user(db: &DatabaseConnection) {
let params = CreateUserParams { let params = CreateUserParams {
username: "test".to_string(), username: "test".to_string(),
password: "password".to_string(),
}; };
let user = create_user(db, params).await.expect("Create user failed!"); let user = create_user(db, params).await.expect("Create user failed!");
let expected = user::ActiveModel { let expected = user::ActiveModel {
id: Unchanged(1), id: Unchanged(1),
username: Unchanged("test".to_owned()), username: Unchanged("test".to_owned()),
password_hash: Unchanged(None),
..Default::default()
}; };
assert_eq!(user, expected); assert_eq!(user, expected);
} }

View File

@@ -49,3 +49,22 @@ pub async fn make_delete_request(app: Router, url: &str, user_id: Option<i32>) -
.await .await
.unwrap() .unwrap()
} }
pub async fn make_jwt_request(
app: Router,
url: &str,
method: &str,
body: Option<String>,
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()
}

View File

@@ -1,5 +1,5 @@
mod api; mod api;
mod db; 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; pub use db::setup_test_db;