Compare commits

..

10 Commits

Author SHA1 Message Date
400b63462a Fixed middleware signature and added /me endpoint 2025-12-06 18:17:46 +01:00
0796a3403f Undo permesc start 2025-12-06 17:57:04 +01:00
20a09a672a Logout fixes and started perm_esc protection 2025-12-06 09:40:42 +01:00
9bccfcf7c3 Cargo fmt and clippy fix 2025-12-05 23:49:31 +01:00
c1fee82ba9 Working Login and Logout with Permissions 2025-12-05 23:43:29 +01:00
1ae9057baf Rebuild permissions system 2025-12-05 22:59:19 +01:00
2b31e36060 Pre pull fix 2025-12-05 22:17:24 +01:00
78b6215ffb Update README.md 2025-12-05 00:42:28 +01:00
Hector van der Aa
ca5b5bfa96 Update README.md 2025-12-05 00:35:13 +01:00
Hector van der Aa
1b3faeeefc Update README.md 2025-12-05 00:34:11 +01:00
14 changed files with 374 additions and 175 deletions

View File

@@ -8,7 +8,6 @@ A Rust-based Minecraft server manager.
- [Roadmap](#roadmap) - [Roadmap](#roadmap)
- [Contributing](#contributing) - [Contributing](#contributing)
- [Use of AI](#use-of-ai) - [Use of AI](#use-of-ai)
- [License](#license)
## Background ## Background
After a forced restart corrupted my previous server manager, [Crafty 4](https://gitlab.com/crafty-controller/crafty-4), I started looking for alternatives that were: After a forced restart corrupted my previous server manager, [Crafty 4](https://gitlab.com/crafty-controller/crafty-4), I started looking for alternatives that were:
@@ -47,5 +46,10 @@ If you believe you can provide meaningful help, feel free to reach out directly:
## Use of AI ## Use of AI
In the scope of this project AI is currently only being used to standardize command line output with the `info!, warn!, error!` and `debug!` macros from tracing. In the scope of this project AI is currently only being used to standardize command line output with the `info!, warn!, error!` and `debug!` macros from tracing.
## License ---
[PolyForm Noncommercial License 1.0.0](LICENSE)
<div align="center">
<a href="./LICENSE">PolyForm Noncommercial License</a> • h3cx
Built on coffee, late nights, and a fully up-to-date Arch install (btw).
</div>

View File

@@ -22,8 +22,10 @@ tower = { version = "0.5.2", features = ["tokio", "tracing"] }
tower-http = { version = "0.6.7", features = ["cors"] } tower-http = { version = "0.6.7", features = ["cors"] }
tracing = { version = "0.1.43", features = ["max_level_debug"] } tracing = { version = "0.1.43", features = ["max_level_debug"] }
tracing-subscriber = "0.3.22" tracing-subscriber = "0.3.22"
uuid = { version = "1.18.1", features = ["v4", "serde"] } uuid = { version = "1.19.0", features = ["v4", "serde"] }
validator = { version = "0.20.0", features = ["derive"] } validator = { version = "0.20.0", features = ["derive"] }
mineguard = {path = "../../../MineGuard/"}
axum-extra = { version = "0.12.2", features = ["cookie"] }
[build-dependencies] [build-dependencies]
chrono = "0.4.42" chrono = "0.4.42"

View File

@@ -1,6 +1,5 @@
CREATE TABLE user_permissions( CREATE TABLE user_permissions(
uuid UUID PRIMARY KEY, uuid UUID PRIMARY KEY,
root BOOL NOT NULL, root BOOL NOT NULL,
manage_users BOOL NOT NULL, permissions JSON NOT NULL
login BOOL NOT NULL
); );

View File

@@ -1,12 +1,11 @@
use crate::prelude::*; use crate::prelude::*;
use std::i64;
use anyhow::Result; use anyhow::Result;
use argon2::{ use argon2::{
Argon2, PasswordHash, PasswordVerifier, Argon2, PasswordHash, PasswordVerifier,
password_hash::{PasswordHasher, SaltString, rand_core::OsRng}, password_hash::{PasswordHasher, SaltString, rand_core::OsRng},
}; };
use axum::{extract::State, http::StatusCode}; use axum::http::StatusCode;
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use jsonwebtoken::{DecodingKey, EncodingKey, Header, TokenData, Validation, decode, encode}; use jsonwebtoken::{DecodingKey, EncodingKey, Header, TokenData, Validation, decode, encode};
@@ -26,8 +25,8 @@ pub fn verify_password(password: &str, password_hash: &str) -> Result<bool, pass
let parsed_hash = PasswordHash::new(password_hash)?; let parsed_hash = PasswordHash::new(password_hash)?;
let argon2 = Argon2::default(); let argon2 = Argon2::default();
match argon2.verify_password(password.as_bytes(), &parsed_hash) { match argon2.verify_password(password.as_bytes(), &parsed_hash) {
Ok(_) => return Ok(true), Ok(_) => Ok(true),
Err(password_hash::Error::Password) => return Ok(false), Err(password_hash::Error::Password) => Ok(false),
Err(e) => Err(e), Err(e) => Err(e),
} }
} }
@@ -37,8 +36,8 @@ pub fn gen_jwt(username: String) -> Result<String, StatusCode> {
let secret: String = "verysafestring".to_string(); let secret: String = "verysafestring".to_string();
let now = Utc::now(); let now = Utc::now();
let expire: chrono::TimeDelta = Duration::hours(24); let expire: chrono::TimeDelta = Duration::hours(24);
let exp = (now + expire).timestamp() as i64; let exp = (now + expire).timestamp();
let iat = now.timestamp() as i64; let iat = now.timestamp();
let claim = AuthClaims { iat, exp, username }; let claim = AuthClaims { iat, exp, username };
encode( encode(
@@ -48,7 +47,7 @@ pub fn gen_jwt(username: String) -> Result<String, StatusCode> {
) )
.map_err(|e| { .map_err(|e| {
error!(error = %e, username = claim.username, "create jwt failed"); error!(error = %e, username = claim.username, "create jwt failed");
return StatusCode::INTERNAL_SERVER_ERROR; StatusCode::INTERNAL_SERVER_ERROR
}) })
} }
@@ -61,7 +60,7 @@ pub fn verify_jwt(token: String) -> Result<TokenData<AuthClaims>, StatusCode> {
) )
.map_err(|e| { .map_err(|e| {
error!(error = %e, "verify jwt failed"); error!(error = %e, "verify jwt failed");
return StatusCode::INTERNAL_SERVER_ERROR; StatusCode::INTERNAL_SERVER_ERROR
}); });
result result
} }

View File

@@ -1,7 +1,11 @@
use axum::http::Method; use axum::{
Json, RequestExt,
extract::{MatchedPath, Request, State},
http::{Method, StatusCode},
};
use std::collections::HashMap; use std::collections::HashMap;
use crate::domain::user_prems::UserActions; use crate::domain::user_prems::{UserActions, UserPermissions};
#[derive(Debug, Hash, Clone, PartialEq, Eq)] #[derive(Debug, Hash, Clone, PartialEq, Eq)]
pub struct RouteKey { pub struct RouteKey {
@@ -12,7 +16,7 @@ pub struct RouteKey {
#[derive(Debug)] #[derive(Debug)]
pub struct AppCfg { pub struct AppCfg {
pub db_path: String, pub db_path: String,
pub route_perms: HashMap<RouteKey, Vec<UserActions>>, pub route_perms: HashMap<RouteKey, UserPermissions>,
} }
impl AppCfg { impl AppCfg {
@@ -27,6 +31,7 @@ impl AppCfg {
&mut self, &mut self,
method: Method, method: Method,
path: impl Into<String>, path: impl Into<String>,
root: bool,
perms: Vec<UserActions>, perms: Vec<UserActions>,
) { ) {
let key = RouteKey { let key = RouteKey {
@@ -34,37 +39,46 @@ impl AppCfg {
path: path.into(), path: path.into(),
}; };
self.route_perms.insert(key, perms); let user_perms = UserPermissions {
root,
permissions: perms.into_iter().collect(), // Vec → HashSet
};
self.route_perms.insert(key, user_perms);
} }
pub fn get_route_perms(&self, method: &Method, path: &str) -> Option<&Vec<UserActions>> { pub fn get_route_perms(&self, method: &Method, path: &str) -> Option<UserPermissions> {
let key = RouteKey { let key = RouteKey {
method: method.clone(), method: method.clone(),
path: path.to_string(), path: path.to_string(),
}; };
self.route_perms.get(&key) let perm = match self.route_perms.get(&key) {
Some(val) => val.clone(),
None => return None,
};
Some(perm)
} }
pub fn route_allows(&self, method: &Method, path: &str, user_perms: &[UserActions]) -> bool { pub async fn route_allows(
if user_perms.contains(&UserActions::Root) { &self,
return true; method: &Method,
} path: &str,
user_perms: UserPermissions,
let key = RouteKey { ) -> Result<bool, StatusCode> {
method: method.clone(), let req_perms = match self.get_route_perms(method, path) {
path: path.to_string(), Some(val) => val,
None => return Ok(false),
}; };
let required = match self.route_perms.get(&key) { if req_perms.root {
Some(perms) => perms, return Ok(true);
None => return true, // no perms required → allow
};
if required.contains(&UserActions::Root) {
return false;
} }
required.iter().all(|p| user_perms.contains(p)) Ok(req_perms
.permissions
.iter()
.all(|action| user_perms.permissions.contains(action)))
} }
} }

View File

@@ -1,12 +1,16 @@
use crate::{ use crate::{
auth::{gen_jwt, verify_password}, auth::{gen_jwt, verify_password},
domain::{api::LoginData, user::InternalUser, user_prems::UserPermissions}, domain::{
api::LoginData,
user::InternalUser,
user_prems::{ExtUserPermissions, UserPermissions},
},
infra::db, infra::db,
prelude::*, prelude::*,
}; };
use std::sync::Arc; use std::sync::Arc;
use axum::{extract::State, http::StatusCode}; use axum::http::StatusCode;
use uuid::Uuid; use uuid::Uuid;
use validator::Validate; use validator::Validate;
@@ -17,14 +21,17 @@ use crate::{
state::AppState, state::AppState,
}; };
pub async fn login(state: Arc<AppState>, login_data: LoginData) -> Result<String, StatusCode> { pub async fn login(
state: Arc<AppState>,
login_data: LoginData,
) -> Result<(String, User), StatusCode> {
debug!(username = login_data.username.as_str(), "login started"); debug!(username = login_data.username.as_str(), "login started");
let user = db::user::get_by_username(&state.db_pool, &login_data.username) let user = db::user::get_by_username(&state.db_pool, &login_data.username)
.await .await
.map_err(|e| { .map_err(|e| {
error!(error = %e, username = login_data.username.as_str(), "fetch user during login failed"); error!(error = %e, username = login_data.username.as_str(), "fetch user during login failed");
return StatusCode::INTERNAL_SERVER_ERROR; StatusCode::INTERNAL_SERVER_ERROR
})?; })?;
let user = match user { let user = match user {
Some(value) => value, Some(value) => value,
@@ -33,7 +40,7 @@ pub async fn login(state: Arc<AppState>, login_data: LoginData) -> Result<String
let verify = verify_password(&login_data.password, &user.password_hash).map_err(|e| { let verify = verify_password(&login_data.password, &user.password_hash).map_err(|e| {
error!(error = %e, username = login_data.username.as_str(), "verify password hash failed"); error!(error = %e, username = login_data.username.as_str(), "verify password hash failed");
return StatusCode::INTERNAL_SERVER_ERROR; StatusCode::INTERNAL_SERVER_ERROR
})?; })?;
if !verify { if !verify {
@@ -45,7 +52,7 @@ pub async fn login(state: Arc<AppState>, login_data: LoginData) -> Result<String
StatusCode::INTERNAL_SERVER_ERROR StatusCode::INTERNAL_SERVER_ERROR
})?; })?;
Ok(token) Ok((token, User::from(user)))
} }
pub async fn create(state: Arc<AppState>, new_user: NewUser) -> Result<User, StatusCode> { pub async fn create(state: Arc<AppState>, new_user: NewUser) -> Result<User, StatusCode> {
@@ -68,16 +75,12 @@ pub async fn create(state: Arc<AppState>, new_user: NewUser) -> Result<User, Sta
StatusCode::INTERNAL_SERVER_ERROR StatusCode::INTERNAL_SERVER_ERROR
})?; })?;
let perms = db::perms::create( let perms = db::perms::create(&state.db_pool, created_user.uuid, internal.permissions)
&state.db_pool, .await
created_user.uuid.clone(), .map_err(|e| {
internal.permissions, error!(error = %e, "create user permissions entry failed");
) StatusCode::INTERNAL_SERVER_ERROR
.await })?;
.map_err(|e| {
error!(error = %e, "create user permissions entry failed");
StatusCode::INTERNAL_SERVER_ERROR
})?;
created_user.attach_permissions(perms); created_user.attach_permissions(perms);
@@ -89,11 +92,24 @@ pub async fn create(state: Arc<AppState>, new_user: NewUser) -> Result<User, Sta
pub async fn get_all(state: Arc<AppState>) -> Result<Vec<User>, StatusCode> { pub async fn get_all(state: Arc<AppState>) -> Result<Vec<User>, StatusCode> {
debug!("fetch all users started"); debug!("fetch all users started");
let users = db::user::get_safe_all(&state.db_pool).await.map_err(|e| { let mut users = db::user::get_safe_all(&state.db_pool).await.map_err(|e| {
error!(error = %e, "fetch all users failed"); error!(error = %e, "fetch all users failed");
StatusCode::INTERNAL_SERVER_ERROR StatusCode::INTERNAL_SERVER_ERROR
})?; })?;
let perms = db::perms::get_all(&state.db_pool).await.map_err(|e| {
error!(error = %e, "fetch all permissions failed");
StatusCode::INTERNAL_SERVER_ERROR
})?;
users.iter_mut().for_each(|u| {
let u_perms = match perms.iter().find(|p| p.uuid == u.uuid) {
Some(val) => UserPermissions::from(val.clone()),
None => UserPermissions::new(),
};
u.attach_permissions(u_perms);
});
Ok(users) Ok(users)
} }
@@ -148,11 +164,8 @@ pub async fn get_by_uuid(state: Arc<AppState>, uuid: Uuid) -> anyhow::Result<Opt
.await .await
.context("failed to fetch user permissions")?; .context("failed to fetch user permissions")?;
match perms { if let Some(perms) = perms {
Some(perms) => { user.attach_permissions(perms);
user.attach_permissions(perms);
}
None => {}
} }
Ok(Some(user)) Ok(Some(user))
@@ -212,11 +225,8 @@ pub async fn get_by_username(
.await .await
.context("failed to fetch user permissions")?; .context("failed to fetch user permissions")?;
match perms { if let Some(perms) = perms {
Some(perms) => { user.attach_permissions(perms);
user.attach_permissions(perms);
}
None => {}
} }
Ok(Some(user)) Ok(Some(user))

View File

@@ -5,7 +5,7 @@ use sqlx::prelude::FromRow;
use uuid::Uuid; use uuid::Uuid;
use validator::Validate; use validator::Validate;
use crate::domain::user_prems::UserPermissions; use crate::domain::user_prems::{ExtUserPermissions, UserPermissions};
use crate::domain::validation; use crate::domain::validation;
use crate::auth; use crate::auth;
@@ -71,13 +71,13 @@ impl TryFrom<NewUser> for InternalNewUser {
type Error = UserConversionError; type Error = UserConversionError;
fn try_from(value: NewUser) -> Result<Self, Self::Error> { fn try_from(value: NewUser) -> Result<Self, Self::Error> {
let password_hash = let password_hash =
auth::hash_password(&value.password).map_err(|e| UserConversionError::HashFailed(e))?; auth::hash_password(&value.password).map_err(UserConversionError::HashFailed)?;
let uuid = Uuid::new_v4(); let uuid = Uuid::new_v4();
Ok(Self { Ok(Self {
uuid: uuid, uuid,
username: value.username, username: value.username,
email: value.email, email: value.email,
password_hash: password_hash, password_hash,
first_name: value.first_name, first_name: value.first_name,
last_name: value.last_name, last_name: value.last_name,
permissions: value.permissions.clone(), permissions: value.permissions.clone(),
@@ -108,12 +108,12 @@ impl Display for UserConversionError {
impl InternalUser { impl InternalUser {
pub fn attach_permissions(&mut self, permissions: UserPermissions) { pub fn attach_permissions(&mut self, permissions: UserPermissions) {
self.permissions = UserPermissions::from(permissions); self.permissions = permissions;
} }
} }
impl User { impl User {
pub fn attach_permissions(&mut self, permissions: UserPermissions) { pub fn attach_permissions(&mut self, permissions: UserPermissions) {
self.permissions = UserPermissions::from(permissions); self.permissions = permissions;
} }
} }

View File

@@ -1,37 +1,88 @@
use std::collections::HashSet;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::prelude::FromRow; use sqlx::{prelude::FromRow, types::Json};
use uuid::Uuid; use uuid::Uuid;
#[derive(Debug, Eq, PartialEq)] #[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, Serialize, Deserialize)]
pub enum UserActions { pub enum UserActions {
Root,
ManageUsers, ManageUsers,
Login,
} }
#[derive(Debug, Clone, Deserialize, Serialize, FromRow)] #[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct UserPermissions { pub struct UserPermissions {
pub root: bool, pub root: bool,
pub manage_users: bool, pub permissions: HashSet<UserActions>,
pub login: bool,
} }
impl Default for UserPermissions { #[derive(Debug, Clone, Deserialize, Serialize, Default)]
fn default() -> Self { pub struct ExtUserPermissions {
pub uuid: Uuid,
pub root: bool,
pub permissions: HashSet<UserActions>,
}
#[derive(Debug, Clone, FromRow)]
pub struct UserPermissionsRow {
pub root: bool,
pub permissions: Json<HashSet<UserActions>>,
}
#[derive(Debug, Clone, FromRow)]
pub struct ExtUserPermissionsRow {
pub uuid: Uuid,
pub root: bool,
pub permissions: Json<HashSet<UserActions>>,
}
impl From<UserPermissions> for UserPermissionsRow {
fn from(value: UserPermissions) -> Self {
Self { Self {
root: false, root: value.root,
manage_users: false, permissions: Json(value.permissions),
login: false, }
}
}
impl From<UserPermissionsRow> for UserPermissions {
fn from(value: UserPermissionsRow) -> Self {
Self {
root: value.root,
permissions: value.permissions.0,
}
}
}
impl From<ExtUserPermissionsRow> for ExtUserPermissions {
fn from(value: ExtUserPermissionsRow) -> Self {
Self {
uuid: value.uuid,
root: value.root,
permissions: value.permissions.0,
}
}
}
impl From<ExtUserPermissions> for UserPermissions {
fn from(value: ExtUserPermissions) -> Self {
Self {
root: value.root,
permissions: value.permissions,
} }
} }
} }
impl UserPermissions { impl UserPermissions {
pub fn new() -> Self {
Self {
root: false,
permissions: HashSet::new(),
}
}
pub fn root() -> Self { pub fn root() -> Self {
Self { Self {
root: true, root: true,
manage_users: true, permissions: HashSet::new(),
login: true,
} }
} }
} }

View File

@@ -2,7 +2,12 @@ use anyhow::Result;
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use crate::{domain::user_prems::UserPermissions, prelude::*}; use crate::{
domain::user_prems::{
ExtUserPermissions, ExtUserPermissionsRow, UserPermissions, UserPermissionsRow,
},
prelude::*,
};
pub async fn create( pub async fn create(
pool: &PgPool, pool: &PgPool,
@@ -10,29 +15,29 @@ pub async fn create(
new_perms: UserPermissions, new_perms: UserPermissions,
) -> Result<UserPermissions> { ) -> Result<UserPermissions> {
debug!(user_uuid = %uuid, "insert user permissions started"); debug!(user_uuid = %uuid, "insert user permissions started");
let perms = sqlx::query_as::<_, UserPermissions>( let insert = UserPermissionsRow::from(new_perms);
let perms = sqlx::query_as::<_, UserPermissionsRow>(
r#" r#"
INSERT INTO user_permissions (uuid, root, manage_users, login) INSERT INTO user_permissions (uuid, root, permissions)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3)
RETURNING uuid, root, manage_users, login RETURNING root, permissions
"#, "#,
) )
.bind(uuid) .bind(uuid)
.bind(new_perms.root) .bind(insert.root)
.bind(new_perms.manage_users) .bind(insert.permissions)
.bind(new_perms.login)
.fetch_one(pool) .fetch_one(pool)
.await?; .await?;
debug!(user_uuid = %uuid, "insert user permissions completed"); debug!(user_uuid = %uuid, "insert user permissions completed");
Ok(perms) Ok(UserPermissions::from(perms))
} }
pub async fn get_by_uuid(pool: &PgPool, uuid: Uuid) -> Result<Option<UserPermissions>> { pub async fn get_by_uuid(pool: &PgPool, uuid: Uuid) -> Result<Option<UserPermissions>> {
debug!(user_uuid = %uuid, "fetch user permissions by uuid started"); debug!(user_uuid = %uuid, "fetch user permissions by uuid started");
let perms = sqlx::query_as::<_, UserPermissions>( let perms = sqlx::query_as::<_, UserPermissionsRow>(
r#" r#"
SELECT uuid, root, manage_users, login SELECT uuid, root, permissions
FROM user_permissions FROM user_permissions
WHERE uuid = $1 WHERE uuid = $1
"#, "#,
@@ -42,7 +47,10 @@ pub async fn get_by_uuid(pool: &PgPool, uuid: Uuid) -> Result<Option<UserPermiss
.await?; .await?;
debug!(user_uuid = %uuid, "fetch user permissions by uuid completed"); debug!(user_uuid = %uuid, "fetch user permissions by uuid completed");
Ok(perms) match perms {
Some(val) => Ok(Some(UserPermissions::from(val))),
None => Ok(None),
}
} }
pub async fn exists_by_uuid(pool: &PgPool, uuid: Uuid) -> Result<bool> { pub async fn exists_by_uuid(pool: &PgPool, uuid: Uuid) -> Result<bool> {
@@ -52,7 +60,7 @@ pub async fn exists_by_uuid(pool: &PgPool, uuid: Uuid) -> Result<bool> {
SELECT EXISTS( SELECT EXISTS(
SELECT 1 SELECT 1
FROM user_permissions FROM user_permissions
WHERE uuis = $1 WHERE uuid = $1
) )
"#, "#,
) )
@@ -63,3 +71,23 @@ pub async fn exists_by_uuid(pool: &PgPool, uuid: Uuid) -> Result<bool> {
debug!(user_uuid = %uuid, "check user permissions existence completed"); debug!(user_uuid = %uuid, "check user permissions existence completed");
Ok(exists) Ok(exists)
} }
pub async fn get_all(pool: &PgPool) -> Result<Vec<ExtUserPermissions>> {
debug!("fetch all internal users started");
let users = sqlx::query_as::<_, ExtUserPermissionsRow>(
r#"
SELECT uuid, root, permissions
FROM user_permissions
"#,
)
.fetch_all(pool)
.await?;
let clean = users
.iter()
.map(|v| ExtUserPermissions::from(v.clone()))
.collect();
debug!("fetch all internal users completed");
Ok(clean)
}

View File

@@ -8,7 +8,7 @@ use rustymine_daemon::{
router, router,
state::{AppState, check_root}, state::{AppState, check_root},
}; };
use tracing::{Level, debug, info}; use tracing::{Level, info};
pub const APP_NAME: &str = env!("CARGO_PKG_NAME"); pub const APP_NAME: &str = env!("CARGO_PKG_NAME");
pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
@@ -63,8 +63,14 @@ async fn main() -> Result<()> {
route_perms: HashMap::new(), route_perms: HashMap::new(),
}; };
config.insert_route_perms(Method::GET, "/api/login", vec![UserActions::Login]); config.insert_route_perms(Method::GET, "/api/users", false, vec![]);
config.insert_route_perms(Method::GET, "/api/users", vec![UserActions::Login]); config.insert_route_perms(Method::GET, "/api/users/{uuid}", false, vec![]);
config.insert_route_perms(
Method::POST,
"/api/users",
false,
vec![UserActions::ManageUsers],
);
let state = Arc::new(AppState::new(config).await); let state = Arc::new(AppState::new(config).await);
check_root(state.clone()).await; check_root(state.clone()).await;

View File

@@ -5,85 +5,96 @@ use axum::{
Extension, Extension,
extract::{MatchedPath, Request, State}, extract::{MatchedPath, Request, State},
http::{self, Method, StatusCode, header::AUTHORIZATION}, http::{self, Method, StatusCode, header::AUTHORIZATION},
middleware::{Next, from_fn_with_state}, middleware::Next,
response::Response, response::Response,
}; };
use axum_extra::extract::CookieJar;
use tower_http::cors::{Any, CorsLayer}; use tower_http::cors::{Any, CorsLayer};
use tracing::debug; use tracing::debug;
use crate::{auth::verify_jwt, infra::db, state::AppState}; use crate::{auth::verify_jwt, state::AppState};
pub async fn auth( pub async fn auth(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
jar: CookieJar,
mut req: Request, mut req: Request,
next: Next, next: Next,
) -> Result<Response, StatusCode> { ) -> Result<Response, StatusCode> {
let request_method = req.method().clone(); let method = req.method().clone();
let request_path = req.uri().path().to_string(); let path = req.uri().path().to_string();
debug!(method = ?request_method, path = request_path, "authenticate request started"); debug!(?method, path, "authenticate request started");
// 1) Extract Authorization header // 1) Try JWT from cookie first
let auth_header = req let token_from_cookie = jar
.headers() .get("auth_token")
.get(http::header::AUTHORIZATION) .map(|cookie| cookie.value().to_owned());
.ok_or(StatusCode::FORBIDDEN)?; // no header at all
let auth_header = auth_header.to_str().map_err(|e| { // 2) If no cookie, fall back to Authorization: Bearer ...
error!(error = %e, method = ?request_method, path = request_path, "authorization header parse failed"); let token = match token_from_cookie {
StatusCode::FORBIDDEN Some(t) => t,
})?; None => {
let auth_header = req
.headers()
.get(http::header::AUTHORIZATION)
.ok_or(StatusCode::FORBIDDEN)?;
// 2) Expect "Bearer <token>" let auth_header = auth_header.to_str().map_err(|e| {
let mut parts = auth_header.split_whitespace(); error!(
let (scheme, token) = match (parts.next(), parts.next()) { error = %e,
(Some(scheme), Some(token)) if scheme.eq_ignore_ascii_case("bearer") => (scheme, token), ?method,
_ => { path,
// either wrong scheme or missing token "authorization header parse failed"
warn!(method = ?request_method, path = request_path, "authorization header missing bearer token"); );
return Err(StatusCode::UNAUTHORIZED); StatusCode::FORBIDDEN
})?;
let mut parts = auth_header.split_whitespace();
match (parts.next(), parts.next()) {
(Some(scheme), Some(token)) if scheme.eq_ignore_ascii_case("bearer") => {
token.to_owned()
}
_ => {
warn!(?method, path, "authorization header missing bearer token");
return Err(StatusCode::UNAUTHORIZED);
}
}
} }
}; };
// 3) Verify JWT // 3) Verify JWT
let token_data = verify_jwt(token.to_string())?; // verify_jwt(&str) -> Result<TokenData<AuthClaims>, StatusCode> let token_data = verify_jwt(token).map_err(|e| {
warn!(error = %e, ?method, path, "invalid jwt");
StatusCode::UNAUTHORIZED
})?;
let username = &token_data.claims.username; let username = &token_data.claims.username;
// 4) Load current user from DB // 4) Load user from DB
let current_user = match user_routines::get_by_username(state, username) let current_user = user_routines::get_by_username(state, username)
.await .await
.map_err(|e| { .map_err(|e| {
error!(error = %e, method = ?request_method, path = request_path, username, "fetch user for auth failed"); error!(
return StatusCode::INTERNAL_SERVER_ERROR; error = %e,
})? { ?method,
Some(user) => user, path,
None => { username,
error!(method = ?request_method, path = request_path, username, "authenticated user missing in database"); "fetch user for auth failed"
return Err(StatusCode::INTERNAL_SERVER_ERROR); );
} StatusCode::INTERNAL_SERVER_ERROR
}; })?
// 5) Attach user to request extensions so handlers can grab it .ok_or_else(|| {
error!(
?method,
path, username, "authenticated user missing in database"
);
StatusCode::INTERNAL_SERVER_ERROR
})?;
// 5) Attach user to request extensions
req.extensions_mut().insert(current_user); req.extensions_mut().insert(current_user);
// 6) Continue down the stack // 6) Continue the chain
Ok(next.run(req).await)
}
pub async fn perms(
State(state): State<Arc<AppState>>,
Extension(user): Extension<InternalUser>,
mut req: Request,
next: Next,
) -> Result<Response, StatusCode> {
let method: Method = req.method().clone();
let path = req
.extensions()
.get::<MatchedPath>()
.map(|p| p.as_str().to_string())
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(next.run(req).await) Ok(next.run(req).await)
} }
@@ -94,3 +105,37 @@ pub fn cors() -> CorsLayer {
.allow_methods([Method::GET, Method::POST]) .allow_methods([Method::GET, Method::POST])
.allow_headers([AUTHORIZATION]) .allow_headers([AUTHORIZATION])
} }
pub async fn permissions(
State(state): State<Arc<AppState>>,
Extension(user): Extension<InternalUser>,
req: Request,
next: Next,
) -> Result<Response, StatusCode> {
let request_method = req.method().clone();
let request_path = req.uri().path().to_string();
debug!(method = ?request_method, path = request_path, "permissions request started");
debug!("Calling user {}", user.username.clone());
if user.permissions.root {
return Ok(next.run(req).await);
}
let method = req.method();
let path = req
.extensions()
.get::<MatchedPath>()
.map(|p| p.as_str())
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
match state
.config
.route_allows(method, path, user.permissions.clone())
.await
{
Ok(true) => Ok(next.run(req).await),
_ => Err(StatusCode::UNAUTHORIZED),
}
}

View File

@@ -4,12 +4,10 @@ pub mod user_routes;
use axum::{ use axum::{
Json, Router, Json, Router,
http::StatusCode, http::StatusCode,
middleware::from_fn_with_state,
routing::{get, post}, routing::{get, post},
}; };
use serde_json::{Value, json}; use serde_json::{Value, json};
use std::sync::Arc; use std::sync::Arc;
use tower::{Layer, ServiceBuilder};
use crate::prelude::*; use crate::prelude::*;
use crate::state::AppState; use crate::state::AppState;
@@ -24,6 +22,13 @@ macro_rules! middleware {
axum::middleware::from_fn_with_state($state, crate::router::middleware::auth), axum::middleware::from_fn_with_state($state, crate::router::middleware::auth),
) )
}; };
(cors_auth_perms, $state:expr) => {
(
crate::router::middleware::cors(),
axum::middleware::from_fn_with_state($state, crate::router::middleware::auth),
axum::middleware::from_fn_with_state($state, crate::router::middleware::permissions),
)
};
} }
pub async fn init_router(app_state: Arc<AppState>) -> Router { pub async fn init_router(app_state: Arc<AppState>) -> Router {
@@ -34,16 +39,16 @@ pub async fn init_router(app_state: Arc<AppState>) -> Router {
.route( .route(
"/api/users", "/api/users",
post(user_routes::create) post(user_routes::create)
.layer(middleware!(cors_auth, app_state.clone())) .layer(middleware!(cors_auth_perms, app_state.clone()))
.with_state(app_state.clone()) .with_state(app_state.clone())
.get(user_routes::get_all) .get(user_routes::get_all)
.layer(middleware!(cors_auth, app_state.clone())) .layer(middleware!(cors_auth_perms, app_state.clone()))
.with_state(app_state.clone()), .with_state(app_state.clone()),
) )
.route( .route(
"/api/users/{uuid}", "/api/users/{uuid}",
get(user_routes::get_uuid) get(user_routes::get_uuid)
.layer(middleware!(cors_auth, app_state.clone())) .layer(middleware!(cors_auth_perms, app_state.clone()))
.with_state(app_state.clone()), .with_state(app_state.clone()),
) )
.route( .route(
@@ -51,6 +56,18 @@ pub async fn init_router(app_state: Arc<AppState>) -> Router {
post(user_routes::login) post(user_routes::login)
.layer(middleware!(cors)) .layer(middleware!(cors))
.with_state(app_state.clone()), .with_state(app_state.clone()),
)
.route(
"/api/logout",
post(user_routes::logout)
.layer(middleware!(cors_auth, app_state.clone()))
.with_state(app_state.clone()),
)
.route(
"/api/me",
get(user_routes::me)
.layer(middleware!(cors_auth, app_state.clone()))
.with_state(app_state.clone()),
); );
info!("router initialization completed"); info!("router initialization completed");

View File

@@ -1,4 +1,7 @@
use crate::{domain::api::LoginData, prelude::*}; use crate::{
domain::{api::LoginData, user::InternalUser},
prelude::*,
};
use std::sync::Arc; use std::sync::Arc;
use crate::{ use crate::{
@@ -8,10 +11,14 @@ use crate::{
}; };
use anyhow::Result; use anyhow::Result;
use axum::{ use axum::{
Json, Extension, Json,
extract::{Path, State}, extract::{Path, State},
http::StatusCode, http::StatusCode,
}; };
use axum_extra::extract::{
CookieJar,
cookie::{Cookie, SameSite},
};
use uuid::Uuid; use uuid::Uuid;
pub async fn create( pub async fn create(
@@ -43,8 +50,35 @@ pub async fn get_uuid(
pub async fn login( pub async fn login(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
jar: CookieJar,
Json(login_data): Json<LoginData>, Json(login_data): Json<LoginData>,
) -> Result<Json<String>, StatusCode> { ) -> Result<(CookieJar, Json<User>), StatusCode> {
let result = core::user_routines::login(state, login_data).await?; let (jwt, user) = core::user_routines::login(state, login_data).await?;
Ok(Json(result))
let cookie = Cookie::build(("auth_token", jwt))
.http_only(true)
.secure(false)
.same_site(SameSite::None)
.path("/")
.build();
let jar = jar.add(cookie);
Ok((jar, Json(user)))
}
pub async fn logout(jar: CookieJar) -> Result<CookieJar, StatusCode> {
let cookie = Cookie::build(("auth_token", ""))
.path("/")
.http_only(true)
.build();
let jar = jar.remove(cookie);
Ok(jar)
}
pub async fn me(Extension(user): Extension<InternalUser>) -> Result<Json<User>, StatusCode> {
let clean = User::from(user);
Ok(Json(clean))
} }

View File

@@ -1,13 +1,6 @@
use std::{collections::HashMap, process::exit, sync::Arc}; use std::{process::exit, sync::Arc};
use crate::{ use crate::{core, domain::user::NewUser, prelude::*};
core,
domain::{
user::{InternalNewUser, NewUser},
user_prems::UserActions,
},
prelude::*,
};
use sqlx::PgPool; use sqlx::PgPool;
@@ -40,10 +33,7 @@ impl AppState {
.unwrap(); .unwrap();
info!("database ready after connect and migrate"); info!("database ready after connect and migrate");
Self { Self { db_pool, config }
db_pool: db_pool,
config: config,
}
} }
} }