diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 0a96f0e..c697a45 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -148,6 +148,7 @@ dependencies = [ "serde", "serde_json", "sqlx", + "time", "tokio", ] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index e211d01..1b7184e 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -14,3 +14,4 @@ dotenv = "0.15.0" chrono = { version = "0.4.44", features = ["serde"] } jsonwebtoken = "10.3.0" argon2 = "0.5.3" +time = "0.3.47" diff --git a/backend/src/cookie/jwt.rs b/backend/src/cookie/jwt.rs index 47b84f9..c03acc7 100644 --- a/backend/src/cookie/jwt.rs +++ b/backend/src/cookie/jwt.rs @@ -13,7 +13,7 @@ pub struct Error { pub fn encode_token(header: &Header, id: String, key: &EncodingKey) -> String { let now = chrono::Utc::now(); let issued = now.timestamp() as usize; - let expires = (now + chrono::Duration::minutes(90)).timestamp() as usize; + let expires = (now + chrono::Duration::minutes(60)).timestamp() as usize; let claims: Claims = Claims { subject: id, issued: issued, diff --git a/backend/src/cookie/mod.rs b/backend/src/cookie/mod.rs index 5b383e3..417233c 100644 --- a/backend/src/cookie/mod.rs +++ b/backend/src/cookie/mod.rs @@ -1 +1 @@ -mod jwt; +pub mod jwt; diff --git a/backend/src/env.rs b/backend/src/env.rs new file mode 100644 index 0000000..1de565e --- /dev/null +++ b/backend/src/env.rs @@ -0,0 +1,16 @@ +#[derive(Debug, Clone)] +pub struct Env { + pub db_url: String, + pub token_secret: String, +} + +impl Env { + pub fn load() -> Env { + let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + let token_secret = std::env::var("TOKEN_SECRET").expect("TOKEN_SECRET must be set"); + Env { + db_url, + token_secret, + } + } +} diff --git a/backend/src/handlers/auth.rs b/backend/src/handlers/auth.rs new file mode 100644 index 0000000..ed1ed22 --- /dev/null +++ b/backend/src/handlers/auth.rs @@ -0,0 +1,165 @@ +use std::{sync::Arc, usize}; + +use argon2::{ + Argon2, PasswordHash, PasswordHasher, PasswordVerifier, + password_hash::{SaltString, rand_core::OsRng}, +}; +use axum::{ + Json, + extract::State, + http::{Response, StatusCode, header}, + response::IntoResponse, +}; +use axum_extra::extract::cookie::{Cookie, SameSite}; +use chrono::format; +use jsonwebtoken::{EncodingKey, Header}; +use serde_json::json; + +use crate::{ + AppState, + cookie::jwt::encode_token, + models::{FilteredUser, LoginModel, LoginScheme, UserCreateScheme}, +}; + +pub async fn create_user( + State(data): State>, + Json(request): Json, +) -> Result)> { + if request.username.is_empty() || request.pwd.is_empty() { + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({"status": "error", "message": "Missing credential"})), + )); + } + + let exist_check = sqlx::query_as::<_, UserCreateScheme>( + r#"SELECT firstname, name, username, is_admin, passwd FROM users WHERE username = $1"#, + ) + .bind(&request.username) + .fetch_optional(&data.db) + .await + .map_err(|e| { + ( + StatusCode::BAD_REQUEST, + Json(json!({"status": "error", "message": format!("{:?}", e)})), + ) + })?; + + if let Some(_) = exist_check { + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({"status": "error", "message": "user already exists"})), + )); + } + + let argon = Argon2::default(); + let salt = SaltString::generate(&mut OsRng); + let hashed_pwd = match argon.hash_password(request.pwd.clone().as_bytes(), &salt) { + Ok(h) => h.to_string(), + Err(e) => panic!("Error hashing {:}", e), + }; + + let user = sqlx::query("INSERT INTO login (username, passwd, firstname, name, is_admin) VALUES ($1, $2, $3, $4, $5)") + .bind(request.username) + .bind(&hashed_pwd) + .bind(request.first_name) + .bind(request.last_name) + .bind(request.is_admin) + .execute(&data.db) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"status": "error", "message": format!("{}", e)})), + ) + })?; + + if user.rows_affected() < 1 { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"status": "error", "message": "Error creating user"})), + )); + } else { + Ok(Json(json!({"status": "success", "result": "User created"}))) + } +} + +pub async fn login( + State(data): State>, + Json(request): Json, +) -> Result)> { + let user = sqlx::query_as::<_, LoginModel>(r#"SELECT * FROM users WHERE username = $1"#) + .bind(request.username) + .fetch_optional(&data.db) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"status": "error", "message": format!("{}", e)})), + ) + })? + .ok_or_else(|| { + ( + StatusCode::BAD_REQUEST, + Json(json!({"status": "error", "message": "Invalid username"})), + ) + })?; + + let pwd_hash = PasswordHash::new(&user.pwd); + let valid_pwd = Argon2::default() + .verify_password(&request.pwd.as_bytes(), &pwd_hash.unwrap()) + .is_ok(); + + if !valid_pwd { + let error_response = serde_json::json!({ + "status": "error", + "message": "Invalid password" + }); + return Err((StatusCode::BAD_REQUEST, Json(error_response))); + } + + let token = encode_token( + &Header::default(), + user.id.clone().to_string(), + &EncodingKey::from_secret(data.env.token_secret.as_ref()), + ); + + let cookie = Cookie::build(("token", token.to_owned())) + .path("/") + .max_age(time::Duration::hours(1)) + .same_site(SameSite::Lax) + .http_only(true); + + let mut response = Response::new( + json!({"status": "success", "token": token, "user": filter_users(&user)}).to_string(), + ); + response + .headers_mut() + .insert(header::SET_COOKIE, cookie.to_string().parse().unwrap()); + Ok(response) +} + +pub async fn logout() -> Result)> { + let cookie = Cookie::build(("token", "")) + .path("/") + .max_age(time::Duration::hours(-1)) + .same_site(SameSite::Lax) + .http_only(true); + + let mut response = Response::new( + json!({"status": "success", "message": "successfully logged out"}).to_string(), + ); + response + .headers_mut() + .insert(header::SET_COOKIE, cookie.to_string().parse().unwrap()); + Ok(response) +} + +fn filter_users(user: &LoginModel) -> FilteredUser { + FilteredUser { + id: user.id, + first_name: user.first_name.clone(), + last_name: user.last_name.clone(), + is_admin: user.is_admin.clone(), + } +} diff --git a/backend/src/handlers/mod.rs b/backend/src/handlers/mod.rs index 356fccf..2b84e86 100644 --- a/backend/src/handlers/mod.rs +++ b/backend/src/handlers/mod.rs @@ -1 +1,2 @@ +pub mod auth; pub mod ticket; diff --git a/backend/src/handlers/ticket.rs b/backend/src/handlers/ticket.rs index 4006227..28fc16e 100644 --- a/backend/src/handlers/ticket.rs +++ b/backend/src/handlers/ticket.rs @@ -68,17 +68,18 @@ pub async fn delete_ticket( pub async fn get_tickets( State(data): State>, ) -> Result)> { - let tickets = - sqlx::query_as(r#"SELECT * FROM tickets WHERE status <> 'Archived' ORDER BY date DESC"#) - .fetch_all(&data.db) - .await - .map_err(|e| { - let error_response = serde_json::json!({ - "status": "error", - "message": format!("Database error: {}", e), - }); - (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response)) - })?; + let tickets = sqlx::query_as::<_, Ticket>( + r#"SELECT * FROM tickets WHERE status <> 'Archived' ORDER BY date DESC"#, + ) + .fetch_all(&data.db) + .await + .map_err(|e| { + let error_response = serde_json::json!({ + "status": "error", + "message": format!("Database error: {}", e), + }); + (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response)) + })?; let ticket_response = tickets .iter() @@ -144,7 +145,7 @@ pub async fn edit_ticket( return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))); } - let updated_ticket = sqlx::query_as(r#"SELECT * FROM tickets WHERE id = $1"#) + let updated_ticket = sqlx::query_as::<_, Ticket>(r#"SELECT * FROM tickets WHERE id = $1"#) .bind(id) .fetch_one(&data.db) .await diff --git a/backend/src/main.rs b/backend/src/main.rs index 814b13e..abab5e1 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,5 +1,6 @@ #![allow(unused_imports)] mod cookie; +mod env; mod handlers; mod models; mod router; @@ -12,13 +13,17 @@ use router::create_router; use serde::{Deserialize, Serialize}; use sqlx::{PgPool, postgres::PgPoolOptions}; +use crate::env::Env; + pub struct AppState { db: PgPool, + env: Env, } #[tokio::main] async fn main() { dotenv().ok(); + let env = Env::load(); let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL variable not set"); let pool = match PgPoolOptions::new().connect(&database_url).await { Ok(pool) => { @@ -30,7 +35,10 @@ async fn main() { std::process::exit(1); } }; - let app = create_router(Arc::new(AppState { db: pool.clone() })); + let app = create_router(Arc::new(AppState { + db: pool.clone(), + env: env.clone(), + })); let listener = tokio::net::TcpListener::bind("0.0.0.0:8001").await.unwrap(); axum::serve(listener, app).await; } diff --git a/backend/src/models.rs b/backend/src/models.rs index da3caa9..49d877c 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -49,7 +49,7 @@ pub struct TicketUpdateScheme { pub status: String, } -#[derive(Deserialize, Serialize, Debug)] +#[derive(Deserialize, Serialize, Debug, sqlx::FromRow)] pub struct UserCreateScheme { pub first_name: String, pub last_name: String, @@ -64,6 +64,23 @@ pub struct LoginScheme { pub pwd: String, } +#[derive(Deserialize, Serialize, Debug, sqlx::FromRow)] +pub struct LoginModel { + pub id: i16, + pub first_name: String, + pub last_name: String, + pub is_admin: bool, + pub pwd: String, +} + +#[derive(Debug, Serialize)] +pub struct FilteredUser { + pub id: i16, + pub first_name: String, + pub last_name: String, + pub is_admin: bool, +} + #[derive(Debug, Serialize, Deserialize, Clone)] pub struct Claims { pub subject: String,