feat: lay the ground for login
This commit is contained in:
@@ -37,6 +37,11 @@ pub const USER_PFP_PATHS: &[&str] = &[
|
||||
".face",
|
||||
];
|
||||
|
||||
/// Path relative to a user's home to ssh's `.authorized_keys`, used to verify user's identity when
|
||||
/// distrusted
|
||||
pub const AUTHORIZED_KEYS_PATH: &str = ".ssh/authorized_keys";
|
||||
pub const MAX_AUTHORIZED_KEYS_SIZE: u64 = 64 * 1024; // 64 KiB
|
||||
|
||||
pub const ERROR_ASCII_ARTS: &[&str] = &[
|
||||
include_str!("../assets/lain/lain-dancing.txt"),
|
||||
include_str!("../assets/lain/lain-head.txt"),
|
||||
@@ -51,4 +56,5 @@ pub mod mime {
|
||||
|
||||
pub mod web_scopes {
|
||||
pub const IMAGES: &str = "/image";
|
||||
pub const LOGIN: &str = "/login";
|
||||
}
|
||||
|
||||
32
src/db/mod.rs
Normal file
32
src/db/mod.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
//! Everything that interacts with DB (or generally persistent state)
|
||||
//!
|
||||
//! Currently it's all in memory or with default values until I model all the data properly and
|
||||
//! choose a DB provider.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
pub struct DB {
|
||||
// TODO: should I use some sort of UIDs instead of the username everywhere?
|
||||
enabled_users: RwLock<HashSet<String>>,
|
||||
}
|
||||
|
||||
impl DB {
|
||||
#[must_use]
|
||||
#[expect(clippy::new_without_default)]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
enabled_users: RwLock::new(HashSet::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn enable_user(&mut self, user: String) {
|
||||
self.enabled_users.write().await.insert(user);
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub async fn user_is_enabled(&self, user: &str) -> bool {
|
||||
self.enabled_users.read().await.contains(user)
|
||||
}
|
||||
}
|
||||
32
src/main.rs
32
src/main.rs
@@ -2,6 +2,7 @@
|
||||
#![feature(
|
||||
decl_macro,
|
||||
duration_constructors,
|
||||
iterator_try_collect,
|
||||
never_type,
|
||||
once_cell_try,
|
||||
seek_stream_len
|
||||
@@ -26,6 +27,34 @@
|
||||
//!
|
||||
//! I will try to keep those 3 modules as documented as possible, please feel free to open any
|
||||
//! issues/PRs regarding information in there.
|
||||
//!
|
||||
//! # Public Information
|
||||
//!
|
||||
//! To make sure this application doesn't expose any public imformation it's important to define
|
||||
//! what public information we are willing to expose. The application deals with user information
|
||||
//! so it must leak at least some information, to make sure we don't overreach, we must have clear
|
||||
//! where we draw the line.
|
||||
//!
|
||||
//! By default all information is private, but this application might leak by default:
|
||||
//!
|
||||
//! - **User system information:** Unix's UID of a given username.
|
||||
//! - **User profile pictures:** See [`consts::USER_PFP_PATHS`].
|
||||
//! - **User's `autorized_ssh_keys`:** See [`consts::AUTHORIZED_KEYS_PATH`].
|
||||
//!
|
||||
//! Note that no file information within user's home can be accessed until the user adds `o+x`
|
||||
//! permissions on their home directory. Once this is done, only state of files regarding the
|
||||
//! previous can be publicly accessible, there's no arbirtary path reading.
|
||||
//!
|
||||
//! Any user information is checked ASAP against the allowed groups (see [`conf::Unix::groups`]) to
|
||||
//! fail fast without exposing any personal information for users alien to these groups. That means
|
||||
//! that any reference to the "user", will assume its already from an allowed group, if its not a
|
||||
//! group member, it will be treated as nonexistent.
|
||||
//!
|
||||
//! Information about existance of a user alien to the configured groups might vulnerable to timing
|
||||
//! attacks though.
|
||||
//!
|
||||
//! TODO: This was clearly defined after some API was already written so these assumptions will
|
||||
//! need to be reviewed for the old code (notably pfp logic).
|
||||
|
||||
use std::fs::File;
|
||||
|
||||
@@ -37,6 +66,7 @@ pub mod args;
|
||||
pub mod auth;
|
||||
pub mod conf;
|
||||
pub mod consts;
|
||||
pub mod db;
|
||||
pub mod ext;
|
||||
pub mod serdes;
|
||||
pub mod server;
|
||||
@@ -61,7 +91,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
// (idek japanese but im vibing)
|
||||
println!("\n\x1b[1;3;4;33mConfiguration\x1b[0m: {conf:#?}\n");
|
||||
|
||||
server::start_app(args, conf).await?;
|
||||
server::start_app(args, conf, db::DB::new()).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
use std::{ffi::OsStr, io, os::unix::fs::MetadataExt, path::Path, sync::Arc};
|
||||
use std::{ffi::OsStr, io, path::Path, sync::Arc};
|
||||
|
||||
use ::users::os::unix::UserExt as _;
|
||||
use moka::future::{Cache, CacheBuilder};
|
||||
use sha2::Digest as _;
|
||||
use tokio::{fs::File, io::AsyncReadExt};
|
||||
use ssh_key::PublicKey;
|
||||
|
||||
use crate::{
|
||||
consts,
|
||||
server::caches::{mime::Mime, users::UsersCache},
|
||||
utils::fs::{read_limited_path, read_limited_path_str},
|
||||
};
|
||||
|
||||
pub mod mime;
|
||||
@@ -39,6 +40,8 @@ pub struct AppCache<'a> {
|
||||
pfp_cache: Cache<String, Option<Image>>,
|
||||
}
|
||||
|
||||
/// Most of the methods in here fail silently with file i/o and some might cache results, I chose
|
||||
/// either arbitrarily on the spot but I should make this more explicit.
|
||||
impl<'a> AppCache<'a> {
|
||||
/// # Errors
|
||||
///
|
||||
@@ -97,23 +100,6 @@ impl<'a> AppCache<'a> {
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -183,4 +169,45 @@ impl<'a> AppCache<'a> {
|
||||
|
||||
Some(img)
|
||||
}
|
||||
|
||||
/// Gets [`consts::AUTHORIZED_KEYS_PATH`] from user's home.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// In any of [`GetAuthorizedKeysError`]
|
||||
pub async fn get_authorized_keys(
|
||||
&self,
|
||||
username: &str,
|
||||
) -> Result<Box<[PublicKey]>, GetAuthorizedKeysError> {
|
||||
async fn read_users_authorized_keys(
|
||||
home_dir: &Path,
|
||||
) -> Result<Vec<PublicKey>, GetAuthorizedKeysError> {
|
||||
let buf = read_limited_path_str::<{ consts::MAX_AUTHORIZED_KEYS_SIZE }>(
|
||||
&home_dir.join(consts::AUTHORIZED_KEYS_PATH),
|
||||
)
|
||||
.await
|
||||
.map_err(GetAuthorizedKeysError::ReadSshFile)?;
|
||||
|
||||
Ok(buf.lines().map(PublicKey::from_openssh).try_collect()?)
|
||||
}
|
||||
|
||||
let user = self
|
||||
.get_member_user_by_name(username)
|
||||
.await
|
||||
.ok_or(GetAuthorizedKeysError::UserNotFound)?;
|
||||
|
||||
Ok(read_users_authorized_keys(user.home_dir())
|
||||
.await?
|
||||
.into_boxed_slice())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GetAuthorizedKeysError {
|
||||
#[error("said user was not found")]
|
||||
UserNotFound,
|
||||
#[error("failure reading ssh file: {0}")]
|
||||
ReadSshFile(io::Error),
|
||||
#[error("error parsing ssh key: {0}")]
|
||||
ParseError(#[from] ssh_key::Error),
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ pub struct AppState {
|
||||
pub args: crate::args::Args,
|
||||
pub config: &'static crate::conf::Config,
|
||||
pub cache: caches::AppCache<'static>,
|
||||
pub db: crate::db::DB,
|
||||
}
|
||||
|
||||
/// Type alias to be used just as `data: AppData,` extractor in [`actix_web`] request handlers.
|
||||
@@ -17,7 +18,11 @@ pub type AppData = static_app_data::StaticAppDataExtractor<&'static AppState>;
|
||||
|
||||
/// 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<()> {
|
||||
pub async fn start_app(
|
||||
args: crate::args::Args,
|
||||
config: crate::conf::Config,
|
||||
db: crate::db::DB,
|
||||
) -> io::Result<()> {
|
||||
use crate::consts::web_scopes as ws;
|
||||
|
||||
let config = Box::leak(Box::new(config));
|
||||
@@ -28,6 +33,7 @@ pub async fn start_app(args: crate::args::Args, config: crate::conf::Config) ->
|
||||
args,
|
||||
config,
|
||||
cache,
|
||||
db,
|
||||
}));
|
||||
|
||||
println!(
|
||||
@@ -41,6 +47,7 @@ pub async fn start_app(args: crate::args::Args, config: crate::conf::Config) ->
|
||||
.wrap(middleware::Logger::new("%a (%{r}a) %r -> %s, %b B in %T s"))
|
||||
.wrap(middleware::NormalizePath::trim())
|
||||
.service(services::images::make_scope(ws::IMAGES))
|
||||
.service(services::login::make_scope(ws::LOGIN))
|
||||
.default_service(web::to(services::not_found::not_found))
|
||||
})
|
||||
.bind(&app.config.server.listen)?
|
||||
|
||||
54
src/server/services/login.rs
Normal file
54
src/server/services/login.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use actix_web::{
|
||||
HttpResponse, get, post,
|
||||
web::{self, Path},
|
||||
};
|
||||
|
||||
use crate::{consts, server::AppData};
|
||||
|
||||
#[must_use]
|
||||
pub fn make_scope(path: &str) -> actix_web::Scope {
|
||||
web::scope(path)
|
||||
.service(login)
|
||||
.service(login_post)
|
||||
.service(activate)
|
||||
}
|
||||
|
||||
#[get("")]
|
||||
pub async fn login(_data: AppData) -> HttpResponse {
|
||||
HttpResponse::Ok().content_type(consts::mime::HTML).body(
|
||||
"<html><body>\
|
||||
<form method=\"post\">\
|
||||
<h2>Username</h2>\
|
||||
<input type=\"text\" placeholder=\"username\"/>\
|
||||
<h2>Password</h2>\
|
||||
<input type=\"password\" placeholder=\"password\"/>\
|
||||
<button type=\"submit\">Login</button>\
|
||||
</form>\
|
||||
</body></html>",
|
||||
)
|
||||
}
|
||||
|
||||
#[post("")]
|
||||
pub async fn login_post(_data: AppData) -> HttpResponse {
|
||||
HttpResponse::NotImplemented()
|
||||
.content_type(consts::mime::HTML)
|
||||
.body(
|
||||
"<html><body>\
|
||||
<h1>TODO</h1>\
|
||||
</body></html>",
|
||||
)
|
||||
}
|
||||
|
||||
#[get("/activate/{username}")]
|
||||
pub async fn activate(data: AppData, username: Path<String>) -> HttpResponse {
|
||||
let keys = data.cache.get_authorized_keys(&username).await;
|
||||
|
||||
match keys {
|
||||
Ok(keys) => HttpResponse::Ok()
|
||||
.content_type(consts::mime::TEXT)
|
||||
.body(format!("{keys:#?}")),
|
||||
Err(err) => HttpResponse::BadRequest()
|
||||
.content_type(consts::mime::TEXT)
|
||||
.body(err.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod images;
|
||||
pub mod not_found;
|
||||
pub mod login;
|
||||
|
||||
54
src/utils.rs
54
src/utils.rs
@@ -83,3 +83,57 @@ pub mod shasum {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod fs {
|
||||
use std::{io, os::unix::fs::MetadataExt as _, path::Path};
|
||||
|
||||
use tokio::{fs::File, io::AsyncReadExt};
|
||||
|
||||
/// # Errors
|
||||
///
|
||||
/// On any underlaying file i/o error.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// If a file's [`u64`] doesn't fit in memory.
|
||||
pub 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)
|
||||
}
|
||||
|
||||
/// # Errors
|
||||
///
|
||||
/// On any underlaying file i/o error.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// If a file's [`u64`] doesn't fit in memory.
|
||||
pub async fn read_limited_path_str<const MAXSIZE: u64>(path: &Path) -> io::Result<String> {
|
||||
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 = String::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_string(&mut buf).await?;
|
||||
Ok(buf)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user