Added user login, logout and creation functionality

Also minor changes to ticketing and AppState
This commit is contained in:
2026-04-24 19:46:02 +02:00
parent fe04483e76
commit 51b6f89df2
10 changed files with 226 additions and 16 deletions

View File

@@ -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,

View File

@@ -1 +1 @@
mod jwt;
pub mod jwt;

16
backend/src/env.rs Normal file
View File

@@ -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,
}
}
}

View File

@@ -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<Arc<AppState>>,
Json(request): Json<UserCreateScheme>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
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<Arc<AppState>>,
Json(request): Json<LoginScheme>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
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<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
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(),
}
}

View File

@@ -1 +1,2 @@
pub mod auth;
pub mod ticket;

View File

@@ -68,17 +68,18 @@ pub async fn delete_ticket(
pub async fn get_tickets(
State(data): State<Arc<AppState>>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
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

View File

@@ -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;
}

View File

@@ -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,