Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
This commit is contained in:
2025-06-05 14:24:48 +01:00
parent 050f25bba9
commit ed958a8ed0
41 changed files with 1885 additions and 480 deletions

View File

@@ -41,6 +41,15 @@ pub struct AutheliaConfig {
pub user_database: PathBuf,
}
#[derive(Clone, Deserialize)]
pub struct PostgresqlConfig {
pub user: String,
pub password: String,
pub host: String,
pub port: u16,
pub database: String,
}
#[derive(Clone, Deserialize)]
pub struct RedisConfig {
pub host: String,
@@ -54,6 +63,7 @@ pub struct Config {
pub server: ServerConfig,
pub oauth: OAuthConfig,
pub authelia: AutheliaConfig,
pub postgresql: PostgresqlConfig,
pub redis: RedisConfig,
}

View File

@@ -11,7 +11,7 @@ use axum::serve;
use clap::Parser;
use log::info;
use log4rs::config::Deserializers;
use std::net::SocketAddr;
use std::{error::Error, net::SocketAddr};
use tokio::net::TcpListener;
use config::{Args, Config};
@@ -25,6 +25,8 @@ async fn main() {
let config = Config::try_from(&args.config).unwrap();
let state = State::from_config(config.clone()).await;
init(&state).await.unwrap();
let routes = routes::routes(state);
let app = axum::Router::new().nest(&format!("{}/api", config.server.subpath), routes);
@@ -34,3 +36,12 @@ async fn main() {
info!("Listening on {}", listener.local_addr().unwrap());
serve(listener, app).await.unwrap();
}
async fn init(state: &State) -> Result<(), Box<dyn Error + Send + Sync>> {
sqlx::migrate!("./migrations")
.run(&state.pg_pool)
.await
.expect("Failed to run migrations");
Ok(())
}

View File

@@ -1,4 +1,3 @@
use non_empty_string::NonEmptyString;
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -6,50 +5,20 @@ use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UsersFile {
pub users: HashMap<NonEmptyString, UserFile>,
pub users: HashMap<String, UserFile>,
#[serde(flatten)]
pub extra: Option<HashMap<NonEmptyString, Value>>,
pub extra: Option<HashMap<String, Value>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserFile {
pub displayname: NonEmptyString,
pub password: NonEmptyString,
pub displayname: String,
pub password: String,
pub email: Option<String>,
pub disabled: Option<bool>,
pub groups: Option<Vec<NonEmptyString>>,
pub groups: Option<Vec<String>>,
#[serde(flatten)]
pub extra: Option<HashMap<NonEmptyString, Value>>,
}
impl From<super::users::User> for UserFile {
fn from(user: super::users::User) -> Self {
Self {
displayname: user.displayname,
email: user.email,
password: user.password,
disabled: if user.disabled { Some(true) } else { None },
groups: if user.groups.is_empty() {
None
} else {
Some(user.groups)
},
extra: user.extra,
}
}
}
impl From<super::users::Users> for UsersFile {
fn from(users: super::users::Users) -> Self {
Self {
users: users
.users
.into_iter()
.map(|(key, user)| (key, UserFile::from(user)))
.collect(),
extra: users.extra,
}
}
pub extra: Option<HashMap<String, Value>>,
}

View File

@@ -1,49 +1,143 @@
use non_empty_string::NonEmptyString;
use serde::{Deserialize, Serialize};
use std::error::Error;
use std::{
collections::HashMap,
ops::{Deref, DerefMut},
};
use serde::{Deserialize, Serialize};
use sqlx::{PgPool, prelude::FromRow, query, query_as};
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Group {
pub name: String,
}
impl Group {
pub async fn select_by_name(
pool: &PgPool,
name: &str,
) -> Result<Option<Self>, Box<dyn Error + Send + Sync>> {
let group = query_as!(
Group,
r#"
SELECT name
FROM groups
WHERE name = $1
"#,
name
)
.fetch_optional(pool)
.await?;
Ok(group)
}
pub async fn delete_by_name(
pool: &PgPool,
name: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
query!(
r#"
DELETE FROM groups
WHERE name = $1
"#,
name
)
.execute(pool)
.await?;
Ok(())
}
pub async fn all_exist_by_names(
pool: &PgPool,
names: &[String],
) -> Result<bool, Box<dyn Error + Send + Sync>> {
let row = query!(
r#"
SELECT COUNT(*) AS "count!"
FROM groups
WHERE name = ANY($1)
"#,
names
)
.fetch_one(pool)
.await?;
Ok(row.count == i64::try_from(names.len()).unwrap())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Group {
pub users: Vec<NonEmptyString>,
pub struct GroupWithUsers {
pub name: String,
pub users: Vec<String>,
}
pub struct Groups {
pub groups: HashMap<NonEmptyString, Group>,
}
impl Deref for Groups {
type Target = HashMap<NonEmptyString, Group>;
fn deref(&self) -> &Self::Target {
&self.groups
}
}
impl DerefMut for Groups {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.groups
}
}
impl From<super::authelia::UsersFile> for Groups {
fn from(users_file: super::authelia::UsersFile) -> Self {
users_file.users.into_iter().fold(
Self {
groups: HashMap::new(),
},
|mut acc, (key, user)| {
for group in user.groups.unwrap_or_default() {
acc.entry(group)
.or_insert_with(|| Group { users: Vec::new() })
.users
.push(key.clone());
}
acc
},
impl GroupWithUsers {
pub async fn select(pool: &PgPool) -> Result<Vec<Self>, Box<dyn Error + Send + Sync>> {
let groups = query_as!(
GroupWithUsers,
r#"
SELECT
g.name,
COALESCE(array_agg(ug.user_name ORDER BY ug.user_name), ARRAY[]::TEXT[]) AS "users!"
FROM groups g
LEFT JOIN users_groups ug ON g.name = ug.group_name
GROUP BY g.name
"#
)
.fetch_all(pool)
.await?;
Ok(groups)
}
pub async fn select_by_name(
pool: &PgPool,
name: &str,
) -> Result<Option<Self>, Box<dyn Error + Send + Sync>> {
let group = query_as!(
GroupWithUsers,
r#"
SELECT
g.name,
COALESCE(array_agg(ug.user_name ORDER BY ug.user_name), ARRAY[]::TEXT[]) AS "users!"
FROM groups g
LEFT JOIN users_groups ug ON g.name = ug.group_name
WHERE g.name = $1
GROUP BY g.name
"#,
name
)
.fetch_optional(pool)
.await?;
Ok(group)
}
pub async fn insert(
pool: &PgPool,
group_with_users: &Self,
) -> Result<(), Box<dyn std::error::Error>> {
let mut tx = pool.begin().await?;
query!(
r#"INSERT INTO groups (name) VALUES ($1)"#,
group_with_users.name
)
.execute(&mut *tx)
.await?;
query!(
r#"
INSERT INTO users_groups (user_name, group_name)
SELECT * FROM UNNEST($1::text[], $2::text[])
"#,
&group_with_users.users,
&vec![group_with_users.name.clone(); group_with_users.users.len()]
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
}

View File

@@ -0,0 +1,74 @@
use std::error::Error;
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, PgPool, query};
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct UsersGroups {
pub user_name: String,
pub group_name: String,
}
impl UsersGroups {
pub async fn set_users_for_group(
pool: &PgPool,
group_name: &str,
users: &[String],
) -> Result<(), Box<dyn Error + Send + Sync>> {
let mut tx = pool.begin().await?;
query!(
r#"
DELETE FROM users_groups
WHERE group_name = $1
"#,
group_name
)
.execute(&mut *tx)
.await?;
query!(
r#"
INSERT INTO users_groups (user_name, group_name)
SELECT * FROM UNNEST($1::text[], $2::text[])
"#,
users,
&vec![group_name.to_string(); users.len()]
)
.execute(&mut *tx)
.await?;
Ok(())
}
pub async fn set_groups_for_user(
pool: &PgPool,
user_name: &str,
groups: &[String],
) -> Result<(), Box<dyn Error + Send + Sync>> {
let mut tx = pool.begin().await?;
query!(
r#"
DELETE FROM users_groups
WHERE user_name = $1
"#,
user_name
)
.execute(&mut *tx)
.await?;
query!(
r#"
INSERT INTO users_groups (user_name, group_name)
SELECT * FROM UNNEST($1::text[], $2::text[])
"#,
&vec![user_name.to_string(); groups.len()],
groups
)
.execute(&mut *tx)
.await?;
Ok(())
}
}

View File

@@ -1,9 +1,9 @@
use redis_macros::{FromRedisValue, ToRedisArgs};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use time::UtcDateTime;
use uuid::Uuid;
#[derive(Serialize, Deserialize, FromRedisValue, ToRedisArgs)]
#[derive(Serialize, Deserialize, FromRow)]
struct Invite {
id: Uuid,
groups: Vec<String>,

View File

@@ -1,4 +1,5 @@
pub mod authelia;
pub mod groups;
pub mod intersections;
pub mod invites;
pub mod users;

View File

@@ -1,68 +1,199 @@
use non_empty_string::NonEmptyString;
use std::error::Error;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sqlx::{FromRow, PgPool, query, query_as};
use std::{
collections::HashMap,
ops::{Deref, DerefMut},
};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct User {
pub displayname: NonEmptyString,
pub email: Option<String>,
pub password: NonEmptyString,
pub name: String,
pub display_name: String,
pub password: String,
pub email: String,
#[serde(default)]
pub disabled: bool,
pub groups: Vec<NonEmptyString>,
#[serde(default)]
pub image: Option<String>,
}
#[serde(flatten)]
pub extra: Option<HashMap<NonEmptyString, Value>>,
impl User {
pub async fn select_by_name(
pool: &PgPool,
name: &str,
) -> Result<Option<Self>, Box<dyn Error + Send + Sync>> {
let user = query_as!(
User,
r#"
SELECT name, display_name, password, email, disabled, image
FROM users
WHERE name = $1
"#,
name
)
.fetch_optional(pool)
.await?;
Ok(user)
}
pub async fn upsert(pool: &PgPool, user: &Self) -> Result<(), Box<dyn Error + Send + Sync>> {
query!(
r#"
INSERT INTO users (name, display_name, password, email, disabled, image)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (name) DO UPDATE
SET display_name = EXCLUDED.display_name,
password = EXCLUDED.password,
email = EXCLUDED.email,
disabled = EXCLUDED.disabled,
image = EXCLUDED.image
"#,
user.name,
user.display_name,
user.password,
user.email,
user.disabled,
user.image
)
.execute(pool)
.await?;
Ok(())
}
pub async fn delete_by_name(
pool: &PgPool,
name: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
query!(
r#"
DELETE FROM users
WHERE name = $1
"#,
name
)
.execute(pool)
.await?;
Ok(())
}
pub async fn all_exist_by_names(
pool: &PgPool,
names: &[String],
) -> Result<bool, Box<dyn Error + Send + Sync>> {
let row = query!(
r#"
SELECT COUNT(*) AS "count!"
FROM users
WHERE name = ANY($1)
"#,
names
)
.fetch_one(pool)
.await?;
Ok(row.count == i64::try_from(names.len()).unwrap())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Users {
pub users: HashMap<NonEmptyString, User>,
#[serde(flatten)]
pub extra: Option<HashMap<NonEmptyString, Value>>,
pub struct UserWithGroups {
pub name: String,
pub display_name: String,
pub password: String,
pub email: String,
#[serde(default)]
pub disabled: bool,
#[serde(default)]
pub image: Option<String>,
pub groups: Vec<String>,
}
impl Deref for Users {
type Target = HashMap<NonEmptyString, User>;
impl UserWithGroups {
pub async fn select(pool: &PgPool) -> Result<Vec<Self>, Box<dyn Error + Send + Sync>> {
let users = query_as!(
UserWithGroups,
r#"
SELECT
u.name,
u.display_name,
u.password,
u.email,
u.disabled,
u.image,
COALESCE(array_agg(ug.group_name ORDER BY ug.group_name), ARRAY[]::TEXT[]) AS "groups!"
FROM users u
LEFT JOIN users_groups ug ON u.name = ug.user_name
GROUP BY u.name, u.email, u.disabled, u.image
"#
)
.fetch_all(pool)
.await?;
fn deref(&self) -> &Self::Target {
&self.users
}
}
impl DerefMut for Users {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.users
}
}
impl From<super::authelia::UserFile> for User {
fn from(user_file: super::authelia::UserFile) -> Self {
Self {
displayname: user_file.displayname,
email: user_file.email,
password: user_file.password,
disabled: user_file.disabled.unwrap_or(false),
groups: user_file.groups.unwrap_or_default(),
extra: user_file.extra,
}
}
}
impl From<super::authelia::UsersFile> for Users {
fn from(users_file: super::authelia::UsersFile) -> Self {
Self {
users: users_file
.users
.into_iter()
.map(|(key, user)| (key, User::from(user)))
.collect(),
extra: users_file.extra,
}
Ok(users)
}
pub async fn select_by_name(
pool: &PgPool,
name: &str,
) -> Result<Option<Self>, Box<dyn Error + Send + Sync>> {
let user = query_as!(
UserWithGroups,
r#"
SELECT
u.name,
u.display_name,
u.password,
u.email,
u.disabled,
u.image,
COALESCE(array_agg(ug.group_name ORDER BY ug.group_name), ARRAY[]::TEXT[]) AS "groups!"
FROM users u
LEFT JOIN users_groups ug ON u.name = ug.user_name
WHERE u.name = $1
GROUP BY u.name, u.email, u.disabled, u.image
"#,
name
)
.fetch_optional(pool)
.await?;
Ok(user)
}
pub async fn insert(
pool: &PgPool,
user_with_groups: &Self,
) -> Result<(), Box<dyn Error + Send + Sync>> {
let mut tx = pool.begin().await?;
query!(
r#"INSERT INTO users (name, display_name, password, email, disabled, image)
VALUES ($1, $2, $3, $4, $5, $6)
"#,
user_with_groups.name,
user_with_groups.display_name,
user_with_groups.password,
user_with_groups.email,
user_with_groups.disabled,
user_with_groups.image
)
.execute(&mut *tx)
.await?;
query!(
r#"
INSERT INTO users_groups (user_name, group_name)
SELECT * FROM UNNEST($1::text[], $2::text[])
"#,
&user_with_groups.groups,
&vec![user_with_groups.name.clone(); user_with_groups.groups.len()]
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
}

View File

@@ -6,227 +6,173 @@ use axum::{
response::{IntoResponse, Redirect},
routing,
};
use log::error;
use non_empty_string::NonEmptyString;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use crate::{models::groups, routes::auth, state::State};
use crate::{
config::Config,
models::{self, groups::Group},
routes::auth,
state::State,
};
#[derive(Debug, Serialize)]
struct GroupResponse {
groupname: NonEmptyString,
users: Vec<NonEmptyString>,
users: Vec<String>,
}
impl From<(NonEmptyString, groups::Group)> for GroupResponse {
fn from((groupname, group): (NonEmptyString, groups::Group)) -> Self {
Self {
groupname,
users: group.users,
}
impl From<models::groups::GroupWithUsers> for GroupResponse {
fn from(group: models::groups::GroupWithUsers) -> Self {
Self { users: group.users }
}
}
type GroupsResponse = HashMap<NonEmptyString, GroupResponse>;
impl From<groups::Groups> for GroupsResponse {
fn from(groups: groups::Groups) -> Self {
groups
.groups
.into_iter()
.map(|(key, group)| (key.clone(), GroupResponse::from((key, group))))
.collect()
}
}
type GroupsResponse = HashMap<String, GroupResponse>;
pub async fn get_all(
_user: auth::User,
extract::State(state): extract::State<State>,
_: auth::User,
extract::State(pg_pool): extract::State<PgPool>,
) -> Result<impl IntoResponse, StatusCode> {
let groups = state.load_groups().map_err(|e| {
error!("Failed to read users file: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let groups_with_users = models::groups::GroupWithUsers::select(&pg_pool)
.await
.or(Err(StatusCode::INTERNAL_SERVER_ERROR))?;
Ok(Json(GroupsResponse::from(groups)))
let groups_response = groups_with_users
.into_iter()
.map(|group| (group.name.clone(), GroupResponse::from(group)))
.collect::<GroupsResponse>();
Ok(Json(groups_response))
}
pub async fn get(
_user: auth::User,
extract::Path(groupname): extract::Path<NonEmptyString>,
extract::State(state): extract::State<State>,
_: auth::User,
extract::Path(name): extract::Path<NonEmptyString>,
extract::State(pg_pool): extract::State<PgPool>,
) -> Result<impl IntoResponse, StatusCode> {
let groups = state.load_groups().map_err(|e| {
error!("Failed to read users file: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let group_with_users = models::groups::GroupWithUsers::select_by_name(&pg_pool, name.as_str())
.await
.or(Err(StatusCode::INTERNAL_SERVER_ERROR))?
.ok_or(StatusCode::NOT_FOUND)?;
groups.get(&groupname).cloned().map_or_else(
|| Err(StatusCode::NOT_FOUND),
|group| Ok(Json(GroupResponse::from((groupname, group))).into_response()),
)
Ok(Json(GroupResponse::from(group_with_users)))
}
#[derive(Debug, Deserialize)]
pub struct GroupCreate {
groupname: NonEmptyString,
name: NonEmptyString,
users: Vec<NonEmptyString>,
}
impl From<GroupCreate> for groups::Group {
fn from(update: GroupCreate) -> Self {
Self {
users: update.users,
}
}
}
pub async fn create(
_user: auth::User,
extract::State(state): extract::State<State>,
_: auth::User,
extract::State(pg_pool): extract::State<PgPool>,
extract::Json(group_create): extract::Json<GroupCreate>,
) -> Result<impl IntoResponse, StatusCode> {
let (mut users, groups) = state.load_users_and_groups().map_err(|e| {
error!("Failed to read users file: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let groupname = group_create.groupname.clone();
if groups.contains_key(&groupname) {
if models::groups::Group::select_by_name(&pg_pool, group_create.name.as_str())
.await
.or(Err(StatusCode::INTERNAL_SERVER_ERROR))?
.is_some()
{
return Err(StatusCode::CONFLICT);
}
let group_created = groups::Group::from(group_create);
let users = group_create
.users
.into_iter()
.map(|u| u.to_string())
.collect::<Vec<_>>();
for username in &group_created.users {
if !users.contains_key(username) {
return Err(StatusCode::NOT_FOUND);
}
users
.get_mut(username)
.unwrap()
.groups
.push(groupname.clone());
if !models::users::User::all_exist_by_names(&pg_pool, &users)
.await
.or(Err(StatusCode::INTERNAL_SERVER_ERROR))?
{
return Err(StatusCode::NOT_FOUND);
}
state.save_users(users).map_err(|e| {
error!("Failed to save users file: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let group_with_users = models::groups::GroupWithUsers {
name: group_create.name.to_string(),
users,
};
Ok(Json(GroupResponse::from((groupname, group_created))).into_response())
models::groups::GroupWithUsers::insert(&pg_pool, &group_with_users)
.await
.or(Err(StatusCode::INTERNAL_SERVER_ERROR))?;
Ok(())
}
#[derive(Debug, Deserialize)]
pub struct GroupUpdate {
groupname: Option<NonEmptyString>,
users: Vec<NonEmptyString>,
}
impl From<GroupUpdate> for groups::Group {
fn from(update: GroupUpdate) -> Self {
Self {
users: update.users,
}
}
users: Option<Vec<NonEmptyString>>,
}
pub async fn update(
user: auth::User,
extract::Path(groupname): extract::Path<NonEmptyString>,
extract::State(state): extract::State<State>,
session_user: auth::User,
extract::Path(name): extract::Path<NonEmptyString>,
extract::State(pg_pool): extract::State<PgPool>,
extract::State(config): extract::State<Config>,
extract::Json(group_update): extract::Json<GroupUpdate>,
) -> Result<impl IntoResponse, StatusCode> {
let (mut users, groups) = state.load_users_and_groups().map_err(|e| {
error!("Failed to read users file: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let group = models::groups::Group::select_by_name(&pg_pool, name.as_str())
.await
.or(Err(StatusCode::INTERNAL_SERVER_ERROR))?
.ok_or(StatusCode::NOT_FOUND)?;
let new_groupname = group_update
.groupname
.clone()
.unwrap_or_else(|| groupname.clone());
let mut logout = false;
let group_existing = groups.get(&groupname).ok_or(StatusCode::NOT_FOUND)?;
let group_updated = groups::Group::from(group_update);
if let Some(users) = &group_update.users {
let users = users.iter().map(ToString::to_string).collect::<Vec<_>>();
if groupname != new_groupname
&& (groupname == state.config.oauth.admin_group
|| new_groupname == state.config.oauth.admin_group)
{
return Err(StatusCode::FORBIDDEN);
}
if groupname != new_groupname && groups.contains_key(&new_groupname) {
return Err(StatusCode::CONFLICT);
}
for user in &group_existing.users {
let user = users.get_mut(user).unwrap();
let pos = user.groups.iter().position(|g| g == &groupname).unwrap();
user.groups.remove(pos);
}
for username in &group_updated.users {
if !users.contains_key(username) {
if !models::users::User::all_exist_by_names(&pg_pool, &users)
.await
.or(Err(StatusCode::INTERNAL_SERVER_ERROR))?
{
return Err(StatusCode::NOT_FOUND);
}
let user = users.get_mut(username).unwrap();
if !user.groups.contains(&new_groupname) {
user.groups.push(new_groupname.clone());
models::intersections::UsersGroups::set_users_for_group(
&pg_pool,
group.name.as_str(),
&users,
)
.await
.or(Err(StatusCode::INTERNAL_SERVER_ERROR))?;
if name == config.oauth.admin_group && !users.contains(&session_user.username) {
logout = true;
}
}
state.save_users(users).map_err(|e| {
error!("Failed to save users file: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})?;
if new_groupname == state.config.oauth.admin_group
&& !group_updated
.users
.iter()
.any(|group_user| *group_user == *user.username.to_string())
{
if logout {
return Ok(Redirect::to("/api/auth/logout").into_response());
}
Ok(Json(GroupResponse::from((new_groupname, group_updated))).into_response())
Ok(().into_response())
}
pub async fn delete(
_user: auth::User,
extract::Path(groupname): extract::Path<String>,
extract::State(state): extract::State<State>,
_: auth::User,
extract::Path(name): extract::Path<String>,
extract::State(pg_pool): extract::State<PgPool>,
extract::State(config): extract::State<Config>,
) -> Result<impl IntoResponse, StatusCode> {
let (mut users, groups) = state.load_users_and_groups().map_err(|e| {
error!("Failed to read users file: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})?;
if groupname == state.config.oauth.admin_group {
if name == config.oauth.admin_group {
return Err(StatusCode::FORBIDDEN);
}
if let Some(old_group) = groups.get(&groupname) {
for user in &old_group.users {
let user = users.get_mut(user).unwrap();
let pos = user.groups.iter().position(|g| g == &groupname).unwrap();
user.groups.remove(pos);
}
} else {
return Err(StatusCode::NOT_FOUND);
}
let group = models::groups::Group::select_by_name(&pg_pool, &name)
.await
.or(Err(StatusCode::INTERNAL_SERVER_ERROR))?
.ok_or(StatusCode::NOT_FOUND)?;
state.save_users(users).map_err(|e| {
error!("Failed to save users file: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})?;
Group::delete_by_name(&pg_pool, &group.name)
.await
.or(Err(StatusCode::INTERNAL_SERVER_ERROR))?;
Ok(StatusCode::NO_CONTENT.into_response())
Ok(())
}
pub fn routes(state: State) -> Router {

View File

@@ -6,205 +6,216 @@ use axum::{
response::{IntoResponse, Redirect},
routing,
};
use log::error;
use non_empty_string::NonEmptyString;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use crate::{
models::users, routes::auth, state::State, utils::crypto::generate_random_password_hash,
config::Config, models, routes::auth, state::State,
utils::crypto::generate_random_password_hash,
};
#[derive(Debug, Serialize)]
struct UserResponse {
username: NonEmptyString,
displayname: NonEmptyString,
email: Option<String>,
groups: Option<Vec<NonEmptyString>>,
display_name: String,
email: String,
disabled: bool,
image: Option<String>,
groups: Vec<String>,
}
impl From<(NonEmptyString, users::User)> for UserResponse {
fn from((username, user): (NonEmptyString, users::User)) -> Self {
impl From<models::users::UserWithGroups> for UserResponse {
fn from(user: models::users::UserWithGroups) -> Self {
Self {
username,
displayname: user.displayname,
display_name: user.display_name,
email: user.email,
groups: Some(user.groups),
disabled: user.disabled,
image: user.image,
groups: user.groups,
}
}
}
type UsersResponse = HashMap<NonEmptyString, UserResponse>;
impl From<users::Users> for UsersResponse {
fn from(users: users::Users) -> Self {
users
.users
.into_iter()
.map(|(key, user)| (key.clone(), UserResponse::from((key, user))))
.collect()
}
}
type UsersResponse = HashMap<String, UserResponse>;
pub async fn get_all(
_user: auth::User,
extract::State(state): extract::State<State>,
_: auth::User,
extract::State(pg_pool): extract::State<PgPool>,
) -> Result<impl IntoResponse, StatusCode> {
let users = state.load_users().map_err(|e| {
error!("Failed to read users file: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let users_with_groups = models::users::UserWithGroups::select(&pg_pool)
.await
.or(Err(StatusCode::INTERNAL_SERVER_ERROR))?;
Ok(Json(UsersResponse::from(users)))
let users_response = users_with_groups
.into_iter()
.map(|user| (user.name.clone(), UserResponse::from(user)))
.collect::<UsersResponse>();
Ok(Json(users_response))
}
pub async fn get(
_user: auth::User,
extract::Path(username): extract::Path<NonEmptyString>,
extract::State(state): extract::State<State>,
_: auth::User,
extract::Path(name): extract::Path<NonEmptyString>,
extract::State(pg_pool): extract::State<PgPool>,
) -> Result<impl IntoResponse, StatusCode> {
let users = state.load_users().map_err(|e| {
error!("Failed to read users file: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let user_with_groups = models::users::UserWithGroups::select_by_name(&pg_pool, name.as_str())
.await
.or(Err(StatusCode::INTERNAL_SERVER_ERROR))?
.ok_or(StatusCode::NOT_FOUND)?;
users.get(&username).cloned().map_or_else(
|| Err(StatusCode::NOT_FOUND),
|user| Ok(Json(UserResponse::from((username, user))).into_response()),
)
Ok(Json(UserResponse::from(user_with_groups)))
}
#[derive(Debug, Deserialize)]
pub struct UserCreate {
username: NonEmptyString,
name: NonEmptyString,
displayname: NonEmptyString,
email: NonEmptyString,
disabled: Option<bool>,
groups: Option<Vec<NonEmptyString>>,
}
#[allow(clippy::fallible_impl_from)]
impl From<UserCreate> for users::User {
fn from(user_create: UserCreate) -> Self {
Self {
displayname: user_create.displayname,
email: Some(user_create.email.to_string()),
password: NonEmptyString::new(generate_random_password_hash()).unwrap(),
disabled: user_create.disabled.unwrap_or(false),
groups: user_create.groups.unwrap_or_default(),
extra: None,
}
}
disabled: bool,
image: Option<NonEmptyString>,
groups: Vec<NonEmptyString>,
}
pub async fn create(
_user: auth::User,
extract::State(state): extract::State<State>,
_: auth::User,
extract::State(pg_pool): extract::State<PgPool>,
extract::Json(user_create): extract::Json<UserCreate>,
) -> Result<impl IntoResponse, StatusCode> {
let mut users = state.load_users().map_err(|e| {
error!("Failed to read users file: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let username = user_create.username.clone();
if users.contains_key(&username) {
if models::users::User::select_by_name(&pg_pool, user_create.name.as_str())
.await
.or(Err(StatusCode::INTERNAL_SERVER_ERROR))?
.is_some()
{
return Err(StatusCode::CONFLICT);
}
let user_created = users::User::from(user_create);
users.users.insert(username.clone(), user_created.clone());
let groups = user_create
.groups
.into_iter()
.map(|g| g.to_string())
.collect::<Vec<_>>();
state.save_users(users).map_err(|e| {
error!("Failed to save users file: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})?;
if !models::groups::Group::all_exist_by_names(&pg_pool, &groups)
.await
.or(Err(StatusCode::INTERNAL_SERVER_ERROR))?
{
return Err(StatusCode::NOT_FOUND);
}
Ok(Json(UserResponse::from((username, user_created))).into_response())
let user_with_groups = models::users::UserWithGroups {
name: user_create.name.to_string(),
display_name: user_create.displayname.to_string(),
password: generate_random_password_hash(),
email: user_create.email.to_string(),
disabled: user_create.disabled,
image: user_create.image.map(|i| i.to_string()),
groups,
};
models::users::UserWithGroups::insert(&pg_pool, &user_with_groups)
.await
.or(Err(StatusCode::INTERNAL_SERVER_ERROR))?;
Ok(())
}
#[derive(Debug, Deserialize)]
pub struct UserUpdate {
username: Option<NonEmptyString>,
displayname: NonEmptyString,
email: NonEmptyString,
display_name: Option<NonEmptyString>,
email: Option<NonEmptyString>,
disabled: Option<bool>,
image: Option<NonEmptyString>,
groups: Option<Vec<NonEmptyString>>,
}
impl From<(Self, UserUpdate)> for users::User {
fn from((user_existing, user_update): (Self, UserUpdate)) -> Self {
Self {
displayname: user_update.displayname,
email: Some(user_update.email.to_string()),
password: user_existing.password,
disabled: user_update.disabled.unwrap_or(user_existing.disabled),
groups: user_update.groups.unwrap_or(user_existing.groups),
extra: user_existing.extra,
}
}
}
pub async fn update(
user: auth::User,
extract::Path(username): extract::Path<NonEmptyString>,
extract::State(state): extract::State<State>,
session_user: auth::User,
extract::Path(name): extract::Path<NonEmptyString>,
extract::State(pg_pool): extract::State<PgPool>,
extract::State(config): extract::State<Config>,
extract::Json(user_update): extract::Json<UserUpdate>,
) -> Result<impl IntoResponse, StatusCode> {
let mut users = state.load_users().map_err(|e| {
error!("Failed to read users file: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let user = models::users::User::select_by_name(&pg_pool, name.as_str())
.await
.or(Err(StatusCode::INTERNAL_SERVER_ERROR))?
.ok_or(StatusCode::NOT_FOUND)?;
let new_username = user_update
.username
.clone()
.unwrap_or_else(|| username.clone());
let mut logout = false;
let user_existing = users.remove(&username).ok_or(StatusCode::NOT_FOUND)?;
let user_updated = users::User::from((user_existing, user_update));
if let Some(groups) = user_update.groups {
let groups = groups
.into_iter()
.map(|g| g.to_string())
.collect::<Vec<_>>();
users
.users
.insert(new_username.clone(), user_updated.clone());
if !models::groups::Group::all_exist_by_names(&pg_pool, &groups)
.await
.or(Err(StatusCode::INTERNAL_SERVER_ERROR))?
{
return Err(StatusCode::NOT_FOUND);
}
state.save_users(users).map_err(|e| {
error!("Failed to save users file: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})?;
models::intersections::UsersGroups::set_groups_for_user(
&pg_pool,
user.name.as_str(),
&groups,
)
.await
.or(Err(StatusCode::INTERNAL_SERVER_ERROR))?;
if user.username.to_string() == username && (username != new_username || user_updated.disabled)
{
if name == session_user.username.to_string() && !groups.contains(&config.oauth.admin_group)
{
logout = true;
}
}
let user = models::users::User {
name: user.name,
display_name: user_update
.display_name
.map(|d| d.to_string())
.unwrap_or(user.display_name),
password: user.password,
email: user_update
.email
.map(|e| e.to_string())
.unwrap_or(user.email),
disabled: user_update.disabled.unwrap_or(user.disabled),
image: user_update.image.map(|i| i.to_string()).or(user.image),
};
models::users::User::upsert(&pg_pool, &user)
.await
.or(Err(StatusCode::INTERNAL_SERVER_ERROR))?;
if logout {
return Ok(Redirect::to("/api/auth/logout").into_response());
}
Ok(Json(UserResponse::from((new_username, user_updated))).into_response())
Ok(().into_response())
}
pub async fn delete(
user: auth::User,
extract::Path(username): extract::Path<String>,
extract::State(state): extract::State<State>,
session_user: auth::User,
extract::Path(name): extract::Path<String>,
extract::State(pg_pool): extract::State<PgPool>,
) -> Result<impl IntoResponse, StatusCode> {
let mut users = state.load_users().map_err(|e| {
error!("Failed to read users file: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})?;
if users.remove(&username).is_none() {
return Err(StatusCode::NOT_FOUND);
if name == session_user.username.to_string() {
return Err(StatusCode::FORBIDDEN);
}
state.save_users(users).map_err(|e| {
error!("Failed to save users file: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let user = models::users::User::select_by_name(&pg_pool, &name)
.await
.or(Err(StatusCode::INTERNAL_SERVER_ERROR))?
.ok_or(StatusCode::NOT_FOUND)?;
if user.username.to_string() == username {
return Ok(Redirect::to("/api/auth/logout").into_response());
}
models::users::User::delete_by_name(&pg_pool, &user.name)
.await
.or(Err(StatusCode::INTERNAL_SERVER_ERROR))?;
Ok(StatusCode::NO_CONTENT.into_response())
Ok(())
}
pub fn routes(state: State) -> Router {

View File

@@ -11,6 +11,7 @@ use openidconnect::{
reqwest,
};
use redis::{self, AsyncCommands};
use sqlx::{PgPool, postgres::PgPoolOptions};
use tokio::spawn;
use crate::config::Config;
@@ -47,6 +48,7 @@ pub struct State {
pub config: Config,
pub oauth_http_client: reqwest::Client,
pub oauth_client: OAuthClient,
pub pg_pool: PgPool,
pub redis_client: redis::aio::MultiplexedConnection,
pub session_store: RedisSessionStore,
}
@@ -54,6 +56,7 @@ pub struct State {
impl State {
pub async fn from_config(config: Config) -> Self {
let (oauth_http_client, oauth_client) = oauth_client(&config).await;
let pg_pool = pg_pool(&config).await;
let redis_client = redis_client(&config).await;
let session_store = session_store(&config);
@@ -61,6 +64,7 @@ impl State {
config,
oauth_http_client,
oauth_client,
pg_pool,
redis_client,
session_store,
}
@@ -85,6 +89,12 @@ impl FromRef<State> for OAuthClient {
}
}
impl FromRef<State> for PgPool {
fn from_ref(state: &State) -> Self {
state.pg_pool.clone()
}
}
impl FromRef<State> for redis::aio::MultiplexedConnection {
fn from_ref(state: &State) -> Self {
state.redis_client.clone()
@@ -127,6 +137,21 @@ async fn oauth_client(config: &Config) -> (reqwest::Client, OAuthClient) {
(oauth_http_client, oauth_client)
}
async fn pg_pool(config: &Config) -> PgPool {
PgPoolOptions::new()
.max_connections(5)
.connect(&format!(
"postgres://{}:{}@{}:{}/{}",
config.postgresql.user,
config.postgresql.password,
config.postgresql.host,
config.postgresql.port,
config.postgresql.database
))
.await
.unwrap()
}
async fn redis_client(config: &Config) -> redis::aio::MultiplexedConnection {
let url = format!(
"redis://{}:{}/{}",
@@ -153,7 +178,7 @@ async fn redis_client(config: &Config) -> redis::aio::MultiplexedConnection {
.await
.unwrap();
let channel = format!("__keyevent@{}__:expired", database);
let channel = format!("__keyevent@{database}__:expired");
connection.subscribe(&[channel]).await.unwrap();
while let Some(msg) = rx.recv().await {
@@ -178,7 +203,6 @@ fn session_store(config: &Config) -> RedisSessionStore {
"redis://{}:{}/{}",
config.redis.host, config.redis.port, config.redis.database
);
let session_store = RedisSessionStore::new(url).unwrap().with_prefix("session:");
session_store
RedisSessionStore::new(url).unwrap().with_prefix("session:")
}

View File

@@ -1,39 +0,0 @@
use std::error::Error;
use crate::{models, state::State};
impl State {
pub fn load_users(&self) -> Result<models::users::Users, Box<dyn Error + Send + Sync>> {
let file_contents = std::fs::read_to_string(&self.config.authelia.user_database)?;
let users_file: models::authelia::UsersFile = serde_yaml::from_str(&file_contents)?;
let users = models::users::Users::from(users_file);
Ok(users)
}
pub fn load_groups(&self) -> Result<models::groups::Groups, Box<dyn Error + Send + Sync>> {
let file_contents = std::fs::read_to_string(&self.config.authelia.user_database)?;
let users_file = serde_yaml::from_str::<models::authelia::UsersFile>(&file_contents)?;
let groups = models::groups::Groups::from(users_file);
Ok(groups)
}
pub fn load_users_and_groups(
&self,
) -> Result<(models::users::Users, models::groups::Groups), Box<dyn Error + Send + Sync>> {
let file_contents = std::fs::read_to_string(&self.config.authelia.user_database)?;
let users_file = serde_yaml::from_str::<models::authelia::UsersFile>(&file_contents)?;
let users = models::users::Users::from(users_file.clone());
let groups = models::groups::Groups::from(users_file);
Ok((users, groups))
}
pub fn save_users(
&self,
users: models::users::Users,
) -> Result<(), Box<dyn Error + Send + Sync>> {
let users_file = models::authelia::UsersFile::from(users);
let file_contents = serde_yaml::to_string(&users_file)?;
std::fs::write(&self.config.authelia.user_database, file_contents)?;
Ok(())
}
}

View File

@@ -1,2 +1 @@
pub mod authelia;
pub mod crypto;