commit c5731665a35e6ae79a9bea5b0e4aeaacd48ca42e Author: selfhoster1312 Date: Fri Aug 18 10:59:50 2023 +0200 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f5cbd1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +target +Cargo.lock +**/.*.sw* diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..79e387b --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "yunohome" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +futures = "0.3" +tower = "0.4" +tokio = { version = "1", features = [ "full" ] } +hyper = { version = "0.14", features = [ "full" ] } +axum = { version = "0.6", features = [ "headers", "http2", "macros", "tracing" ] } +clap = { version = "4.3", features = [ "derive" ] } +snafu = "0.7" +log = "0.4" +env_logger = "0.10" +#cookie = "0.17" +#fastrand = "2" +ring = "0.16" +hex = "0.4" +chrono = { version = "0.4", features = [ "serde" ] } +yunohost-api = { path = "yunohost-api" } +axum_typed_multipart = "0.8" +async-trait = "0.1" +serde = { version = "1", features = [ "derive" ] } \ No newline at end of file diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..acfabd8 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,11 @@ +use clap::Parser; + +use std::path::PathBuf; + +/// The main SSOWat program +#[derive(Debug, Parser)] +#[command(author, version, about, long_about = None)] +pub struct Cli { + /// Where to place the UNIX socket for SSOWat + pub path: PathBuf, +} \ No newline at end of file diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..bdecee0 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,19 @@ +use snafu::Snafu; + +use std::path::PathBuf; + +#[derive(Debug, Snafu)] +#[snafu(visibility(pub))] +pub enum Error { + #[snafu(display("Failed to spawn unix socket at {}", path.display()))] + SocketCreate { path: PathBuf, source: std::io::Error }, + + #[snafu(display("Failed to spawn a web server"))] + Server { source: hyper::Error }, + + #[snafu(display("{}", source))] + Yunohost { source: yunohost_api::Error }, + + #[snafu(display("{}", source))] + Session { source: crate::state::sessions::SessionError }, +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..abc0059 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,35 @@ +#[macro_use] extern crate async_trait; +#[macro_use] extern crate axum; +#[macro_use] extern crate serde; + +use clap::Parser; + +use std::sync::Arc; + +mod cli; +mod error; +mod routes; +mod state; +mod utils; + +#[tokio::main] +async fn main() -> Result<(), error::Error> { + env_logger::init(); + + let args = cli::Cli::parse(); + let path = args.path.clone(); + + let _ = tokio::fs::remove_file(&path).await; + tokio::fs::create_dir_all(path.parent().unwrap()) + .await + .unwrap(); + + let state = Arc::new( + state::AppState::new().await? + ); + + let app = routes::router(Some("/ssowat/".to_string()), state); + utils::socket::serve(&path, app).await?; + + Ok(()) +} diff --git a/src/routes/index.rs b/src/routes/index.rs new file mode 100644 index 0000000..c060147 --- /dev/null +++ b/src/routes/index.rs @@ -0,0 +1,3 @@ +pub async fn route() -> &'static str { + "Hello world" +} diff --git a/src/routes/login.rs b/src/routes/login.rs new file mode 100644 index 0000000..c7d018a --- /dev/null +++ b/src/routes/login.rs @@ -0,0 +1,57 @@ +use axum::{ + extract::{FromRequest, Form, Json, Query}, + http::{self, Request, StatusCode}, + response::{IntoResponse, Response}, + RequestExt, +}; +use axum_typed_multipart::{TryFromMultipart, TypedMultipart}; + +#[derive(Debug, TryFromMultipart, Deserialize)] +pub struct LoginForm { + username: String, + #[allow(dead_code)] + password: String, +} + +#[async_trait] +impl FromRequest for LoginForm +where + Json: FromRequest<(), B>, + Form: FromRequest<(), B>, + TypedMultipart: FromRequest, + B::Data: Into, + B::Error: Into + Send + std::error::Error, + B: Send + 'static + axum::body::HttpBody, + S: Send +{ + type Rejection = Response; + + async fn from_request(req: Request, _state: &S) -> Result { + let headers = req.headers(); + + if let Some(mime) = headers.get(http::header::CONTENT_TYPE).and_then(|v| v.to_str().ok()) { + if mime.starts_with("application/json") { + let Json(login_form): Json = req.extract().await.map_err(IntoResponse::into_response)?; + return Ok(login_form); + } + + if mime.starts_with("application/x-www-form-urlencoded") { + let Form(login_form) = req.extract().await.map_err(IntoResponse::into_response)?; + return Ok(login_form); + } + + if mime.starts_with("multipart/form-data") { + let TypedMultipart(login_form): TypedMultipart = req.extract().await.map_err(IntoResponse::into_response)?; + return Ok(login_form); + } + Err(StatusCode::UNSUPPORTED_MEDIA_TYPE.into_response()) + } else { + Err("No POST Content-Type".into_response()) + } + } +} + +#[debug_handler] +pub async fn route(form: LoginForm) -> String { + format!("Welcome {}", form.username) +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs new file mode 100644 index 0000000..a8eb98b --- /dev/null +++ b/src/routes/mod.rs @@ -0,0 +1,24 @@ +use axum::{ + extract::State, + routing::{get, post}, + Router, +}; + +use crate::state::RoutableAppState; + +mod index; +mod login; + +/// Build a router for the application, in a specific subpath eg `/yunohost/sso/` +pub fn router(subpath: Option, state: RoutableAppState) -> Router { + let app = Router::new() + .route("/", get(index::route)) + .route("/login/", get(login::route)) + .with_state(state); + if let Some(p) = subpath { + Router::new() + .nest(&p, app) + } else { + app + } +} \ No newline at end of file diff --git a/src/state/mod.rs b/src/state/mod.rs new file mode 100644 index 0000000..8e712eb --- /dev/null +++ b/src/state/mod.rs @@ -0,0 +1,26 @@ +use snafu::prelude::*; +use yunohost_api::YunohostUsers; + +use std::sync::Arc; + +use crate::error::*; + +pub mod sessions; +use sessions::SessionManager; + +pub type RoutableAppState = Arc; + +pub struct AppState { + sessions: SessionManager, + users: YunohostUsers, +} + +impl AppState { + pub async fn new() -> Result { + Ok(AppState { + sessions: SessionManager::new().context(SessionSnafu)?, + // Timeout in ms + users: YunohostUsers::new(500).await.context(YunohostSnafu)?, + }) + } +} diff --git a/src/state/sessions.rs b/src/state/sessions.rs new file mode 100644 index 0000000..d5ea720 --- /dev/null +++ b/src/state/sessions.rs @@ -0,0 +1,179 @@ +use ring::{hmac,rand}; +use snafu::prelude::*; +use tokio::sync::RwLock; +use yunohost_api::Username; + +use crate::utils::time::now; + +/// An error related to session management +#[derive(Debug, Snafu)] +#[snafu(visibility(pub))] +pub enum SessionError { + #[snafu(display("Some cryptographic operation failed due to evil gnomes"))] + Crypto, + #[snafu(display("Malformed cookie: {}", content))] + MalformedCookie { content: String }, + #[snafu(display("Malformed hex cookie signature: {}", sig))] + MalformedCookieSig { sig: String, source: hex::FromHexError }, + #[snafu(display("Malformed cookie UNIX timestamp: {}", timestamp))] + MalformedCookieTimestamp { timestamp: String, source: std::num::ParseIntError }, + #[snafu(display("Malformed cookie username (empty)"))] + MalformedCookieUsername { source: yunohost_api::Error }, +} + +/// Holds the currently active cookie-based user sessions for a certain cookie type. +/// Sessions are automatically invalidated when the application is restarted, +/// as the `secret` material is regenerated randomly. They are also invalidated +/// after some time. +pub struct SessionManager { + /// The time of start, used for invalidated expired sessions + pub start_time: i64, + /// The secret material used for signing/encrypting cookies + pub secret: hmac::Key, + /// The list of currently valid cookies. Some of them may have been invalidated, + /// but they are stored in a different list to guarantee fast race-free access to this one. + pub cookies: RwLock>, + /// The list of invalidated cookies, due to logout or other purging mechanisms. + /// This list is behind a mutex (RwLock) to prevent race conditions + pub invalidated_cookies: RwLock>, + /// Expiration duration for set cookies, in seconds + pub expiration_secs: u64, + +} + +impl SessionManager { + pub fn new() -> Result { + //let rng = Rng::new(); + let rng = rand::SystemRandom::new(); + let key = hmac::Key::generate(hmac::HMAC_SHA256, &rng).map_err(|_| SessionError::Crypto)?; + + Ok(SessionManager { + start_time: now(), + secret: key, + cookies: RwLock::new(Vec::new()), + invalidated_cookies: RwLock::new(Vec::new()), + // TODO: make expiration configurable + expiration_secs: 3600 * 24 * 7 + }) + } + + /// Validates that a submitted cookie is a valid session. Returns: + /// - Err(_) if the cookie format is really wrong + /// - Ok(Some(name)) if the user is still logged in + /// - Ok(None) if the user is no longer logged in (invalid/expired cookie) + pub async fn verify_cookie(&self, cookie_claim: &str) -> Result, SessionError> { + // First check the expiration of the claimed cookie. + // If the timestamp was messed with, it will fail further verification. + if let Some(claimed_timestamp) = Cookie::has_expired(cookie_claim, self.expiration_secs)? { + // The claimed timestamp is still valid. Check if we ever had that cookie in memory. + // If the server was restarted, user will have to login again. + if let Some(valid_cookie) = self.find_cookie(&self.cookies, claimed_timestamp, &cookie_claim).await { + // Make sure the session hasn't been invalidated + if let Some(_invalidated_cookie) = self.find_cookie(&self.invalidated_cookies, claimed_timestamp, &cookie_claim).await { + // User has logged out or been removed from the system + Ok(None) + } else { + // User is still logged in! + Ok(Some(valid_cookie.username())) + } + } else { + // User doesn't have an active session + Ok(None) + } + } else { + // Claimed Cookie timestamp has expired + return Ok(None); + } + } + + /// Generates a new valid cookie inside the `SessionManager`, and returns: + /// - the cookie name to be set + /// - the cookie content that can be sent to a client + pub async fn make_session(&mut self, cookie_name: &str, username: &Username) -> (String, String) { + let now = now(); + let signable_payload = format!("{now}:{cookie_name}:{username}"); + let signed_payload = hmac::sign(&self.secret, signable_payload.as_bytes()).as_ref().to_vec(); + let cookie_payload = format!( + "{}:{}", + signable_payload, + hex::encode(&signed_payload), + ); + + let cookie = Cookie { + cookie_name: cookie_name.to_string(), + timestamp: now, + username: username.clone(), + signature: signed_payload.clone(), + content: cookie_payload.clone(), + }; + + { + // We don't want to block too long the cookie jar + // So it's only locked for this block (then dropped) + let mut jar = self.cookies.write().await; + jar.push(cookie); + } + + (cookie_name.to_string(), cookie_payload) + } + + /// Helper method to find a cookie with a specific timestamp, name and username in a cookie jar + async fn find_cookie(&self, jar: &RwLock>, timestamp: i64, content: &str) -> Option { + jar.read().await.iter().find(|cookie| { + // First compare the timestamp (cheapest operation to invalidate the match) + cookie.timestamp == timestamp + && cookie.content == content + }).cloned() + } +} + +/// A signed/encrypted as stored in memory. +#[derive(Clone, Debug)] +pub struct Cookie { + /// The POSIX timetamp this cookie was created at + pub timestamp: i64, + /// The cookie name + pub cookie_name: String, + /// The username for which this cookie is valid + pub username: Username, + /// The cryptographic signature going with the cookie + pub signature: Vec, + /// The stringy representation of the cookie, to be received/sent with clients + pub content: String, +} + +impl Cookie { + /// Extrats the timestamp from a stringy cookie + pub fn timestamp(cookie: &str) -> Result { + let (timestamp, _rest) = cookie.split_once(':') + .context(MalformedCookieSnafu { content: cookie.to_string() })?; + let timestamp: i64 = timestamp.parse().context(MalformedCookieTimestampSnafu { timestamp })?; + + Ok(timestamp) + } + + /// Verifies whether a given cookie string has expired, before parsing it entirely. Returns: + /// - Err(SessionError) when the cookie is malformed + /// - Ok(Some(timestamp)) when the cookie is still valid + /// - Ok(None) when the cookie has expired + /// Will error if the cookie is misformed. + pub fn has_expired(cookie: &str, expiration_secs: u64) -> Result, SessionError> { + let timestamp = Self::timestamp(cookie)?; + + if let Some(expiration) = timestamp.checked_add_unsigned(expiration_secs) { + if expiration >= now() { + Ok(Some(timestamp)) + } else { + Ok(None) + } + } else { + // Addition overflowed. System clock is broken, expiration is set too high, or the client tried to trick us. + Ok(None) + } + } + + /// The typed [`yunohost_api::Username`] for which this cookie is deemed valid. + pub fn username(&self) -> Username { + self.username.clone() + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..07d42f3 --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,2 @@ +pub mod time; +pub mod socket; \ No newline at end of file diff --git a/src/utils/socket.rs b/src/utils/socket.rs new file mode 100644 index 0000000..31a6839 --- /dev/null +++ b/src/utils/socket.rs @@ -0,0 +1,130 @@ +use axum::{ + Router, + extract::connect_info, +}; +use futures::ready; +use hyper::{ + client::connect::{Connected, Connection}, + server::accept::Accept, +}; +use snafu::prelude::*; +use std::{ + io, + path::Path, + pin::Pin, + sync::Arc, + task::{Context, Poll}, +}; +use tokio::{ + io::{AsyncRead, AsyncWrite}, + net::{unix::UCred, UnixListener, UnixStream}, +}; +use tower::BoxError; + +use crate::error::*; + +pub struct ServerAccept { + uds: UnixListener, +} + +impl ServerAccept { + pub fn new(uds: UnixListener) -> ServerAccept { + ServerAccept { + uds, + } + } +} + +impl Accept for ServerAccept { + type Conn = UnixStream; + type Error = BoxError; + + fn poll_accept( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll>> { + let (stream, _addr) = ready!(self.uds.poll_accept(cx))?; + Poll::Ready(Some(Ok(stream))) + } +} + +pub struct ClientConnection { + stream: UnixStream, +} + +impl AsyncWrite for ClientConnection { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.stream).poll_write(cx, buf) + } + + fn poll_flush( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll> { + Pin::new(&mut self.stream).poll_flush(cx) + } + + fn poll_shutdown( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll> { + Pin::new(&mut self.stream).poll_shutdown(cx) + } +} + +impl AsyncRead for ClientConnection { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut tokio::io::ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.stream).poll_read(cx, buf) + } +} + +impl Connection for ClientConnection { + fn connected(&self) -> Connected { + Connected::new() + } +} + +#[derive(Clone, Debug)] +#[allow(dead_code)] +pub struct UdsConnectInfo { + peer_addr: Arc, + peer_cred: UCred, +} + +impl connect_info::Connected<&UnixStream> for UdsConnectInfo { + fn connect_info(target: &UnixStream) -> Self { + let peer_addr = target.peer_addr().unwrap(); + let peer_cred = target.peer_cred().unwrap(); + + Self { + peer_addr: Arc::new(peer_addr), + peer_cred, + } + } +} + +/// Serve a webapp on UNIX socket path +pub async fn serve(path: &Path, app: Router) -> Result<(), Error> { + let _ = tokio::fs::remove_file(&path).await; + tokio::fs::create_dir_all(path.parent().unwrap()) + .await + .unwrap(); + + // TODO: set permissions + + let uds = UnixListener::bind(path.clone()) + .context(SocketCreateSnafu { path: path.clone() })?; + hyper::Server::builder(ServerAccept::new(uds)) + .serve(app.into_make_service_with_connect_info::()) + .await.context(ServerSnafu)?; + + Ok(()) +} diff --git a/src/utils/time.rs b/src/utils/time.rs new file mode 100644 index 0000000..86bc693 --- /dev/null +++ b/src/utils/time.rs @@ -0,0 +1,5 @@ +use chrono::Utc; + +pub fn now() -> i64 { + Utc::now().timestamp() +} diff --git a/src/webserver/mod.rs b/src/webserver/mod.rs new file mode 100644 index 0000000..3f0ac88 --- /dev/null +++ b/src/webserver/mod.rs @@ -0,0 +1,26 @@ +use axum::Router; +use axum::extract::connect_info::ConnectInfo; +use snafu::prelude::*; +use tokio::net::UnixListener; + +use std::path::Path; + +use crate::error::*; +mod utils; +pub use utils::*; + +pub async fn serve_socket(path: &Path, app: Router) -> Result<(), Error> { + let uds = UnixListener::bind(path.clone()) + .context(SocketCreateSnafu { path: path.clone() })?; + hyper::Server::builder(ServerAccept::new(uds)) + .serve(app.into_make_service_with_connect_info::()) + .await.context(ServerSnafu)?; + + Ok(()) +} + +pub async fn handler(ConnectInfo(info): ConnectInfo) -> &'static str { + println!("new connection from `{:?}`", info); + + "Hello, World!" +} diff --git a/yunohost-api/Cargo.toml b/yunohost-api/Cargo.toml new file mode 100644 index 0000000..b55ac5c --- /dev/null +++ b/yunohost-api/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "yunohost-api" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +ldap3 = "0.11" +env_logger = "0.10" +log = "0.4" +tokio = "1" +snafu = "0.7" +serde = { version = "1", features = [ "derive" ] } +serde_json = "1" +regex = "1.9" +url = "2.4" + +[dev-dependencies] +scan-rules = "0.2" +tokio = { version = "1", features = [ "sync", "rt" ] } diff --git a/yunohost-api/examples/info.rs b/yunohost-api/examples/info.rs new file mode 100644 index 0000000..e7b5fae --- /dev/null +++ b/yunohost-api/examples/info.rs @@ -0,0 +1,39 @@ +use yunohost_api::{YunohostUsers, Username}; + +use std::io::{BufRead, Stdin, stdin}; +use std::str::FromStr; + +fn acquire_loop(input: &Stdin, message: &str) -> T +where ::Err: std::fmt::Display { + loop { + println!("{message}"); + let value = input.lock().lines().next().unwrap().unwrap(); + match T::from_str(&value) { + Ok(v) => { + return v; + }, Err(e) => { + println!("{}", e); + } + } + } +} + +#[tokio::main(flavor = "current_thread")] +async fn main() { + env_logger::init(); + + let input = stdin(); + let users = YunohostUsers::new(500).await.unwrap(); + + loop { + println!("Welcome to Yunohost! What user would you like to know about?"); + + let username: Username = acquire_loop(&input, "Username:"); + + if users.get_user(&username).await.unwrap() { + println!("User found!"); + } else { + println!("User not found!"); + } + } +} diff --git a/yunohost-api/examples/login.rs b/yunohost-api/examples/login.rs new file mode 100644 index 0000000..b20f292 --- /dev/null +++ b/yunohost-api/examples/login.rs @@ -0,0 +1,40 @@ +use yunohost_api::{YunohostUsers, Username, Password}; + +use std::io::{BufRead, Stdin, stdin}; +use std::str::FromStr; + +fn acquire_loop(input: &Stdin, message: &str) -> T +where ::Err: std::fmt::Display { + loop { + println!("{message}"); + let value = input.lock().lines().next().unwrap().unwrap(); + match T::from_str(&value) { + Ok(v) => { + return v; + }, Err(e) => { + println!("{}", e); + } + } + } +} + +#[tokio::main(flavor = "current_thread")] +async fn main() { + env_logger::init(); + + let input = stdin(); + let users = YunohostUsers::new(500).await.unwrap(); + + loop { + println!("Welcome to Yunohost! Please login to continue..."); + + let username: Username = acquire_loop(&input, "Username:"); + let password: Password = acquire_loop(&input, "Password:"); + + if users.check_credentials(&username, &password).await.unwrap() { + println!("Successful login"); + } else { + println!("Failed login"); + } + } +} diff --git a/yunohost-api/src/cache.rs b/yunohost-api/src/cache.rs new file mode 100755 index 0000000..1c155fc --- /dev/null +++ b/yunohost-api/src/cache.rs @@ -0,0 +1,91 @@ +use serde::Deserialize; +use snafu::prelude::*; + +use std::os::unix::fs::MetadataExt; +use std::path::{Path, PathBuf}; +use std::sync::RwLock; + +use crate::error::*; + +// TODO: Can we return reference to the inner data without cloning? +// Cloning is MUCH cheaper than reading from disk so it's not really a problem +// TODO: Should we TryFrom> so we support more than JSON/FromStr? +pub trait Cachable: Clone + std::fmt::Debug + for <'a> Deserialize<'a> + Default {} + +impl Deserialize<'a> + Default> Cachable for T {} + +#[derive(Debug)] +pub struct JsonCacheInner { + mtime: i64, + content: T, +} + +impl JsonCacheInner { + pub fn new() -> JsonCacheInner { + JsonCacheInner { + mtime: i64::MIN, + content: T::default(), + } + } + + pub fn get(&self) -> T { + self.content.clone() + } +} + +#[derive(Debug)] +pub struct JsonCache { + path: PathBuf, + inner: RwLock>, +} + +impl JsonCache { + /// Prepares a cached JSON file. Does not load from disk. + pub fn new(path: &Path) -> JsonCache { + JsonCache { + path: path.to_path_buf(), + inner: RwLock::new(JsonCacheInner::new()), + } + } + + /// Loads a cached JSON file from disk to memory + pub fn load(path: &Path) -> Result, Error> { + let cache = Self::new(path); + cache.reload(0)?; + Ok(cache) + } + + pub fn get(&self) -> Result { + if let Some(new_mtime) = self.stale()? { + self.reload(new_mtime)?; + } + + let lock = self.inner.read().unwrap(); + let content = lock.get(); + + Ok(content) + } + + pub fn stale(&self) -> Result, Error> { + let metadata = self.path.metadata().context(ReadFileSnafu { path: self.path.clone() })?; + let inner = self.inner.read().unwrap(); + let mtime = metadata.mtime(); + if mtime != inner.mtime { + // Needs reloading + Ok(Some(mtime)) + } else { + // Data still valid + Ok(None) + } + } + + fn reload(&self, mtime: i64) -> Result<(), Error> { + let mut inner = self.inner.write().unwrap(); + let content = std::fs::read_to_string(&self.path).context(ReadFileSnafu { path: self.path.clone() })?; + //let content = T::from_str(&content)?; + let content: T = serde_json::from_str(&content).context(InvalidJsonSnafu { path: self.path.clone() })?; + inner.content = content; + inner.mtime = mtime; + Ok(()) + } +} diff --git a/yunohost-api/src/credentials.rs b/yunohost-api/src/credentials.rs new file mode 100644 index 0000000..8921e26 --- /dev/null +++ b/yunohost-api/src/credentials.rs @@ -0,0 +1,83 @@ +use ldap3::dn_escape; +use serde::{Serialize, Deserialize}; +use snafu::OptionExt; + +use std::str::FromStr; + +use crate::error::*; + +fn non_empty_string(s: &str) -> Option { + if s.trim() == "" { + None + } else { + Some(s.to_string()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub struct Username(String); + +impl Username { + pub fn new(s: &str) -> Result { + non_empty_string(s).context(EmptyUsernameSnafu) + .map(|s| Username(s)) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + pub fn ldap_escape(&self) -> String { + dn_escape(self.as_str()).to_string() + } + + pub fn to_dn(&self, domains: &[&str]) -> String { + let mut dn = format!("uid={},ou=users", self.ldap_escape()); + for domain in domains { + dn.push_str(",dc="); + dn.push_str(domain); + } + + dn + } +} + +impl FromStr for Username { + type Err = Error; + + fn from_str(s: &str) -> Result { + Username::new(s) + } +} + +impl std::fmt::Display for Username { + fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(fmt, "{}", self.0) + } +} + +#[derive(Clone, Debug)] +pub struct Password(String); + +impl Password { + pub fn new(s: &str) -> Result { + non_empty_string(s).context(EmptyPasswordSnafu) + .map(|s| Password(s)) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + pub fn ldap_escape(&self) -> String { + dn_escape(self.as_str()).to_string() + } +} + +impl FromStr for Password { + type Err = Error; + + fn from_str(s: &str) -> Result { + Password::new(s) + } +} diff --git a/yunohost-api/src/error.rs b/yunohost-api/src/error.rs new file mode 100644 index 0000000..0016171 --- /dev/null +++ b/yunohost-api/src/error.rs @@ -0,0 +1,33 @@ +use snafu::Snafu; + +use std::path::PathBuf; + +use crate::Username; + +#[derive(Debug, Snafu)] +#[snafu(visibility(pub))] +pub enum Error { + #[snafu(display("Failed to establish connection to the LDAP database at path {uri}"))] + LdapInit { uri: String, source: ldap3::result::LdapError }, + + #[snafu(display("Failed to bind on the LDAP database"))] + LdapBind { source: ldap3::result::LdapError }, + + #[snafu(display("Failed to search the LDAP database"))] + LdapSearch { source: ldap3::result::LdapError }, + + #[snafu(display("No such user: {}", username.as_str()))] + LdapNoSuchUser { username: Username }, + + #[snafu(display("Empty username provided for login"))] + EmptyUsername, + + #[snafu(display("Empty password provided for login"))] + EmptyPassword, + + #[snafu(display("Failed to read file {}", path.display()))] + ReadFile { path: PathBuf, source: std::io::Error }, + + #[snafu(display("Invalid JSON in file {}", path.display()))] + InvalidJson { path: PathBuf, source: serde_json::Error }, +} diff --git a/yunohost-api/src/helpers.rs b/yunohost-api/src/helpers.rs new file mode 100644 index 0000000..c85b504 --- /dev/null +++ b/yunohost-api/src/helpers.rs @@ -0,0 +1,57 @@ +// /// Remove the leading http(s) part of a URI. +// /// ``` +// /// # use yunohost_api::SSOWatConfig; +// /// let stripped = SSOWatConfig::strip_url_protocol("http://theanarchistlibrary.org/"); +// /// # assert_eq!("theanarchistlibrary.org/", stripped) +// /// ``` +// /// Result: theanarchistlibrary.org/ +// /// ``` +// /// # use yunohost_api::SSOWatConfig; +// /// let stripped = SSOWatConfig::strip_url_protocol("misformedhttpdomain/"); +// /// # assert_eq!("misformedhttpdomain/", stripped); +// /// ``` +// /// Result: misformedhttpdomain/ +// pub fn strip_url_protocol<'a>(uri: &'a str) -> &'a str { +// let s = uri.strip_prefix("http").unwrap_or(&uri); +// let s = s.strip_prefix("s").unwrap_or(&s); +// let s = s.strip_prefix("://").unwrap_or(&s); +// s +// // uri +// // .strip_prefix("http") +// // .and_then(|s| s.strip_prefix("")) +// // .trim_start_matches("http") +// // .trim_start_matches("s") +// // .trim_start_matches("://") +// } + +/// Extracts the domain part of a http(s) URI. +/// ``` +/// # use yunohost_api::SSOWatConfig; +/// let domain = SSOWatConfig::extract_domain("https://mediaslibres.org/spip.php?page=sedna-rss"); +/// assert_eq!("mediaslibres.org", domain); +/// ``` +/// Result: mediaslibres.org +/// ``` +/// # use yunohost_api::SSOWatConfig; +/// let domain = SSOWatConfig::extract_domain("http://foo.bar.example.com"); +/// assert_eq!("foo.bar.example.com", domain); +/// ``` +/// Result: foo.bar.example.com +/// ``` +/// # use yunohost_api::SSOWatConfig; +/// let domain = SSOWatConfig::extract_domain("http://foo.bar.example.com/bar/baz"); +/// assert_eq!("foo.bar.example.com", domain); +/// ``` +/// Result: foo.bar.example.com +/// ``` +/// # use yunohost_api::SSOWatConfig; +/// let domain = SSOWatConfig::extract_domain("misformedhttpdomain"); +/// # assert_eq!("misformedhttpdomain", domain) +/// ``` +/// Result: misformedhttpdomain +pub fn extract_domain<'a>(uri: &'a str) -> &'a str { + let mut split = Self::strip_url_protocol(uri).split('/'); + // Even if nothing was matched, there should always be one split part + // If there was an additional slash, we just extracted the domain + split.next().unwrap() +} diff --git a/yunohost-api/src/ldap.rs b/yunohost-api/src/ldap.rs new file mode 100644 index 0000000..b741e0f --- /dev/null +++ b/yunohost-api/src/ldap.rs @@ -0,0 +1,110 @@ +use ldap3::{LdapConnAsync, LdapConnSettings, Ldap, Scope, exop::WhoAmI, SearchEntry}; +use snafu::prelude::*; +use tokio::sync::RwLock; + +use std::sync::Arc; +use std::time::Duration; + +use crate::credentials::{Username, Password}; +use crate::error::*; + +const LDAP_PATH: &'static str = "ldapi://%2Fvar%2Frun%2Fslapd%2Fldapi"; + +/// Opens a new LDAP connection. Does not guarantee it will stay alive +async fn new_ldap(timeout: Duration) -> Result { + let settings = LdapConnSettings::new().set_conn_timeout(timeout); + log::info!("Opening new LDAP connection with timeout: {}ms", timeout.as_millis()); + + let (conn, ldap) = LdapConnAsync::with_settings( + settings, + LDAP_PATH + ).await.context(LdapInitSnafu { uri: LDAP_PATH.to_string() })?; + + tokio::spawn(async move { + if let Err(e) = conn.drive().await { + log::error!("{}", e); + } + }); + + Ok(ldap) +} + +#[derive(Clone, Debug)] +pub struct YunohostUsers { + timeout: Duration, + inner: Arc>, +} + +impl YunohostUsers { + /// Open a new connection to Yunohost LDAP database with a specific timeout in milliseconds + pub async fn new(timeout: u64) -> Result { + let timeout = Duration::from_millis(timeout); + Ok(YunohostUsers { + timeout: timeout.clone(), + inner: Arc::new(RwLock::new( + new_ldap(timeout).await? + )), + }) + } + + pub async fn keepalive(&self) -> Result<(), Error> { + { + let mut ldap = self.inner.write().await; + if ! ldap.is_closed() { + // check connection is still alive with WhoAmI + ldap.with_timeout(self.timeout.clone()); + if ldap.extended(WhoAmI).await.is_ok() { + return Ok(()); + } + } + } + log::warn!("LDAP connection has been closed. Opening again."); + let mut ldap = self.inner.clone().write_owned().await; + *ldap = new_ldap(self.timeout.clone()).await?; + Ok(()) + } + + pub async fn check_credentials(&self, username: &Username, password: &Password) -> Result { + self.keepalive().await?; + let mut ldap = self.inner.write().await; + + let username = username.to_dn(& [ "yunohost", "org" ]); + let password = password.ldap_escape(); + + log::debug!("Attempting LDAP login for {}", &username); + ldap.with_timeout(self.timeout.clone()); + let reply = ldap.simple_bind( + &username, + &password, + ).await.context(LdapBindSnafu)?; + log::debug!("{:#?}", reply); + + // Successful auth if return code is 0 + Ok(reply.rc == 0) + } + + pub async fn get_user(&self, username: &Username) -> Result { + self.keepalive().await?; + let mut ldap = self.inner.write().await; + ldap.with_timeout(self.timeout.clone()); + + let res = ldap.search( + &username.to_dn(& [ "yunohost", "org" ]), + Scope::Base, + "(objectclass=*)", + [ "+" ], + ).await.context(LdapSearchSnafu)?; + + if let Ok((res, _)) = res.success() { + // There should be only one user with this uid + let res = res.into_iter().take(1).next().unwrap(); + let res = SearchEntry::construct(res); + log::debug!("Found:\n{:#?}", res); + } else { + return Err(Error::LdapNoSuchUser { username: username.clone() }); + } + + + Ok(true) + } +} diff --git a/yunohost-api/src/lib.rs b/yunohost-api/src/lib.rs new file mode 100644 index 0000000..bb9d62b --- /dev/null +++ b/yunohost-api/src/lib.rs @@ -0,0 +1,10 @@ +mod cache; +pub use cache::JsonCache; +mod credentials; +pub use credentials::{Username, Password}; +mod error; +pub use error::Error; +mod ldap; +pub use ldap::YunohostUsers; +mod permissions; +pub use permissions::{YunohostPermissions, SSOWatConfig, PermissionName}; diff --git a/yunohost-api/src/permissions/mod.rs b/yunohost-api/src/permissions/mod.rs new file mode 100644 index 0000000..75ad0b5 --- /dev/null +++ b/yunohost-api/src/permissions/mod.rs @@ -0,0 +1,30 @@ +use std::path::Path; + +use crate::error::*; +use crate::JsonCache; + +mod ssowat; +pub use ssowat::{SSOWatConfig, PermissionName}; + +#[derive(Debug)] +pub struct YunohostPermissions { + ssowat: JsonCache, +} + +impl YunohostPermissions { + pub fn new() -> Result { + Self::from_path("/etc/ssowat/conf.json") + } + + pub fn from_path>(path: T) -> Result { + let path = path.as_ref(); + let ssowat: JsonCache = JsonCache::load(path)?; + Ok(YunohostPermissions { + ssowat, + }) + } + + pub fn ssowat_config(&self) -> Result { + self.ssowat.get() + } +} diff --git a/yunohost-api/src/permissions/ssowat.rs b/yunohost-api/src/permissions/ssowat.rs new file mode 100644 index 0000000..e34bc0f --- /dev/null +++ b/yunohost-api/src/permissions/ssowat.rs @@ -0,0 +1,50 @@ +use serde::{Serialize, Deserialize}; +use url::Url; + +use std::collections::HashMap; + +use crate::Username; + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct SSOWatConfig { + domains: Vec, + permissions: HashMap, + portal_domain: String, + portal_path: String, + redirected_urls: HashMap, + theme: String, +} + +impl SSOWatConfig { + pub fn permission_for_uri(&self, uri: &Url) -> Option { + // First check if the domain is actually managed by SSOWat + if let Some(domain) = uri.domain() { + if ! self.domains.contains(&domain.to_string()) { + // Domain not managed + return None; + } + } else { + // No domain (eg. http://8.8.8.8/) + return None; + } + + todo!(); + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct Permission { + auth_header: bool, + label: String, + public: bool, + show_tile: bool, + uris: Vec, + use_remote_user_in_nginx_conf: bool, + users: Vec, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct PermissionName { + #[serde(flatten)] + name: String, +}