Skip to content

Commit d8c31fe

Browse files
authored
Merge pull request #4 from kavinda-100/develop
Develop
2 parents 53c2e82 + 1333695 commit d8c31fe

11 files changed

Lines changed: 322 additions & 60 deletions

File tree

README.md

Lines changed: 116 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ A production-ready Task Management REST API built with **Rust**, **Axum**, and *
1313

1414
- 🏗️ **Clean Architecture** – Organized into Models, Controllers, Services, Routes, and Middleware
1515
- 🔄 **API Versioning**`/api/v1` ready
16-
- 🔒 **Google OAuth + JWT Auth** – Secure authentication system
16+
- 🔐 **JWT Cookie Authentication (Implemented)** – Register, login, logout, and protected `me` endpoint
17+
- 🔒 **Google OAuth (Planned/Partial)** – User model and login checks are ready for OAuth users
1718
- 🗃️ **Database Migrations** – SQLx-powered migrations for PostgreSQL
1819
-**Async/Await** – Powered by Tokio runtime for high-performance APIs
1920
- 📝 **Full CRUD** – Tasks, Projects, Users, Workspaces
@@ -40,7 +41,120 @@ A production-ready Task Management REST API built with **Rust**, **Axum**, and *
4041

4142
---
4243

43-
## 📁 Project Structure (coming soon)
44+
## 📁 Project Structure (Current)
45+
46+
```bash
47+
src/
48+
├── controllers/
49+
│ ├── auth_controller.rs # register/login/logout/me handlers
50+
│ └── root_controller.rs # health check
51+
├── dtos/
52+
│ └── auth_dto.rs # request validation + auth response DTO
53+
├── middleware/
54+
│ └── auth.rs # JWT cookie auth extractor for protected routes
55+
├── routes/
56+
│ ├── auth_routes.rs # /api/v1/auth/* route definitions
57+
│ └── mod.rs # route composition + static file fallback
58+
├── utils/
59+
│ ├── response.rs # ApiResponse<T> success wrapper
60+
│ ├── api_error.rs # ApiError enum -> HTTP error responses
61+
│ ├── jwt.rs # token generation and verification
62+
│ ├── hash.rs # Argon2 password hash + verify
63+
│ └── mod.rs # utility exports + validation error formatter
64+
├── db/
65+
│ └── mod.rs # DB pool + automatic SQLx migrations at startup
66+
└── main.rs # app bootstrap, middleware layers, state wiring
67+
```
68+
69+
## 🔐 Auth Module (Implemented So Far)
70+
71+
Base path: `/api/v1/auth`
72+
73+
- `POST /register`
74+
- Validates payload (`name`, `email`, `password`, `confirm_password`)
75+
- Checks duplicate email
76+
- Hashes password with Argon2
77+
- Creates user in PostgreSQL
78+
- Generates JWT and stores it in an HTTP-only cookie named `token`
79+
- Returns normalized `ApiResponse<AuthUserResponse>`
80+
81+
- `POST /login`
82+
- Validates payload
83+
- Finds user by email
84+
- Blocks password login for Google-only users (`google_id` present)
85+
- Verifies password against Argon2 hash
86+
- Generates JWT and updates `token` cookie
87+
- Returns normalized `ApiResponse<AuthUserResponse>`
88+
89+
- `POST /logout`
90+
- Clears auth cookie (`token`)
91+
- Returns normalized `ApiResponse<()>`
92+
93+
- `GET /me`
94+
- Protected route using `AuthUser` extractor (`src/middleware/auth.rs`)
95+
- Reads and verifies JWT from cookie
96+
- Loads user from DB
97+
- Returns current authenticated user profile in `ApiResponse<AuthUserResponse>`
98+
99+
## 🧰 `src/utils` Folder and Use Cases
100+
101+
This project is educational, so the utility layer is intentionally explicit and reusable for future modules (tasks, projects, workspaces).
102+
103+
| File | What it does | Why it matters for future features |
104+
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------- |
105+
| `src/utils/response.rs` | Defines `ApiResponse<T>` with `success`, `status_code`, `message`, `data` | Gives every endpoint a predictable success payload shape |
106+
| `src/utils/api_error.rs` | Defines `ApiError` (`BadRequest`, `Unauthorized`, `NotFound`, `Conflict`, `InternalServerError`) and converts it into HTTP responses | Centralizes error-to-status mapping so controllers stay clean |
107+
| `src/utils/jwt.rs` | Generates and verifies JWT claims (`sub`, `exp`) | Reusable token logic for any protected resource |
108+
| `src/utils/hash.rs` | Hashes passwords (Argon2 + random salt) and verifies passwords | Security best practice reusable for account/password features |
109+
| `src/utils/mod.rs` | Exports utility modules and formats validator errors into readable strings | Keeps validation messages user-friendly and consistent |
110+
111+
## 🔄 How Response + Error + JWT Work Together
112+
113+
1. **Request enters route** (`src/routes/auth_routes.rs`) and is handled by `auth_controller`.
114+
2. **DTO validation runs** (`validator` crate in `src/dtos/auth_dto.rs`).
115+
3. **If validation fails**, controller maps errors using `format_validation_errors(...)` and returns `ApiError::BadRequest(...)`.
116+
4. **If business/database logic fails**, controller returns another `ApiError` variant.
117+
5. **`ApiError` implements `IntoResponse`**, so Axum converts it into a standardized HTTP error payload.
118+
6. **If success**, controller returns `Json(ApiResponse<T>)` from `src/utils/response.rs`.
119+
7. **For protected routes**, `AuthUser` middleware extractor in `src/middleware/auth.rs` reads `token` from cookies, verifies JWT using `utils/jwt.rs`, fetches the user from DB, and injects authenticated `User` into the handler.
120+
121+
## 📦 Response Format (Current)
122+
123+
Success shape:
124+
125+
```json
126+
{
127+
"success": true,
128+
"status_code": 200,
129+
"message": "User logged in successfully",
130+
"data": {
131+
"id": "uuid",
132+
"email": "user@example.com",
133+
"name": "User"
134+
}
135+
}
136+
```
137+
138+
Error shape:
139+
140+
```json
141+
{
142+
"success": false,
143+
"status_code": 400,
144+
"message": "email: Invalid email format, password: Password must be between 6 and 12 characters",
145+
"data": null
146+
}
147+
```
148+
149+
## 🍪 JWT Cookie Behavior
150+
151+
- Cookie name: `token`
152+
- `HttpOnly`: enabled (protects token from client-side JS access)
153+
- `SameSite`: `Lax`
154+
- `Secure`: enabled when `DEV_MODE != development`
155+
- Signing secret: `JWT_SECRET` from environment
156+
157+
This makes auth state server-trusted and easy to consume from browser clients.
44158

45159
## database migrations
46160

public/table.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ const endpoints = [
1818
description: 'Login with email and password',
1919
status: 'active',
2020
},
21+
,
22+
{
23+
endpoint: '/api/v1/auth/logout',
24+
method: 'POST',
25+
description: 'Logout the current user',
26+
status: 'active',
27+
},
2128
{
2229
endpoint: '/api/v1/auth/google',
2330
method: 'GET',

src/controllers/auth_controller.rs

Lines changed: 52 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@ use validator::Validate;
55
use crate::{
66
config::AppState,
77
dtos::auth_dto::{AuthUserResponse, LoginUserDto, RegisterUserDto},
8+
middleware::auth::AuthUser,
89
models::User,
910
utils::{
11+
api_error::ApiError,
12+
format_validation_errors,
1013
hash::{hash_password, verify_password},
1114
jwt::generate_jwt,
1215
response::ApiResponse,
@@ -23,43 +26,37 @@ pub async fn register_user(
2326
State(state): State<AppState>,
2427
cookies: Cookies,
2528
Json(payload): Json<RegisterUserDto>,
26-
) -> Result<Json<ApiResponse<AuthUserResponse>>, StatusCode> {
29+
) -> Result<Json<ApiResponse<AuthUserResponse>>, ApiError> {
2730
// logging the registration attempt with the email (but not the password)
2831
tracing::info!("Attempting to register user with email: {}", payload.email);
2932

3033
// Validate the input payload
31-
payload.validate().map_err(|_| StatusCode::BAD_REQUEST)?;
34+
payload.validate().map_err(|e| {
35+
let error_messages = format_validation_errors(&e);
36+
tracing::error!("Validation errors: {}", error_messages);
37+
ApiError::BadRequest(error_messages)
38+
})?;
3239

3340
// comparing password and confirm_password
3441
if payload.password != payload.confirm_password {
35-
return Ok(Json(ApiResponse::new(
36-
false,
37-
StatusCode::BAD_REQUEST,
38-
"Passwords do not match",
39-
None,
40-
)));
42+
return Err(ApiError::BadRequest("Passwords do not match".into()));
4143
}
4244

4345
// Check if the email is already registered
4446
let existing_user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = $1")
4547
.bind(&payload.email)
4648
.fetch_optional(&state.db)
4749
.await
48-
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
50+
.map_err(|_| ApiError::InternalServerError("Failed to fetch user".into()))?;
4951

5052
// If the email is already registered, return a conflict error
5153
if existing_user.is_some() {
52-
return Ok(Json(ApiResponse::new(
53-
false,
54-
StatusCode::CONFLICT,
55-
"User is already registered",
56-
None,
57-
)));
54+
return Err(ApiError::Conflict("User is already registered".into()));
5855
}
5956

6057
// Hash the password
61-
let hashed_password =
62-
hash_password(&payload.password).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
58+
let hashed_password = hash_password(&payload.password)
59+
.map_err(|_| ApiError::InternalServerError("Failed to hash password".into()))?;
6360

6461
// Store the user in the database and return the created user
6562
let user = sqlx::query_as::<_, User>(
@@ -70,11 +67,11 @@ pub async fn register_user(
7067
.bind(&hashed_password)
7168
.fetch_one(&state.db)
7269
.await
73-
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
70+
.map_err(|_| ApiError::InternalServerError("Failed to create user".into()))?;
7471

7572
// Generate a JWT token for the user
7673
let token = generate_jwt(user.id, &state.env_config.jwt_secret)
77-
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
74+
.map_err(|_| ApiError::InternalServerError("Failed to generate JWT token".into()))?;
7875

7976
// Set the JWT token as an HTTP-only cookie
8077
cookies.add(
@@ -112,41 +109,35 @@ pub async fn login_user(
112109
State(state): State<AppState>,
113110
cookies: Cookies,
114111
Json(payload): Json<LoginUserDto>,
115-
) -> Result<Json<ApiResponse<AuthUserResponse>>, StatusCode> {
112+
) -> Result<Json<ApiResponse<AuthUserResponse>>, ApiError> {
116113
// logging the login attempt with the email (but not the password)
117114
tracing::info!("Attempting to login user with email: {}", payload.email);
118115

119116
// Validate the input payload
120-
payload.validate().map_err(|_| StatusCode::BAD_REQUEST)?;
117+
payload.validate().map_err(|e| {
118+
let error_messages = format_validation_errors(&e);
119+
tracing::error!("Validation errors: {}", error_messages);
120+
ApiError::BadRequest(error_messages)
121+
})?;
121122

122123
// Check if the user exists in the database
123124
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = $1")
124125
.bind(&payload.email)
125126
.fetch_optional(&state.db)
126127
.await
127-
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
128+
.map_err(|_| ApiError::InternalServerError("Failed to fetch user".into()))?;
128129

129130
// If the user does not exist, return an unauthorized error
130131
let user = match user {
131132
Some(user) => user,
132133
None => {
133-
return Ok(Json(ApiResponse::new(
134-
false,
135-
StatusCode::UNAUTHORIZED,
136-
"User does not exist",
137-
None,
138-
)));
134+
return Err(ApiError::NotFound("User does not exist".into()));
139135
}
140136
};
141137

142138
// check if the user has a google_id, if so, they should not be able to login with email and password
143139
if user.google_id.is_some() {
144-
return Ok(Json(ApiResponse::new(
145-
false,
146-
StatusCode::UNAUTHORIZED,
147-
"Please login with Google",
148-
None,
149-
)));
140+
return Err(ApiError::Unauthorized("Please login with google".into()));
150141
}
151142

152143
// check if the user has a password_hash, if not, they should not be able to login with email and password
@@ -157,30 +148,20 @@ pub async fn login_user(
157148
"User with email {} does not have a password hash",
158149
payload.email
159150
);
160-
return Ok(Json(ApiResponse::new(
161-
false,
162-
StatusCode::UNAUTHORIZED,
163-
"Invalid Credentials",
164-
None,
165-
)));
151+
return Err(ApiError::NotFound("Password not found".into()));
166152
}
167153
};
168154

169155
// Verify the password
170156
let password_valid = verify_password(&payload.password, password_hash);
171157
// If the password is incorrect, return an unauthorized error
172158
if !password_valid {
173-
return Ok(Json(ApiResponse::new(
174-
false,
175-
StatusCode::UNAUTHORIZED,
176-
"Invalid Credentials",
177-
None,
178-
)));
159+
return Err(ApiError::Unauthorized("Invalid Credentials".into()));
179160
}
180161

181162
// Generate a JWT token for the user
182163
let token = generate_jwt(user.id, &state.env_config.jwt_secret)
183-
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
164+
.map_err(|_| ApiError::InternalServerError("Failed to generate JWT token".into()))?;
184165

185166
// Set the JWT token as an HTTP-only cookie
186167
cookies.add(
@@ -213,7 +194,7 @@ pub async fn login_user(
213194
* Logs out the current user by removing the JWT token cookie. Returns a JSON response indicating that the user has been logged out successfully.
214195
* Path: POST /api/v1/auth/logout
215196
*/
216-
pub async fn logout_user(cookies: Cookies) -> Result<Json<ApiResponse<()>>, StatusCode> {
197+
pub async fn logout_user(cookies: Cookies) -> Result<Json<ApiResponse<()>>, ApiError> {
217198
// logging the logout attempt
218199
tracing::info!("Attempting to logout user");
219200

@@ -234,3 +215,26 @@ pub async fn logout_user(cookies: Cookies) -> Result<Json<ApiResponse<()>>, Stat
234215
None,
235216
)))
236217
}
218+
219+
/**
220+
* Retrieves the authenticated user's information. Requires the user to be authenticated with a valid JWT token in the cookies. Returns a JSON response with the user's information if authentication is successful, or an appropriate error message if authentication fails.
221+
* Path: GET /api/v1/auth/me
222+
*/
223+
pub async fn get_me(
224+
AuthUser(user): AuthUser,
225+
) -> Result<Json<ApiResponse<AuthUserResponse>>, ApiError> {
226+
Ok(Json(ApiResponse::new(
227+
true,
228+
StatusCode::OK,
229+
"Authenticated user",
230+
Some(AuthUserResponse {
231+
id: user.id,
232+
email: user.email,
233+
name: user.name,
234+
google_id: user.google_id,
235+
avatar_url: user.avatar_url,
236+
created_at: user.created_at,
237+
updated_at: user.updated_at,
238+
}),
239+
)))
240+
}

src/dtos/auth_dto.rs

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,39 @@ use validator::Validate;
55

66
#[derive(Debug, Deserialize, Validate)]
77
pub struct RegisterUserDto {
8-
#[validate(length(min = 1))]
8+
#[validate(length(min = 1, message = "Name cannot be empty"))]
99
pub name: String,
1010

11-
#[validate(email)]
11+
#[validate(
12+
email(message = "Invalid email format"),
13+
length(min = 1, message = "Email cannot be empty")
14+
)]
1215
pub email: String,
1316

14-
#[validate(length(min = 6, max = 12))]
17+
#[validate(length(
18+
min = 6,
19+
max = 12,
20+
message = "Password must be between 6 and 12 characters"
21+
))]
1522
pub password: String,
1623

17-
#[validate(length(min = 6, max = 12))]
24+
#[validate(must_match(other = "password", message = "Passwords do not match"))]
1825
pub confirm_password: String,
1926
}
2027

2128
#[derive(Debug, Deserialize, Validate)]
2229
pub struct LoginUserDto {
23-
#[validate(email)]
30+
#[validate(
31+
email(message = "Invalid email format"),
32+
length(min = 1, message = "Email cannot be empty")
33+
)]
2434
pub email: String,
2535

26-
#[validate(length(min = 6, max = 12))]
36+
#[validate(length(
37+
min = 6,
38+
max = 12,
39+
message = "Password must be between 6 and 12 characters"
40+
))]
2741
pub password: String,
2842
}
2943

src/errors/mod.rs

Whitespace-only changes.

0 commit comments

Comments
 (0)