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

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

View File

@@ -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<String> =
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<AppState> for AuthUser {
parts: &mut Parts,
_state: &AppState,
) -> 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") {
let user_id_str = user_id_header.to_str().unwrap_or("1");
let user_id = user_id_str.parse::<i32>().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(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::<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;
pub use auth::AuthUser;
pub use auth::Claims;
pub use json::Json;
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(
get,
path = "/",
path = "",
responses(
(status = 200, description = "Authenticated user's feed", body = ThoughtListSchema)
),

View File

@@ -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())

View File

@@ -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")
),

View File

@@ -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<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(
get,
path = "",
@@ -195,7 +167,7 @@ async fn user_follow_delete(
pub fn create_user_router() -> Router<AppState> {
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(