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

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