feat: add image fetching

This commit is contained in:
2026-03-20 19:48:18 +01:00
parent fb62111c7f
commit 12388a9908
17 changed files with 2122 additions and 92 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
*.png filter=lfs diff=lfs merge=lfs -text

1681
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,16 @@
[package] [package]
name = "oidc" name = "oidc"
description = "OpenID Connect server implementation with unix-like modular authentication backend"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
license = "GPL-2.0-only"
repository = "https://git.javalsai.tuxcord.net/tuxcord/authy-oidc"
keywords = ["oauth", "oauth2", "oidc", "OpenID"]
categories = ["web-programming::http-server", "authentication"]
[profile.release]
strip = "symbols" # almost half of binary size smh
[features] [features]
default = ["pamsock"] default = ["pamsock"]
@@ -12,18 +21,27 @@ pamsock = { version = "0.2.0", git = "https://git.javalsai.tuxcord.net/tuxcord/a
"async", "async",
], optional = true } ], optional = true }
actix-web = "4.13.0"
anstyle = "1.0.13" anstyle = "1.0.13"
anyhow = "1.0.102" anyhow = "1.0.102"
clap = { version = "4.5.60", features = ["derive"] } clap = { version = "4.5.60", features = ["derive"] }
libc = "0.2.182" libc = "0.2.182"
# https://github.com/robo9k/rust-magic/issues/434
magic = { version = "0.16.7", git = "https://github.com/javalsai/rust-magic.git", branch = "dbpaths-clone" }
moka = { version = "0.12.13", features = ["async-lock", "future"] }
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
thiserror = "2.0.18" thiserror = "2.0.18"
tokio = { version = "1.49.0", features = ["full"] } tokio = { version = "1.49.0", features = ["full"] }
toml = "1.0.3" toml = "1.0.3"
users = "0.11.0"
[lints.clippy] [lints.clippy]
pedantic = { level = "deny", priority = -1 } cargo = { level = "warn", priority = -1 }
multiple_crate_versions = { level = "allow" } # otherwise there's too much
correctness = { level = "deny", priority = -1 }
nursery = { level = "deny", priority = -1 } nursery = { level = "deny", priority = -1 }
pedantic = { level = "deny", priority = -1 }
perf = { level = "deny", priority = -1 } perf = { level = "deny", priority = -1 }
style = { level = "deny", priority = -1 } style = { level = "deny", priority = -1 }
unwrap_used = "deny" unwrap_used = "deny"

View File

@@ -1,7 +1,8 @@
# example file to show/test configuration # example file to show/test configuration
[unix] [unix]
groups = [ "wheel", 0 ] groups = [ "wheel", 0, "users" ]
# magic_paths = [ "/usr/share/file/misc/magic" ]
# [server] # [server]
# listen = "127.0.0.1:8080" # listen = "127.0.0.1:8080"

BIN
assets/default-pfp.png LFS Normal file

Binary file not shown.

View File

@@ -1,3 +1,5 @@
//! Program arguments
use std::{ use std::{
os::{linux::net::SocketAddrExt, unix::net::SocketAddr}, os::{linux::net::SocketAddrExt, unix::net::SocketAddr},
path::PathBuf, path::PathBuf,
@@ -10,9 +12,11 @@ use clap::Parser;
#[command(version, about, long_about = None)] #[command(version, about, long_about = None)]
#[command(styles = get_clap_styles())] #[command(styles = get_clap_styles())]
pub struct Args { pub struct Args {
/// Config file path to parse
#[arg(short, default_value = "/etc/authy-oidc.toml")] #[arg(short, default_value = "/etc/authy-oidc.toml")]
pub conf: PathBuf, pub conf: PathBuf,
/// Pamsock abstract name to connect to
#[cfg(feature = "pamsock")] #[cfg(feature = "pamsock")]
#[arg(long, default_value = "pam")] #[arg(long, default_value = "pam")]
#[arg(value_parser = parse_as_abstract_name)] #[arg(value_parser = parse_as_abstract_name)]

View File

@@ -6,16 +6,15 @@ use pamsock::prot::ServerResponse;
use super::AuthenticateResponse; use super::AuthenticateResponse;
#[allow(clippy::from_over_into)] impl From<ServerResponse> for AuthenticateResponse<&'static str> {
impl Into<AuthenticateResponse<&'static str>> for ServerResponse { fn from(value: ServerResponse) -> Self {
fn into(self) -> AuthenticateResponse<&'static str> { use ServerResponse as SR;
use AuthenticateResponse as AR;
match self { match value {
Self::ServerError => AR::Failed("unknown server error"), SR::ServerError => Self::Failed("unknown server error"),
Self::Locked => AR::Failed("account locked, too many login attempts"), SR::Locked => Self::Failed("account locked, too many login attempts"),
Self::Failed => AR::Failed("wrong credentials"), SR::Failed => Self::Failed("wrong credentials"),
Self::Succeeded => AR::Success, SR::Succeeded => Self::Success,
} }
} }
} }

View File

@@ -7,17 +7,18 @@ use std::{
net::{Ipv4Addr, SocketAddr, SocketAddrV4}, net::{Ipv4Addr, SocketAddr, SocketAddrV4},
}; };
use serde::{Deserialize, Serialize}; use serde::Deserialize;
use crate::serdes::Group; use crate::serdes::{DatabasePaths, Group};
#[derive(Debug, Default, Serialize, Deserialize)] #[derive(Debug, Default, Deserialize)]
#[serde(default)] #[serde(default)]
pub struct Unix { pub struct Unix {
pub groups: Vec<Group>, pub groups: Vec<Group>,
pub magic_paths: DatabasePaths,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(default)] #[serde(default)]
pub struct Server { pub struct Server {
pub listen: SocketAddr, pub listen: SocketAddr,
@@ -30,7 +31,7 @@ impl Default for Server {
} }
} }
#[derive(Debug, Default, Serialize, Deserialize)] #[derive(Debug, Default, Deserialize)]
#[serde(default)] #[serde(default)]
pub struct Config { pub struct Config {
pub unix: Unix, pub unix: Unix,
@@ -50,12 +51,14 @@ impl Config {
/// # Errors /// # Errors
/// ///
/// For IO errors reading the file or parsing errors. /// For IO errors reading the file or parsing errors.
///
/// # Panics
///
/// If file's u64 length might not fit in memory.
pub fn from_toml_file(f: &mut File) -> Result<Self, ConfigParseError> { pub fn from_toml_file(f: &mut File) -> Result<Self, ConfigParseError> {
let mut buf = Vec::new(); let mut buf = Vec::new();
if let Ok(len) = f.stream_len() { if let Ok(len) = f.stream_len() {
// There's no way a config file passes machine's memory limitations buf.reserve_exact(len.try_into().expect("file's u64 len to fit in memory"));
#[allow(clippy::cast_possible_truncation)]
buf.reserve_exact(len as usize);
} }
f.read_to_end(&mut buf)?; f.read_to_end(&mut buf)?;

27
src/consts.rs Normal file
View File

@@ -0,0 +1,27 @@
//! Constant behavior paramenters.
use std::time::Duration;
/// Max [`moka`] pfp cache capacity
pub const MAX_PFP_CACHE_CAPACITY: u64 = 1024;
/// Max filesize of accepted user profile pictures, to prevent huge cache and serving times
pub const MAX_PFP_SIZE: u64 = 8 * 1024 * 1024; // 8 MiB; TODO: might lower, high for prototyping
/// Default user image to use if users have none
pub const DEFAULT_USER_PFP: &[u8] = include_bytes!("../assets/default-pfp.png");
pub const DEFAULT_USER_PFP_MIME: &str = "image/png; charset=binary";
/// TTL for cached user information
pub const USER_CACHES_TTL: Duration = Duration::from_mins(15);
/// Paths relative to a user's home to search profile pictures in
pub const USER_PFP_PATHS: &[&str] = &[
".config/logo.avif",
".config/logo.webp",
".config/logo.png",
".config/logo.jpg",
"logo.png",
"logo.jpg",
".face",
];

View File

@@ -1,4 +1,24 @@
#![feature(seek_stream_len)] #![feature(seek_stream_len, once_cell_try)]
#![allow(clippy::future_not_send)] // will get to fix these later
//! # About, Licensing and More
//!
//! Check the README.md for entry-level documentation.
//!
//! # Where is my documentation?
//!
//! For ease of development and centralized **corrent** information, this codebase will serve both
//! as project documentation AND documentation for the behavior of the OpenID Connect server.
//!
//! Might be hard to figure out how the program behaves based on the code, but I will try to put
//! behavior parameters in [`consts`], so that might be a good starting point to know some stuff
//! (e.g. profile profile picture search path).
//!
//! Checking out [`conf`] might be useful too to know what could've been configured by server
//! administrators and less likely but maybe there can also be certain parameters in [`args`].
//!
//! I will try to keep those 3 modules as documented as possible, please feel free to open any
//! issues/PRs regarding information in there.
use std::fs::File; use std::fs::File;
@@ -9,8 +29,10 @@ use crate::ext::FileExt;
pub mod args; pub mod args;
pub mod auth; pub mod auth;
pub mod conf; pub mod conf;
pub mod consts;
pub mod ext; pub mod ext;
pub mod serdes; pub mod serdes;
pub mod server;
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
@@ -23,8 +45,7 @@ async fn main() -> anyhow::Result<()> {
println!("{conf:#?}"); println!("{conf:#?}");
let res = auth::authenticate(&args, "javalsai", "test").await; server::start_app(args, conf).await?;
println!("{res:?}");
Ok(()) Ok(())
} }

View File

@@ -68,7 +68,7 @@ enum EitherTyp {
} }
/// Deserialize either a gid number or a groupname into a [`gid_t`] /// Deserialize either a gid number or a groupname into a [`gid_t`]
#[allow(clippy::missing_errors_doc)] #[expect(clippy::missing_errors_doc)]
pub fn deserialize_group<'de, D>(d: D) -> Result<gid_t, D::Error> pub fn deserialize_group<'de, D>(d: D) -> Result<gid_t, D::Error>
where where
D: Deserializer<'de>, D: Deserializer<'de>,
@@ -101,3 +101,21 @@ where
} }
} }
/// Deserialize into [`magic::cookie::DatabasePaths`] as if it were a [`Vec<String>`]
#[expect(clippy::missing_errors_doc)]
pub fn deserialize_magic_paths<'de, D>(d: D) -> Result<magic::cookie::DatabasePaths, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::Unexpected;
let paths = Vec::<String>::deserialize(d)?;
// TODO: could use this `_err`
paths.try_into().map_err(|_err| {
D::Error::invalid_value(
Unexpected::Other("invalid magic db path"),
&"valid magic db path",
)
})
}

View File

@@ -1,4 +1,7 @@
use std::fmt::Debug; use std::{
fmt::Debug,
ops::{Deref, DerefMut},
};
use libc::gid_t; use libc::gid_t;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -14,3 +17,37 @@ impl Debug for Group {
Debug::fmt(&self.0, f) Debug::fmt(&self.0, f)
} }
} }
#[repr(transparent)]
#[derive(Deserialize, Default)]
pub struct DatabasePaths(
#[serde(deserialize_with = "inner_helpers::deserialize_magic_paths")]
pub magic::cookie::DatabasePaths,
);
// impl Default for DatabasePaths {
// fn default() -> Self {
// Self(["/usr/share/file/misc/magic"].try_into().expect(""))
// }
// }
impl Debug for DatabasePaths {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
Debug::fmt(&self.0, f)
// f.write_str("<magic paths>")
}
}
impl Deref for DatabasePaths {
type Target = magic::cookie::DatabasePaths;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for DatabasePaths {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}

68
src/server/caches/mime.rs Normal file
View File

@@ -0,0 +1,68 @@
//! Mime mmagic analogous to [`magic::Cookie`] BUT [`Sync`]
// I don't think I can do better with thread exclusive `libmagic`
use std::cell::OnceCell;
use magic::{Cookie, cookie};
#[derive(Debug, thiserror::Error)]
pub enum NewCookieError {
#[error(transparent)]
MimeCookieOpen(#[from] magic::cookie::OpenError),
#[error(transparent)]
MimeDBLoad(#[from] magic::cookie::LoadError<magic::cookie::Open>),
// #[error(transparent)]
// MimeDBPath(#[from] magic::cookie::InvalidDatabasePathError),
}
pub struct Mime {
magic_dbs: magic::cookie::DatabasePaths,
}
impl Mime {
thread_local! {
static COOKIE_CELL: OnceCell<Cookie<cookie::Load>> = const { OnceCell::new() };
}
fn use_cookie<F, T>(&self, f: F) -> Result<T, NewCookieError>
where
F: FnOnce(&Cookie<cookie::Load>) -> T,
{
Self::COOKIE_CELL.with(|cookie| {
let may_cookie = cookie.get_or_try_init::<_, NewCookieError>(move || {
let cookie = magic::Cookie::open(magic::cookie::Flags::MIME)?;
Ok(cookie.load(&self.magic_dbs)?)
});
match may_cookie {
Ok(c) => Ok(f(c)),
Err(e) => Err(e),
}
})
}
#[must_use]
pub const fn new(magic_dbs: magic::cookie::DatabasePaths) -> Self {
Self { magic_dbs }
}
/// Cookie initialization is delayed, so each call might be the creation of the cookie if its
/// the first use of the cookie in this thread.
///
/// # Errors
///
/// First layer error fails if new cookie creation failed. Second layer error represents if the
/// mime search was successful.
pub fn buffer(
&self,
buffer: &[u8],
) -> Result<Result<String, magic::cookie::Error>, NewCookieError> {
self.use_cookie(|c| c.buffer(buffer))
}
}
// fn __ace(c: Mime) {
// fn __ace_inner(c: impl Sync + Send) {}
// __ace_inner(c);
// }

176
src/server/caches/mod.rs Normal file
View File

@@ -0,0 +1,176 @@
use std::{ffi::OsStr, io, os::unix::fs::MetadataExt, path::Path, sync::Arc};
use ::users::os::unix::UserExt;
use moka::future::{Cache, CacheBuilder};
use tokio::{fs::File, io::AsyncReadExt};
use crate::{
consts,
server::caches::{mime::Mime, users::UsersCache},
};
pub mod mime;
pub mod users;
const MIME_IMAGE_PREFIX: &str = "image/";
pub struct MimeWithBytes {
pub mime: Box<str>,
pub bytes: Box<[u8]>,
}
pub type Image = Arc<MimeWithBytes>;
// TODO: most of the cache methods here block the executor, if we wanna commit to async we'd have
// to consider that
pub struct AppCache<'a> {
only_groups: &'a [crate::serdes::Group],
// FIXME: blocks
user_cache: UsersCache,
// FIXME: blocks
magic_mime_cookie: Mime,
/// MUST only contain users from an accepted group, we do not want to cache arbitrary usernames
/// and blow memory up.
///
/// [`Option<Image>`] because users may not have a pfp.
pfp_cache: Cache<String, Option<Image>>,
}
impl<'a> AppCache<'a> {
/// # Errors
///
/// Errors if anything failed opening the magic cookie.
///
/// # Panics
///
/// If weighter's usize doesn't fit in its u32
#[must_use]
pub fn new(
magic_dbs: magic::cookie::DatabasePaths,
only_groups: &'a [crate::serdes::Group],
) -> Self {
Self {
only_groups,
user_cache: UsersCache::new(),
magic_mime_cookie: Mime::new(magic_dbs),
pfp_cache: CacheBuilder::new(consts::MAX_PFP_CACHE_CAPACITY)
.time_to_live(consts::USER_CACHES_TTL)
.weigher(|_, v: &Option<Image>| {
v.as_ref()
.map_or(1, |v| v.bytes.len())
.try_into()
.expect("size of image to fit in weigher's size")
})
.build(),
}
}
fn is_member_groups(&self, groups: Option<impl AsRef<[::users::Group]>>) -> bool {
groups.is_some_and(|groups| {
groups.as_ref().iter().any(|group| {
let gid = group.gid();
self.only_groups.iter().any(|from_gr| from_gr.0 == gid)
})
})
}
pub async fn get_member_user_by_name<S: AsRef<OsStr> + ?Sized>(
&self,
username: &S,
) -> Option<Arc<::users::User>> {
let user = self.user_cache.get_user_by_name(username).await?;
// FIXME: `user.groups()` is not cached and could be a DoS point. But I don't think caching
// if a user is member of any group is the proper way for this lmfao
if self.is_member_groups(user.groups()) {
Some(user)
} else {
None
}
}
async fn read_logo_from_home(&self, home: &Path) -> Option<Image> {
async fn read_limited_path<const MAXSIZE: u64>(path: &Path) -> io::Result<Vec<u8>> {
let f = File::open(path).await?;
let size = f.metadata().await?.size();
if size > MAXSIZE {
return Err(io::Error::new(
io::ErrorKind::FileTooLarge,
"filesize is bigger than MAXSIZE",
));
}
let mut buf = Vec::with_capacity(size.try_into().expect("u64 to fit in usize"));
// `.take()` just in case an open fd happens to grow, `.metadata()` SHOULD take
// properties from the fd and lock the read fd until its closed but still
f.take(MAXSIZE).read_to_end(&mut buf).await?;
Ok(buf)
}
for subpath in consts::USER_PFP_PATHS {
let path = home.join(subpath);
if let Ok(img_buf) = read_limited_path::<{ consts::MAX_PFP_SIZE }>(&path).await
&& let Ok(Ok(mime)) = self.magic_mime_cookie.buffer(&img_buf) // TODO: first layer
// error is actually
// relevant
&& mime.starts_with(MIME_IMAGE_PREFIX)
{
return Some(Arc::new(MimeWithBytes {
mime: mime.into_boxed_str(),
bytes: img_buf.into_boxed_slice(),
}));
}
}
None
}
/// Doesn't differenciate users without pfp and nonexistent ones
///
/// So ig a feature not a bug? Less scraping? As in, scraping a user without pfp will just
/// default to default pfp.
///
/// # Performance
///
/// `T` is very generic, usually just takes the path of [`AsRef<str>`], but rarely it can take
/// the [`ToOwned<Owned = String>`] path. That means, if you only have access to a type like
/// [`str`], use it. But if by any chance you have a [`String`] of the value and it's not going
/// to be used anymore, that might be more performant.
///
/// The loss is mainly just the allocation time, just a username, should be small enough but
/// still, just giving it flexibility. Also maybe a [`std::borrow::Cow<str>`] will work
/// perfectly too.
///
/// # Security
///
/// Images ultimately come from users home directories, so they could be anything, not only
/// images (though there's a MIME check, but not designed to be relied upon), make sure to
/// provide the mime type and `X-Content-Type-Options: nosniff` when serving it via http/s.
pub async fn get_pfp<T>(&self, username: T) -> Option<Image>
where
T: AsRef<str> + ToOwned<Owned = String>,
{
// If caching is done properly, it will take advantage of async.
if let Some(cached_pfp) = self.pfp_cache.get(username.as_ref()).await {
return cached_pfp;
}
// This blocks for now, so if we win with caching better. Non-member username requests
// won't cache, we win with actual user-cache and not pushing those away, but will make
// DDoS miss cache constantly.
let user = self.get_member_user_by_name(username.as_ref()).await?;
let img = self.read_logo_from_home(user.home_dir()).await?;
self.pfp_cache
.insert(username.to_owned(), Some(img.clone()))
.await;
Some(img)
}
}

View File

@@ -0,0 +1,32 @@
//! Users cache analogous to [`users::UsersCache`] BUT [`Sync`]
use std::{ffi::OsStr, sync::Arc};
use tokio::sync::Mutex;
use users::Users as _;
pub struct UsersCache {
cache: Mutex<users::UsersCache>,
}
impl UsersCache {
#[must_use]
pub fn new() -> Self {
Self {
cache: Mutex::new(users::UsersCache::new()),
}
}
pub async fn get_user_by_name<S: AsRef<OsStr> + ?Sized>(
&self,
username: &S,
) -> Option<Arc<users::User>> {
self.cache.lock().await.get_user_by_name(username)
}
}
impl Default for UsersCache {
fn default() -> Self {
Self::new()
}
}

47
src/server/mod.rs Normal file
View File

@@ -0,0 +1,47 @@
use std::io;
use actix_web::{App, HttpServer, web};
pub mod caches;
pub mod services;
pub struct AppState {
pub args: crate::args::Args,
pub config: &'static crate::conf::Config,
pub cache: caches::AppCache<'static>,
}
/// Leaks memory for the sake of not atomic'ing all over.
#[expect(clippy::missing_errors_doc)]
pub async fn start_app(args: crate::args::Args, config: crate::conf::Config) -> io::Result<()> {
let config = Box::leak(Box::new(config));
let cache = caches::AppCache::new(
config.unix.magic_paths.clone(),
// ["/usr/share/file/misc/magic"]
// .try_into()
// .expect("To be valid DB paths"),
&config.unix.groups,
);
let app: &AppState = Box::leak(Box::new(AppState {
args,
config,
cache,
}));
println!(
"\x1b[32mINF\x1b[0m: Trying to listen on {:?}",
app.config.server.listen
);
HttpServer::new(move || {
App::new()
.app_data(web::Data::new(app))
.service(services::get_image)
// .service(factory)
})
.bind(&app.config.server.listen)?
.run()
.await
}

View File

@@ -0,0 +1,30 @@
use actix_web::{HttpResponse, get, web};
use crate::{consts, server};
// TODO: cache control
// TODO: etags
// TODO: canonical redirect for default image and better cache control
#[get("/image/{username}")]
pub async fn get_image(
data: web::Data<&server::AppState>,
username: web::Path<String>,
) -> HttpResponse {
let cached_pfp = data.cache.get_pfp(username.to_string()).await;
let (mime, bytes) = cached_pfp.as_ref().map_or_else(
|| {
(
consts::DEFAULT_USER_PFP_MIME,
web::Bytes::from_static(consts::DEFAULT_USER_PFP),
)
},
|img| {
(
img.mime.as_ref(),
web::Bytes::copy_from_slice(img.bytes.as_ref()),
)
},
);
HttpResponse::Ok().content_type(mime).body(bytes)
}