backend/handlers/auth.rs
1use std::sync::Arc;
2
3use argon2::{
4 Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
5 password_hash::{SaltString, rand_core::OsRng},
6};
7use axum::{
8 Extension, Json,
9 extract::{Path, State},
10 http::{Response, StatusCode, header},
11 response::IntoResponse,
12};
13use axum_extra::extract::cookie::{Cookie, SameSite};
14use jsonwebtoken::{EncodingKey, Header};
15use serde_json::json;
16
17use crate::{
18 AppState,
19 cookie::jwt::encode_token,
20 models::{FilteredUser, LoginScheme, User, UserCreateScheme, UserUpdateScheme},
21};
22
23/// Registers a new user in the system.
24///
25/// Creates a new [`User`] account with the provided [`UserCreateScheme`] credentials.
26/// The password is hashed using Argon2 before being stored. Only administrators can create new users.
27///
28/// # Arguments
29/// - `State(data)`: Application state containing [`AppState`] for database access
30/// - `request`: [`UserCreateScheme`] containing user details including first/last name, username, admin flag, and password
31///
32/// # Returns
33/// - `200 OK` on successful user creation
34/// - `400 Bad Request` if username/password missing or user already exists
35/// - `500 Internal Server Error` if database insertion fails
36///
37/// # Password Hashing
38/// Uses Argon2 with a cryptographically secure random salt.
39pub async fn create_user(
40 State(data): State<Arc<AppState>>,
41 Json(request): Json<UserCreateScheme>,
42) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
43 if request.username.is_empty() || request.pwd.is_empty() {
44 return Err((
45 StatusCode::BAD_REQUEST,
46 Json(json!({"status": "error", "message": "Missing credential"})),
47 ));
48 }
49
50 let exist_check = sqlx::query_as::<_, UserCreateScheme>(
51 r#"SELECT first_name, last_name, username, is_admin, pwd FROM users WHERE username = $1"#,
52 )
53 .bind(&request.username)
54 .fetch_optional(&data.db)
55 .await
56 .map_err(|e| {
57 (
58 StatusCode::BAD_REQUEST,
59 Json(json!({"status": "error", "message": format!("{:?}", e)})),
60 )
61 })?;
62
63 if let Some(_) = exist_check {
64 return Err((
65 StatusCode::BAD_REQUEST,
66 Json(json!({"status": "error", "message": "user already exists"})),
67 ));
68 }
69
70 let argon = Argon2::default();
71 let salt = SaltString::generate(&mut OsRng);
72 let hashed_pwd = match argon.hash_password(request.pwd.clone().as_bytes(), &salt) {
73 Ok(h) => h.to_string(),
74 Err(e) => panic!("Error hashing {:}", e),
75 };
76
77 let user = sqlx::query("INSERT INTO users (username, pwd, first_name, last_name, is_admin) VALUES ($1, $2, $3, $4, $5)")
78 .bind(request.username)
79 .bind(&hashed_pwd)
80 .bind(request.first_name)
81 .bind(request.last_name)
82 .bind(request.is_admin)
83 .execute(&data.db)
84 .await
85 .map_err(|e| {
86 (
87 StatusCode::INTERNAL_SERVER_ERROR,
88 Json(json!({"status": "error", "message": format!("{}", e)})),
89 )
90 })?;
91
92 if user.rows_affected() < 1 {
93 return Err((
94 StatusCode::INTERNAL_SERVER_ERROR,
95 Json(json!({"status": "error", "message": "Error creating user"})),
96 ));
97 } else {
98 Ok(Json(json!({"status": "success", "result": "User created"})))
99 }
100}
101
102/// Authenticates a user and creates a JWT token for session management.
103///
104/// Verifies the provided username and password against stored credentials using Argon2 verification.
105/// On successful authentication, generates and encodes a [`Claims`](crate::models::Claims) token via [`encode_token`](`crate::cookie::jwt::encode_token`) and sets it as an HTTP-only cookie.
106/// The token is valid for 1 hour.
107///
108/// # Arguments
109/// - `State(data)`: Application state containing [`AppState`] for database access
110/// - `request`: [`LoginScheme`] containing login credentials (username, password)
111///
112/// # Returns
113/// - `200 OK` with JSON containing token and filtered [`FilteredUser`] info
114/// - `400 Bad Request` if username not found or password invalid
115/// - `500 Internal Server Error` if database query fails
116///
117/// # Security Features
118/// - HTTP-only cookie prevents JavaScript access
119/// - SameSite=Lax protects against CSRF attacks
120/// - Password verification uses Argon2 with stored [`User`] hash
121/// - JWT token includes user ID and expiration timestamp via [`Claims`](crate::models::Claims) encoded by [`encode_token`](`crate::cookie::jwt::encode_token`)
122///
123/// # Example Response
124/// ```json
125/// {
126/// "status": "success",
127/// "token": "eyJ0eXAiOiJKV1QiLCJhbGc...",
128/// "user": {"id": 1, "first_name": "Admin", "last_name": "User", "username": "admin", "is_admin": true}
129/// }
130/// ```
131pub async fn login(
132 State(data): State<Arc<AppState>>,
133 Json(request): Json<LoginScheme>,
134) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
135 let user = sqlx::query_as::<_, User>(r#"SELECT * FROM users WHERE username = $1"#)
136 .bind(request.username)
137 .fetch_optional(&data.db)
138 .await
139 .map_err(|e| {
140 (
141 StatusCode::INTERNAL_SERVER_ERROR,
142 Json(json!({"status": "error", "message": format!("{}", e)})),
143 )
144 })?
145 .ok_or_else(|| {
146 (
147 StatusCode::BAD_REQUEST,
148 Json(json!({"status": "error", "message": "Invalid username"})),
149 )
150 })?;
151
152 let pwd_hash = PasswordHash::new(&user.pwd);
153 let valid_pwd = Argon2::default()
154 .verify_password(&request.pwd.as_bytes(), &pwd_hash.unwrap())
155 .is_ok();
156
157 if !valid_pwd {
158 let error_response = serde_json::json!({
159 "status": "error",
160 "message": "Invalid password"
161 });
162 return Err((StatusCode::BAD_REQUEST, Json(error_response)));
163 }
164
165 let token = encode_token(
166 &Header::default(),
167 user.id.clone().to_string(),
168 &EncodingKey::from_secret(data.env.token_secret.as_ref()),
169 );
170
171 let cookie = Cookie::build(("token", token.to_owned()))
172 .path("/")
173 .max_age(time::Duration::hours(1))
174 .same_site(SameSite::Lax)
175 .http_only(true);
176
177 let mut response = Response::new(
178 json!({"status": "success", "token": token, "user": filter_user(&user)}).to_string(),
179 );
180 response
181 .headers_mut()
182 .insert(header::SET_COOKIE, cookie.to_string().parse().unwrap());
183 Ok(response)
184}
185
186/// Logs out the current user by invalidating their session cookie.
187///
188/// Sets the authentication cookie to expire immediately (max_age = -1 hour) which causes
189/// the browser to discard it. This effectively logs the user out without requiring server-side
190/// session invalidation. The cookie no longer contains a valid [`Claims`](crate::models::Claims) token.
191///
192/// # Returns
193/// Always returns `200 OK` with success message and an expired cookie header
194///
195/// # Security
196/// - HTTP-only cookie prevents client-side manipulation
197/// - SameSite=Lax protects against CSRF
198/// - Setting max_age to negative value causes immediate expiration
199///
200/// # Example Response
201/// ```json
202/// {"status": "success", "message": "successfully logged out"}
203/// ```
204pub async fn logout() -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
205 let cookie = Cookie::build(("token", ""))
206 .path("/")
207 .max_age(time::Duration::hours(-1))
208 .same_site(SameSite::Lax)
209 .http_only(true);
210
211 let mut response = Response::new(
212 json!({"status": "success", "message": "successfully logged out"}).to_string(),
213 );
214 response
215 .headers_mut()
216 .insert(header::SET_COOKIE, cookie.to_string().parse().unwrap());
217 Ok(response)
218}
219
220/// Retrieves the currently authenticated user's information.
221///
222/// Uses the [`FilteredUser`] data embedded in the JWT token (via middleware).
223/// Useful for frontends to display logged-in user info or verify authentication.
224///
225/// # Returns
226/// - `200 OK` with [`FilteredUser`] data (excluding password)
227/// - Automatically returns `401 Unauthorized` if not authenticated (middleware)
228///
229/// # Example Response
230/// ```json
231/// {
232/// "status": "success",
233/// "data": {
234/// "id": 1,
235/// "first_name": "Admin",
236/// "last_name": "User",
237/// "is_admin": true
238/// }
239/// }
240/// ```
241pub async fn get_current_user(
242 Extension(user): Extension<FilteredUser>,
243) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
244 let response = json!({
245 "status": "success",
246 "data": json!({
247 "id": user.id,
248 "first_name": user.first_name,
249 "last_name": user.last_name,
250 "is_admin": user.is_admin
251 })
252 });
253
254 Ok(Json(response))
255}
256
257/// Deletes a user account from the system.
258///
259/// Only admins can delete users (enforced by middleware). The [`User`] account and all associated data is removed.
260/// Note: Tickets created by deleted users will have NULL user_id references.
261///
262/// # Arguments
263/// - `Path(id)`: [`User`] ID to delete, extracted from URL path
264/// - `State(data)`: Application state containing [`AppState`] for database access
265///
266/// # Returns
267/// - `204 No Content` on successful deletion
268/// - `404 Not Found` if user doesn't exist
269/// - `500 Internal Server Error` if database error occurs
270pub async fn delete_user(
271 Path(id): Path<i16>,
272 State(data): State<Arc<AppState>>,
273) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
274 let query = sqlx::query(r#"DELETE FROM users WHERE id = $1"#)
275 .bind(id)
276 .execute(&data.db)
277 .await
278 .map_err(|e| {
279 (
280 StatusCode::INTERNAL_SERVER_ERROR,
281 Json(json!({"status": "error", "message": format!("{:?}", e)})),
282 )
283 })?;
284
285 if query.rows_affected() == 0 {
286 let error = json!({
287 "status": "error",
288 "message": format!("User with ID {} not found", id)
289 });
290 return Err((StatusCode::NOT_FOUND, Json(error)));
291 }
292
293 Ok(StatusCode::NO_CONTENT)
294}
295
296/// Retrieves all users in the system.
297///
298/// Only admins can call this endpoint (enforced by middleware). Returns all [`User`] records converted to [`FilteredUser`]
299/// and sorted alphabetically by last name. Password hashes are not included in the response.
300///
301/// # Arguments
302/// - `State(data)`: Application state containing [`AppState`] for database access
303///
304/// # Returns
305/// - `200 OK` with array of [`FilteredUser`] objects
306/// - `500 Internal Server Error` if database query fails
307///
308/// # Example Response
309/// ```json
310/// [
311/// {
312/// "id": 1,
313/// "first_name": "Admin",
314/// "last_name": "User",
315/// "username": "admin",
316/// "is_admin": true
317/// },
318/// {
319/// "id": 2,
320/// "first_name": "Regular",
321/// "last_name": "User",
322/// "username": "regularuser",
323/// "is_admin": false
324/// }
325/// ]
326/// ```
327pub async fn get_users(
328 State(data): State<Arc<AppState>>,
329) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
330 let users = sqlx::query_as::<_, User>(r#"SELECT * FROM users ORDER BY last_name ASC"#)
331 .fetch_all(&data.db)
332 .await
333 .map_err(|e| {
334 let error = json!({
335 "status": "error",
336 "message": format!("{:?}", e)
337 });
338 (StatusCode::INTERNAL_SERVER_ERROR, Json(error))
339 })?;
340
341 let response = users
342 .iter()
343 .map(|user| filter_user(&user))
344 .collect::<Vec<FilteredUser>>();
345 let json_respnse = json!(response);
346 Ok(Json(json_respnse))
347}
348
349/// Retrieves a single user's details by their ID.
350///
351/// This endpoint allows fetching a specific [`User`]'s information. It returns a [`FilteredUser`]
352/// object (converted via [`filter_user`]), ensuring sensitive data like password hashes are not exposed.
353///
354/// # Arguments
355/// - `Path(id)`: The ID of the [`User`] to retrieve, extracted from the URL path.
356/// - `State(data)`: Application state containing [`AppState`] for database access.
357///
358/// # Returns
359/// - `200 OK` with a [`FilteredUser`] JSON object if the user is found.
360/// - `404 Not Found` if a user with the given ID does not exist.
361/// - `500 Internal Server Error` if a database query error occurs.
362///
363pub async fn get_user_by_id(
364 Path(id): Path<i16>,
365 State(data): State<Arc<AppState>>,
366) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
367 let query = sqlx::query_as::<_, User>(r#"SELECT * FROM users WHERE id = $1"#)
368 .bind(id)
369 .fetch_one(&data.db)
370 .await;
371
372 match query {
373 Ok(user) => {
374 let response = serde_json::json!(filter_user(&user));
375 return Ok(Json(response));
376 }
377 Err(sqlx::Error::RowNotFound) => {
378 let error_response = serde_json::json!({
379 "status": "fail",
380 "message": format!("User with ID {} not found", id)
381 });
382 return Err((StatusCode::NOT_FOUND, Json(error_response)));
383 }
384 Err(e) => {
385 return Err((
386 StatusCode::INTERNAL_SERVER_ERROR,
387 Json(json!({"status": "error", "message": format!("{:?}", e)})),
388 ));
389 }
390 };
391}
392
393/// Updates an existing user's information.
394///
395/// This endpoint allows administrators to modify a [`User`]'s `first_name`, `last_name`,
396/// `username`, `is_admin` status, and optionally their password. If `new_pwd` in the
397/// request body is an empty string, the user's password remains unchanged.
398///
399/// # Arguments
400/// - `Path(id)`: The ID of the [`User`] to update, extracted from the URL path.
401/// - `State(data)`: Application state containing [`AppState`] for database access.
402/// - `Json(body)`: [`UserUpdateScheme`] containing the fields to update.
403///
404/// # Returns
405/// - `200 OK` with the [`FilteredUser`] object of the updated user (converted via [`filter_user`]).
406/// - `404 Not Found` if a user with the given ID does not exist.
407/// - `500 Internal Server Error` if a database query or password hashing error occurs.
408///
409/// # Security Note
410/// - Passwords are hashed using Argon2 before storage.
411/// - This endpoint requires admin privileges (enforced by middleware via
412/// [`validate_admin`](crate::cookie::validation::validate_admin)).
413pub async fn update_user(
414 Path(id): Path<i16>,
415 State(data): State<Arc<AppState>>,
416 Json(body): Json<UserUpdateScheme>,
417) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
418 let update_result = if !body.new_pwd.is_empty() {
419 let argon = Argon2::default();
420 let salt = SaltString::generate(&mut OsRng);
421 let hashed_pwd = match argon.hash_password(body.new_pwd.clone().as_bytes(), &salt) {
422 Ok(h) => h.to_string(),
423 Err(e) => panic!("Error hashing {:}", e),
424 };
425
426 sqlx::query(r#"UPDATE users SET first_name = $1, last_name = $2, username = $3, pwd = $4, is_admin = $5 WHERE id = $6"#)
427 .bind(body.first_name.to_owned())
428 .bind(body.last_name.to_owned())
429 .bind(body.username.to_owned())
430 .bind(hashed_pwd)
431 .bind(body.make_admin.to_owned())
432 .bind(id)
433 .execute(&data.db)
434 .await
435 } else {
436 sqlx::query(r#"UPDATE users SET first_name = $1, last_name = $2, username = $3, is_admin = $4 WHERE id = $5"#)
437 .bind(body.first_name.to_owned())
438 .bind(body.last_name.to_owned())
439 .bind(body.username.to_owned())
440 .bind(body.make_admin.to_owned())
441 .bind(id)
442 .execute(&data.db)
443 .await
444 }
445 .map_err(|e| {
446 (
447 StatusCode::INTERNAL_SERVER_ERROR,
448 Json(json!({"status": "error", "message": format!("{:?}", e)})),
449 )
450 })?;
451
452 if update_result.rows_affected() == 0 {
453 let error_response = serde_json::json!({
454 "status": "error",
455 "message": format!("User with ID {} not found", id)
456 });
457 return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(error_response)));
458 }
459
460 let updated_user = sqlx::query_as::<_, User>(r#"SELECT * FROM users WHERE id = $1"#)
461 .bind(id)
462 .fetch_one(&data.db)
463 .await
464 .map_err(|e| {
465 (
466 StatusCode::INTERNAL_SERVER_ERROR,
467 Json(json!({"status": "error", "message": format!("{:?}", e)})),
468 )
469 })?;
470
471 let response = serde_json::json!({
472 "user": filter_user(&updated_user),
473 "status": "success"
474 });
475
476 Ok(Json(response))
477}
478
479/// Checks if any administrator user exists in the system.
480///
481/// This endpoint is used during initialization to determine if the setup page should be displayed.
482/// It counts all [`User`] records with `is_admin = true` in the database.
483///
484/// # Arguments
485/// - `State(data)`: Application state containing [`AppState`] for database access
486///
487/// # Returns
488/// - `200 OK` with JSON: `{"has_admin": bool}` - Whether at least one admin exists
489/// - `500 Internal Server Error` if database query fails
490///
491/// # Example Response
492/// ```json
493/// {"has_admin": false}
494/// ```
495pub async fn check_admin_exists(
496 State(data): State<Arc<AppState>>,
497) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
498 let admin_count =
499 sqlx::query_scalar::<_, i64>(r#"SELECT COUNT(*) FROM users WHERE is_admin = true"#)
500 .fetch_one(&data.db)
501 .await
502 .map_err(|e| {
503 (
504 StatusCode::INTERNAL_SERVER_ERROR,
505 Json(json!({"status": "error", "message": format!("{:?}", e)})),
506 )
507 })?;
508
509 let has_admin = admin_count > 0;
510 Ok(Json(json!({"has_admin": has_admin})))
511}
512
513/// Creates the initial administrator account for a fresh system.
514///
515/// This function handles the one-time setup of the first admin [`User`]. It checks that no admin exists
516/// before allowing creation via database count. This endpoint is only functional when the system has no administrators.
517/// Once created, subsequent admin registrations must go through the normal `create_user` endpoint
518/// with proper authorization.
519///
520/// # Arguments
521/// - `State(data)`: Application state containing [`AppState`] for database access
522/// - `request`: [`UserCreateScheme`] containing user creation details (first_name, last_name, username, password)
523///
524/// # Returns
525/// - `200 OK` with success message if admin account created
526/// - `400 Bad Request` if:
527/// - Admin already exists (checked via admin count)
528/// - Username or password is empty
529/// - `500 Internal Server Error` if database insertion fails
530///
531/// # Security Note
532/// The password is hashed using Argon2 with a random salt before storage.
533pub async fn setup_initial_admin(
534 State(data): State<Arc<AppState>>,
535 Json(request): Json<UserCreateScheme>,
536) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
537 // Check if any admin already exists
538 let admin_count =
539 sqlx::query_scalar::<_, i64>(r#"SELECT COUNT(*) FROM users WHERE is_admin = true"#)
540 .fetch_one(&data.db)
541 .await
542 .map_err(|e| {
543 (
544 StatusCode::INTERNAL_SERVER_ERROR,
545 Json(json!({"status": "error", "message": format!("{:?}", e)})),
546 )
547 })?;
548
549 if admin_count > 0 {
550 return Err((
551 StatusCode::BAD_REQUEST,
552 Json(json!({"status": "error", "message": "Admin user already exists"})),
553 ));
554 }
555
556 if request.username.is_empty() || request.pwd.is_empty() {
557 return Err((
558 StatusCode::BAD_REQUEST,
559 Json(json!({"status": "error", "message": "Missing credential"})),
560 ));
561 }
562
563 let argon = Argon2::default();
564 let salt = SaltString::generate(&mut OsRng);
565 let hashed_pwd = match argon.hash_password(request.pwd.clone().as_bytes(), &salt) {
566 Ok(h) => h.to_string(),
567 Err(e) => panic!("Error hashing {:}", e),
568 };
569
570 let user = sqlx::query("INSERT INTO users (username, pwd, first_name, last_name, is_admin) VALUES ($1, $2, $3, $4, $5)")
571 .bind(request.username)
572 .bind(&hashed_pwd)
573 .bind(request.first_name)
574 .bind(request.last_name)
575 .bind(true)
576 .execute(&data.db)
577 .await
578 .map_err(|e| {
579 (
580 StatusCode::INTERNAL_SERVER_ERROR,
581 Json(json!({"status": "error", "message": format!("{}", e)})),
582 )
583 })?;
584
585 if user.rows_affected() < 1 {
586 return Err((
587 StatusCode::INTERNAL_SERVER_ERROR,
588 Json(json!({"status": "error", "message": "Error creating admin user"})),
589 ));
590 } else {
591 Ok(Json(
592 json!({"status": "success", "result": "Admin user created"}),
593 ))
594 }
595}
596
597/// Converts a [`User`] with sensitive data into a [`FilteredUser`] safe for API responses.
598///
599/// This function removes password hashes and other sensitive information before
600/// returning [`User`] data to clients. Always use this helper instead of directly
601/// serializing [`User`] objects.
602/// Used by all authentication endpoints to ensure passwords are never exposed.
603///
604/// # Arguments
605/// - `user`: Reference to the internal [`User`] struct containing password hash
606///
607/// # Returns
608/// [`FilteredUser`] with only safe-to-share information:
609/// - `id`: User ID
610/// - `first_name`, `last_name`: User name
611/// - `username`: Login username
612/// - `is_admin`: Admin privilege flag
613///
614/// # Important
615/// The password hash is explicitly **NOT** included in the output. This prevents
616/// accidental exposure of sensitive authentication data.
617///
618/// # Example
619/// ```ignore
620/// let user = get_user_from_db(1).await?;
621/// let safe_user = filter_user(&user); // Convert User to FilteredUser
622/// // safe_user can be safely serialized and sent to client
623/// ```
624pub fn filter_user(user: &User) -> FilteredUser {
625 FilteredUser {
626 id: user.id,
627 first_name: user.first_name.clone(),
628 last_name: user.last_name.clone(),
629 username: user.username.clone(),
630 is_admin: user.is_admin.clone(),
631 }
632}