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:
@@ -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"
|
||||
|
@@ -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,
|
||||
}
|
||||
}
|
||||
|
@@ -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())
|
||||
}
|
||||
|
@@ -3,5 +3,6 @@ mod json;
|
||||
mod valid;
|
||||
|
||||
pub use auth::AuthUser;
|
||||
pub use auth::Claims;
|
||||
pub use json::Json;
|
||||
pub use valid::Valid;
|
||||
|
93
thoughts-backend/api/src/routers/auth.rs
Normal file
93
thoughts-backend/api/src/routers/auth.rs
Normal 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))
|
||||
}
|
@@ -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)
|
||||
),
|
||||
|
@@ -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())
|
||||
|
@@ -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")
|
||||
),
|
||||
|
@@ -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(
|
||||
|
Reference in New Issue
Block a user