diff --git a/src/backend/Cargo.toml b/src/backend/Cargo.toml index 7859999..933a4e2 100644 --- a/src/backend/Cargo.toml +++ b/src/backend/Cargo.toml @@ -8,6 +8,7 @@ anyhow = "1.0.100" argon2 = "0.5.3" axum = "0.8.7" chrono = "0.4.42" +jsonwebtoken = { version = "10.2.0", features = ["rust_crypto"] } lazy_static = "1.5.0" password-hash = "0.5.0" rand_core = { version = "0.6", features = ["getrandom"] } diff --git a/src/backend/migrations/0002_create_users.sql b/src/backend/migrations/0002_create_users.sql index 0287c4a..8e4c10a 100644 --- a/src/backend/migrations/0002_create_users.sql +++ b/src/backend/migrations/0002_create_users.sql @@ -1,4 +1,4 @@ -CREATE TABLE user_permissions ( +CREATE TABLE users ( uuid UUID PRIMARY KEY, username VARCHAR NOT NULL UNIQUE, email VARCHAR UNIQUE, diff --git a/src/backend/migrations/0003_create_perms.sql b/src/backend/migrations/0003_create_perms.sql index 4ab0d64..d5139d0 100644 --- a/src/backend/migrations/0003_create_perms.sql +++ b/src/backend/migrations/0003_create_perms.sql @@ -1,4 +1,4 @@ -CREATE TABLE users ( +CREATE TABLE user_permissions( uuid UUID PRIMARY KEY, root BOOL NOT NULL, manage_users BOOL NOT NULL, diff --git a/src/backend/src/auth/mod.rs b/src/backend/src/auth/mod.rs index b4abc90..1acac03 100644 --- a/src/backend/src/auth/mod.rs +++ b/src/backend/src/auth/mod.rs @@ -1,8 +1,16 @@ +use crate::prelude::*; +use std::i64; + +use anyhow::Result; use argon2::{ - Argon2, + Argon2, PasswordHash, PasswordVerifier, password_hash::{PasswordHasher, SaltString, rand_core::OsRng}, }; -use tracing::debug; +use axum::{extract::State, http::StatusCode}; +use chrono::{Duration, Utc}; +use jsonwebtoken::{DecodingKey, EncodingKey, Header, TokenData, Validation, decode, encode}; + +use crate::domain::api::AuthClaims; pub fn hash_password(password: &str) -> Result { let salt = SaltString::generate(&mut OsRng); @@ -13,3 +21,47 @@ pub fn hash_password(password: &str) -> Result { debug!("password hashed"); Ok(hash) } + +pub fn verify_password(password: &str, password_hash: &str) -> Result { + let parsed_hash = PasswordHash::new(password_hash)?; + let argon2 = Argon2::default(); + match argon2.verify_password(password.as_bytes(), &parsed_hash) { + Ok(_) => return Ok(true), + Err(password_hash::Error::Password) => return Ok(false), + Err(e) => Err(e), + } +} + +pub fn gen_jwt(username: String) -> Result { + // TODO: Update secret to .env file + let secret: String = "verysafestring".to_string(); + let now = Utc::now(); + let expire: chrono::TimeDelta = Duration::hours(24); + let exp = (now + expire).timestamp() as i64; + let iat = now.timestamp() as i64; + let claim = AuthClaims { iat, exp, username }; + + encode( + &Header::default(), + &claim, + &EncodingKey::from_secret(secret.as_ref()), + ) + .map_err(|e| { + error!("Failed to create JWT: {}", e); + return StatusCode::INTERNAL_SERVER_ERROR; + }) +} + +pub fn verify_jwt(token: String) -> Result, StatusCode> { + let secret = "verysafestring".to_string(); + let result: Result, StatusCode> = decode( + &token, + &DecodingKey::from_secret(secret.as_ref()), + &Validation::default(), + ) + .map_err(|e| { + error!("Failed to verify JWT: {}", e); + return StatusCode::INTERNAL_SERVER_ERROR; + }); + result +} diff --git a/src/backend/src/core/user_routines.rs b/src/backend/src/core/user_routines.rs index adb64f4..6505e26 100644 --- a/src/backend/src/core/user_routines.rs +++ b/src/backend/src/core/user_routines.rs @@ -1,11 +1,12 @@ use crate::{ - domain::{user::InternalUser, user_prems::UserPermissions}, + auth::{gen_jwt, verify_password}, + domain::{api::LoginData, user::InternalUser, user_prems::UserPermissions}, infra::db, prelude::*, }; use std::sync::Arc; -use axum::http::StatusCode; +use axum::{extract::State, http::StatusCode}; use uuid::Uuid; use validator::Validate; @@ -16,6 +17,35 @@ use crate::{ state::AppState, }; +pub async fn login(state: Arc, login_data: LoginData) -> Result { + let user = db::user::get_by_username(&state.db_pool, &login_data.username) + .await + .map_err(|e| { + error!("Failed fetching user during login: {}", e); + return StatusCode::INTERNAL_SERVER_ERROR; + })?; + let user = match user { + Some(value) => value, + None => return Err(StatusCode::UNAUTHORIZED), + }; + + let verify = verify_password(&login_data.password, &user.password_hash).map_err(|e| { + error!("Failed to verify password hash: {}", e); + return StatusCode::INTERNAL_SERVER_ERROR; + })?; + + if !verify { + return Err(StatusCode::UNAUTHORIZED); + } + + let token = gen_jwt(user.username.clone()).map_err(|e| { + error!("Failed to generate JWT: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(token) +} + pub async fn create(state: Arc, new_user: NewUser) -> Result { debug!("create user started"); @@ -29,13 +59,28 @@ pub async fn create(state: Arc, new_user: NewUser) -> Result Result, StatusCode> { debug!(user_uuid = %uuid, "fetch user by uuid started"); - let user = db::user::get_safe_by_uuid(&state.db_pool, uuid.clone()) + + let mut user = match db::user::get_safe_by_uuid(&state.db_pool, uuid) .await .map_err(|e| { error!(error = %e, user_uuid = %uuid, "fetch user failed"); StatusCode::INTERNAL_SERVER_ERROR + })? { + Some(u) => u, + None => return Ok(None), + }; + + let perms = db::perms::get_by_uuid(&state.db_pool, uuid) + .await + .map_err(|e| { + error!(error = %e, user_uuid = %uuid, "fetch permissions failed"); + StatusCode::INTERNAL_SERVER_ERROR })?; - Ok(user) + + match perms { + Some(perms) => { + user.attach_permissions(perms); + } + None => { + error!(user_uuid = %uuid, "permissions missing for existing user"); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + } + + Ok(Some(user)) } pub async fn get_by_uuid(state: Arc, uuid: Uuid) -> anyhow::Result> { debug!(user_uuid = %uuid, "fetch internal user started"); + let mut user = match db::user::get_by_uuid(&state.db_pool, uuid) .await .context("failed to fetch user by uuid")? @@ -74,48 +142,80 @@ pub async fn get_by_uuid(state: Arc, uuid: Uuid) -> anyhow::Result return Ok(None), }; - let perms_exist = db::perms::exists_by_uuid(&state.db_pool, user.uuid) + let perms = db::perms::get_by_uuid(&state.db_pool, user.uuid) .await - .context("failed to check if user permissions exist")?; + .context("failed to fetch user permissions")?; - if perms_exist { - if let Some(perms) = db::perms::get_by_uuid(&state.db_pool, user.uuid) - .await - .context("failed to fetch user permissions")? - { + match perms { + Some(perms) => { user.attach_permissions(perms); } + None => {} } - let user = user; - Ok(Some(user)) } -pub async fn set_perms( +pub async fn get_safe_by_username( state: Arc, - user_perms: UserPermissions, -) -> Result<(), StatusCode> { - debug!(user_uuid = %user_perms.uuid, "assign permissions started"); - let exists = db::user::exists_by_uuid(&state.db_pool, user_perms.uuid) + username: &str, +) -> Result, StatusCode> { + debug!(%username, "fetch user by username started"); + + let mut user = match db::user::get_safe_by_username(&state.db_pool, username) .await .map_err(|e| { - error!(error = %e, user_uuid = %user_perms.uuid, "verify user exists failed"); + error!(error = %e, %username, "fetch user by username failed"); + StatusCode::INTERNAL_SERVER_ERROR + })? { + Some(u) => u, + None => return Ok(None), + }; + + let perms = db::perms::get_by_uuid(&state.db_pool, user.uuid) + .await + .map_err(|e| { + error!(error = %e, user_uuid = %user.uuid, "fetch permissions failed"); StatusCode::INTERNAL_SERVER_ERROR })?; - if !exists { - warn!(user_uuid = %user_perms.uuid, "assign permissions skipped for missing user"); - return Err(StatusCode::BAD_REQUEST); + match perms { + Some(perms) => { + user.attach_permissions(perms); + } + None => { + error!(user_uuid = %user.uuid, "permissions missing for existing user"); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } } - db::perms::create(&state.db_pool, user_perms) - .await - .map_err(|e| { - error!(error = %e, "create user permissions entry failed"); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - info!("user permissions assigned"); - Ok(()) + Ok(Some(user)) +} + +pub async fn get_by_username( + state: Arc, + username: &str, +) -> anyhow::Result> { + debug!(%username, "fetch internal user by username started"); + + let mut user = match db::user::get_by_username(&state.db_pool, username) + .await + .context("failed to fetch user by username")? + { + Some(u) => u, + None => return Ok(None), + }; + + let perms = db::perms::get_by_uuid(&state.db_pool, user.uuid) + .await + .context("failed to fetch user permissions")?; + + match perms { + Some(perms) => { + user.attach_permissions(perms); + } + None => {} + } + + Ok(Some(user)) } diff --git a/src/backend/src/domain/api.rs b/src/backend/src/domain/api.rs new file mode 100644 index 0000000..e0ae284 --- /dev/null +++ b/src/backend/src/domain/api.rs @@ -0,0 +1,14 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Deserialize)] +pub struct LoginData { + pub username: String, + pub password: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AuthClaims { + pub exp: i64, + pub iat: i64, + pub username: String, +} diff --git a/src/backend/src/domain/mod.rs b/src/backend/src/domain/mod.rs index 4cf35ef..3c5e8a0 100644 --- a/src/backend/src/domain/mod.rs +++ b/src/backend/src/domain/mod.rs @@ -1,3 +1,4 @@ +pub mod api; pub mod user; pub mod user_prems; pub mod validation; diff --git a/src/backend/src/domain/user.rs b/src/backend/src/domain/user.rs index 5ef34d9..b4b7b6b 100644 --- a/src/backend/src/domain/user.rs +++ b/src/backend/src/domain/user.rs @@ -5,7 +5,7 @@ use sqlx::prelude::FromRow; use uuid::Uuid; use validator::Validate; -use crate::domain::user_prems::{MemberUserPermissions, UserPermissions}; +use crate::domain::user_prems::UserPermissions; use crate::domain::validation; use crate::auth; @@ -25,7 +25,7 @@ pub struct NewUser { first_name: Option, #[validate(length(min = 1, max = 64))] last_name: Option, - permissions: Option, + permissions: UserPermissions, } #[derive(Debug, Clone, Deserialize)] @@ -36,7 +36,7 @@ pub struct InternalNewUser { pub password_hash: String, pub first_name: Option, pub last_name: Option, - pub permissions: Option, + pub permissions: UserPermissions, } #[derive(Debug, Clone, Deserialize, FromRow)] @@ -48,16 +48,18 @@ pub struct InternalUser { pub first_name: Option, pub last_name: Option, #[sqlx(skip)] - pub permissions: Option, + pub permissions: UserPermissions, } #[derive(Debug, Clone, Serialize, Deserialize, FromRow)] pub struct User { - uuid: Uuid, - username: String, - email: Option, - first_name: Option, - last_name: Option, + pub uuid: Uuid, + pub username: String, + pub email: Option, + pub first_name: Option, + pub last_name: Option, + #[sqlx(skip)] + pub permissions: UserPermissions, } #[derive(Debug)] @@ -91,6 +93,7 @@ impl From for User { email: value.email, first_name: value.first_name, last_name: value.last_name, + permissions: value.permissions.clone(), } } } @@ -105,6 +108,11 @@ impl Display for UserConversionError { impl InternalUser { pub fn attach_permissions(&mut self, permissions: UserPermissions) { - self.permissions = Some(MemberUserPermissions::from(permissions)); + self.permissions = UserPermissions::from(permissions); + } +} +impl User { + pub fn attach_permissions(&mut self, permissions: UserPermissions) { + self.permissions = UserPermissions::from(permissions); } } diff --git a/src/backend/src/domain/user_prems.rs b/src/backend/src/domain/user_prems.rs index 098f13e..b89817e 100644 --- a/src/backend/src/domain/user_prems.rs +++ b/src/backend/src/domain/user_prems.rs @@ -1,4 +1,4 @@ -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use sqlx::prelude::FromRow; use uuid::Uuid; @@ -9,27 +9,19 @@ pub enum UserActions { Login, } -#[derive(Debug, Clone, Deserialize, FromRow)] +#[derive(Debug, Clone, Deserialize, Serialize, FromRow)] pub struct UserPermissions { - pub uuid: Uuid, pub root: bool, pub manage_users: bool, pub login: bool, } -#[derive(Debug, Clone, Deserialize, FromRow)] -pub struct MemberUserPermissions { - pub root: bool, - pub manage_users: bool, - pub login: bool, -} - -impl From for MemberUserPermissions { - fn from(value: UserPermissions) -> Self { +impl Default for UserPermissions { + fn default() -> Self { Self { - root: value.root, - manage_users: value.manage_users, - login: value.login, + root: false, + manage_users: false, + login: false, } } } diff --git a/src/backend/src/infra/db/perms.rs b/src/backend/src/infra/db/perms.rs index 74a6181..de67532 100644 --- a/src/backend/src/infra/db/perms.rs +++ b/src/backend/src/infra/db/perms.rs @@ -4,8 +4,12 @@ use uuid::Uuid; use crate::{domain::user_prems::UserPermissions, prelude::*}; -pub async fn create(pool: &PgPool, new_perms: UserPermissions) -> Result { - debug!(user_uuid = %new_perms.uuid, "insert user permissions started"); +pub async fn create( + pool: &PgPool, + uuid: Uuid, + new_perms: UserPermissions, +) -> Result { + debug!(user_uuid = %uuid, "insert user permissions started"); let perms = sqlx::query_as::<_, UserPermissions>( r#" INSERT INTO user_permissions (uuid, root, manage_users, login) @@ -13,14 +17,14 @@ pub async fn create(pool: &PgPool, new_perms: UserPermissions) -> Result Result Result> { + debug!(username = %username, "fetch user by username started"); + let user = sqlx::query_as::<_, InternalUser>( + r#" + SELECT uuid, username, email, password_hash, first_name, last_name FROM users WHERE username = $1 + "#, + ) + .bind(username) + .fetch_optional(pool) + .await?; + + debug!(username = %username, "fetch user by username completed"); + Ok(user) +} + pub async fn get_safe_by_uuid(pool: &PgPool, uuid: Uuid) -> Result> { debug!(user_uuid = %uuid, "fetch safe user by uuid started"); let user = sqlx::query_as::<_, User>( @@ -58,6 +73,21 @@ pub async fn get_safe_by_uuid(pool: &PgPool, uuid: Uuid) -> Result> Ok(user) } +pub async fn get_safe_by_username(pool: &PgPool, username: &str) -> Result> { + debug!(username = %username, "fetch safe user by username started"); + let user = sqlx::query_as::<_, User>( + r#" + SELECT uuid, username, email, first_name, last_name FROM users WHERE uuid = $1 + "#, + ) + .bind(username) + .fetch_optional(pool) + .await?; + + debug!(username = %username, "fetch safe user by username completed"); + Ok(user) +} + pub async fn get_all(pool: &PgPool) -> Result> { debug!("fetch all internal users started"); let users = sqlx::query_as::<_, InternalUser>( @@ -108,3 +138,22 @@ pub async fn exists_by_uuid(pool: &PgPool, uuid: Uuid) -> Result { debug!(user_uuid = %uuid, "check user existence completed"); Ok(exists) } + +pub async fn exists_by_username(pool: &PgPool, username: &str) -> Result { + debug!(username = %username, "check user existence started"); + let exists = sqlx::query_scalar::<_, bool>( + r#" + SELECT EXISTS( + SELECT 1 + FROM users + WHERE uuid = $1 + ) + "#, + ) + .bind(username) + .fetch_one(pool) + .await?; + + debug!(username = %username, "check user existence completed"); + Ok(exists) +} diff --git a/src/backend/src/router/middleware.rs b/src/backend/src/router/middleware.rs index 69f73f3..e7a4977 100644 --- a/src/backend/src/router/middleware.rs +++ b/src/backend/src/router/middleware.rs @@ -1,21 +1,63 @@ +use crate::{core::user_routines, prelude::*}; +use std::sync::Arc; + use axum::{ - extract::Request, - http::{Method, header::AUTHORIZATION}, - middleware::Next, - response::IntoResponse, + extract::{Request, State}, + http::{self, Method, StatusCode, header::AUTHORIZATION}, + middleware::{Next, from_fn_with_state}, + response::Response, }; + use tower_http::cors::{Any, CorsLayer}; use tracing::debug; -pub async fn auth(request: Request, next: Next) -> impl IntoResponse { - let method = request.method().clone(); - let uri = request.uri().path().to_owned(); +use crate::{auth::verify_jwt, infra::db, state::AppState}; - debug!(%method, uri, "auth middleware started"); - let response = next.run(request).await; - let status = response.status(); - debug!(%method, uri, %status, "auth middleware completed"); - response +pub async fn auth( + State(state): State>, + mut req: Request, + next: Next, +) -> Result { + // 1) Extract Authorization header + let auth_header = req + .headers() + .get(http::header::AUTHORIZATION) + .ok_or(StatusCode::FORBIDDEN)?; // no header at all + + let auth_header = auth_header.to_str().map_err(|e| { + error!("Failed to parse Authorization header: {}", e); + StatusCode::FORBIDDEN + })?; + + // 2) Expect "Bearer " + let mut parts = auth_header.split_whitespace(); + let (scheme, token) = match (parts.next(), parts.next()) { + (Some(scheme), Some(token)) if scheme.eq_ignore_ascii_case("bearer") => (scheme, token), + _ => { + // either wrong scheme or missing token + return Err(StatusCode::UNAUTHORIZED); + } + }; + + // 3) Verify JWT + let token_data = verify_jwt(token.to_string())?; // verify_jwt(&str) -> Result, StatusCode> + let username = &token_data.claims.username; + + // 4) Load current user from DB + let current_user = match user_routines::get_by_username(state, username) + .await + .map_err(|e| { + error!("Error when fetching user via routine: {}", e); + return StatusCode::INTERNAL_SERVER_ERROR; + })? { + Some(user) => user, + None => return Err(StatusCode::INTERNAL_SERVER_ERROR), + }; + // 5) Attach user to request extensions so handlers can grab it + req.extensions_mut().insert(current_user); + + // 6) Continue down the stack + Ok(next.run(req).await) } pub fn cors() -> CorsLayer { diff --git a/src/backend/src/router/mod.rs b/src/backend/src/router/mod.rs index 52f705a..4cda22b 100644 --- a/src/backend/src/router/mod.rs +++ b/src/backend/src/router/mod.rs @@ -4,39 +4,54 @@ pub mod user_routes; use axum::{ Json, Router, http::StatusCode, + middleware::from_fn_with_state, routing::{get, post}, }; use serde_json::{Value, json}; use std::sync::Arc; -use tower::ServiceBuilder; +use tower::{Layer, ServiceBuilder}; use crate::prelude::*; use crate::state::AppState; +macro_rules! middleware { + (cors) => { + crate::router::middleware::cors() + }; + (cors_auth, $state:expr) => { + ( + crate::router::middleware::cors(), + axum::middleware::from_fn_with_state($state, crate::router::middleware::auth), + ) + }; +} + pub async fn init_router(app_state: Arc) -> Router { info!("router initialization started"); let router = Router::new() - .route( - "/api/ping", - get(ping).layer( - ServiceBuilder::new() - .layer(middleware::cors()) - .layer(axum::middleware::from_fn(middleware::auth)), - ), - ) + .route("/api/ping", get(ping).layer(middleware!(cors))) .route( "/api/users", post(user_routes::create) - .layer(ServiceBuilder::new().layer(middleware::cors())) + .layer(middleware!(cors_auth, app_state.clone())) .with_state(app_state.clone()) .get(user_routes::get_all) - .layer(ServiceBuilder::new().layer(middleware::cors())) + .layer(middleware!(cors_auth, app_state.clone())) .with_state(app_state.clone()), ) - .route("/api/users/{uuid}", get(user_routes::get_uuid)) - .layer(ServiceBuilder::new().layer(middleware::cors())) - .with_state(app_state.clone()); + .route( + "/api/users/{uuid}", + get(user_routes::get_uuid) + .layer(middleware!(cors_auth, app_state.clone())) + .with_state(app_state.clone()), + ) + .route( + "/api/login", + post(user_routes::login) + .layer(middleware!(cors)) + .with_state(app_state.clone()), + ); info!("router initialization completed"); router diff --git a/src/backend/src/router/user_routes.rs b/src/backend/src/router/user_routes.rs index 07283fa..afb15ac 100644 --- a/src/backend/src/router/user_routes.rs +++ b/src/backend/src/router/user_routes.rs @@ -1,4 +1,4 @@ -use crate::prelude::*; +use crate::{domain::api::LoginData, prelude::*}; use std::sync::Arc; use crate::{ @@ -40,3 +40,11 @@ pub async fn get_uuid( debug!("get user by uuid route completed"); Ok(Json(user)) } + +pub async fn login( + State(state): State>, + Json(login_data): Json, +) -> Result, StatusCode> { + let result = core::user_routines::login(state, login_data).await?; + Ok(Json(result)) +}