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}