init
This commit is contained in:
commit
c5731665a3
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
target
|
||||
Cargo.lock
|
||||
**/.*.sw*
|
26
Cargo.toml
Normal file
26
Cargo.toml
Normal file
|
@ -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" ] }
|
11
src/cli.rs
Normal file
11
src/cli.rs
Normal file
|
@ -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,
|
||||
}
|
19
src/error.rs
Normal file
19
src/error.rs
Normal file
|
@ -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 },
|
||||
}
|
35
src/main.rs
Normal file
35
src/main.rs
Normal file
|
@ -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(())
|
||||
}
|
3
src/routes/index.rs
Normal file
3
src/routes/index.rs
Normal file
|
@ -0,0 +1,3 @@
|
|||
pub async fn route() -> &'static str {
|
||||
"Hello world"
|
||||
}
|
57
src/routes/login.rs
Normal file
57
src/routes/login.rs
Normal file
|
@ -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<S, B> FromRequest<S, B> for LoginForm
|
||||
where
|
||||
Json<LoginForm>: FromRequest<(), B>,
|
||||
Form<LoginForm>: FromRequest<(), B>,
|
||||
TypedMultipart<LoginForm>: FromRequest<S, B>,
|
||||
B::Data: Into<axum::body::Bytes>,
|
||||
B::Error: Into<axum::BoxError> + Send + std::error::Error,
|
||||
B: Send + 'static + axum::body::HttpBody,
|
||||
S: Send
|
||||
{
|
||||
type Rejection = Response;
|
||||
|
||||
async fn from_request(req: Request<B>, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
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<LoginForm> = 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<LoginForm> = 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)
|
||||
}
|
24
src/routes/mod.rs
Normal file
24
src/routes/mod.rs
Normal file
|
@ -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<String>, 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
|
||||
}
|
||||
}
|
26
src/state/mod.rs
Normal file
26
src/state/mod.rs
Normal file
|
@ -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<AppState>;
|
||||
|
||||
pub struct AppState {
|
||||
sessions: SessionManager,
|
||||
users: YunohostUsers,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub async fn new() -> Result<AppState, Error> {
|
||||
Ok(AppState {
|
||||
sessions: SessionManager::new().context(SessionSnafu)?,
|
||||
// Timeout in ms
|
||||
users: YunohostUsers::new(500).await.context(YunohostSnafu)?,
|
||||
})
|
||||
}
|
||||
}
|
179
src/state/sessions.rs
Normal file
179
src/state/sessions.rs
Normal file
|
@ -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<Vec<Cookie>>,
|
||||
/// 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<Vec<Cookie>>,
|
||||
/// Expiration duration for set cookies, in seconds
|
||||
pub expiration_secs: u64,
|
||||
|
||||
}
|
||||
|
||||
impl SessionManager {
|
||||
pub fn new() -> Result<SessionManager, SessionError> {
|
||||
//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<Option<Username>, 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<Vec<Cookie>>, timestamp: i64, content: &str) -> Option<Cookie> {
|
||||
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<u8>,
|
||||
/// 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<i64, SessionError> {
|
||||
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<Option<i64>, 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()
|
||||
}
|
||||
}
|
2
src/utils/mod.rs
Normal file
2
src/utils/mod.rs
Normal file
|
@ -0,0 +1,2 @@
|
|||
pub mod time;
|
||||
pub mod socket;
|
130
src/utils/socket.rs
Normal file
130
src/utils/socket.rs
Normal file
|
@ -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<Option<Result<Self::Conn, Self::Error>>> {
|
||||
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<Result<usize, io::Error>> {
|
||||
Pin::new(&mut self.stream).poll_write(cx, buf)
|
||||
}
|
||||
|
||||
fn poll_flush(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
) -> Poll<Result<(), io::Error>> {
|
||||
Pin::new(&mut self.stream).poll_flush(cx)
|
||||
}
|
||||
|
||||
fn poll_shutdown(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
) -> Poll<Result<(), io::Error>> {
|
||||
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<io::Result<()>> {
|
||||
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<tokio::net::unix::SocketAddr>,
|
||||
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::<UdsConnectInfo>())
|
||||
.await.context(ServerSnafu)?;
|
||||
|
||||
Ok(())
|
||||
}
|
5
src/utils/time.rs
Normal file
5
src/utils/time.rs
Normal file
|
@ -0,0 +1,5 @@
|
|||
use chrono::Utc;
|
||||
|
||||
pub fn now() -> i64 {
|
||||
Utc::now().timestamp()
|
||||
}
|
26
src/webserver/mod.rs
Normal file
26
src/webserver/mod.rs
Normal file
|
@ -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::<UdsConnectInfo>())
|
||||
.await.context(ServerSnafu)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn handler(ConnectInfo(info): ConnectInfo<UdsConnectInfo>) -> &'static str {
|
||||
println!("new connection from `{:?}`", info);
|
||||
|
||||
"Hello, World!"
|
||||
}
|
21
yunohost-api/Cargo.toml
Normal file
21
yunohost-api/Cargo.toml
Normal file
|
@ -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" ] }
|
39
yunohost-api/examples/info.rs
Normal file
39
yunohost-api/examples/info.rs
Normal file
|
@ -0,0 +1,39 @@
|
|||
use yunohost_api::{YunohostUsers, Username};
|
||||
|
||||
use std::io::{BufRead, Stdin, stdin};
|
||||
use std::str::FromStr;
|
||||
|
||||
fn acquire_loop<T: FromStr>(input: &Stdin, message: &str) -> T
|
||||
where <T as FromStr>::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!");
|
||||
}
|
||||
}
|
||||
}
|
40
yunohost-api/examples/login.rs
Normal file
40
yunohost-api/examples/login.rs
Normal file
|
@ -0,0 +1,40 @@
|
|||
use yunohost_api::{YunohostUsers, Username, Password};
|
||||
|
||||
use std::io::{BufRead, Stdin, stdin};
|
||||
use std::str::FromStr;
|
||||
|
||||
fn acquire_loop<T: FromStr>(input: &Stdin, message: &str) -> T
|
||||
where <T as FromStr>::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");
|
||||
}
|
||||
}
|
||||
}
|
91
yunohost-api/src/cache.rs
Executable file
91
yunohost-api/src/cache.rs
Executable file
|
@ -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<Vec<u8>> so we support more than JSON/FromStr?
|
||||
pub trait Cachable: Clone + std::fmt::Debug + for <'a> Deserialize<'a> + Default {}
|
||||
|
||||
impl<T: Clone + std::fmt::Debug + for<'a> Deserialize<'a> + Default> Cachable for T {}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct JsonCacheInner<T: Cachable> {
|
||||
mtime: i64,
|
||||
content: T,
|
||||
}
|
||||
|
||||
impl<T: Cachable> JsonCacheInner<T> {
|
||||
pub fn new() -> JsonCacheInner<T> {
|
||||
JsonCacheInner {
|
||||
mtime: i64::MIN,
|
||||
content: T::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self) -> T {
|
||||
self.content.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct JsonCache<T: Cachable> {
|
||||
path: PathBuf,
|
||||
inner: RwLock<JsonCacheInner<T>>,
|
||||
}
|
||||
|
||||
impl<T: Cachable> JsonCache<T> {
|
||||
/// Prepares a cached JSON file. Does not load from disk.
|
||||
pub fn new(path: &Path) -> JsonCache<T> {
|
||||
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<JsonCache<T>, Error> {
|
||||
let cache = Self::new(path);
|
||||
cache.reload(0)?;
|
||||
Ok(cache)
|
||||
}
|
||||
|
||||
pub fn get(&self) -> Result<T, Error> {
|
||||
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<Option<i64>, 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(())
|
||||
}
|
||||
}
|
83
yunohost-api/src/credentials.rs
Normal file
83
yunohost-api/src/credentials.rs
Normal file
|
@ -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<String> {
|
||||
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<Username, Error> {
|
||||
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, Error> {
|
||||
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<Password, Error> {
|
||||
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, Error> {
|
||||
Password::new(s)
|
||||
}
|
||||
}
|
33
yunohost-api/src/error.rs
Normal file
33
yunohost-api/src/error.rs
Normal file
|
@ -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 },
|
||||
}
|
57
yunohost-api/src/helpers.rs
Normal file
57
yunohost-api/src/helpers.rs
Normal file
|
@ -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()
|
||||
}
|
110
yunohost-api/src/ldap.rs
Normal file
110
yunohost-api/src/ldap.rs
Normal file
|
@ -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<Ldap, Error> {
|
||||
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<RwLock<Ldap>>,
|
||||
}
|
||||
|
||||
impl YunohostUsers {
|
||||
/// Open a new connection to Yunohost LDAP database with a specific timeout in milliseconds
|
||||
pub async fn new(timeout: u64) -> Result<YunohostUsers, Error> {
|
||||
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<bool, Error> {
|
||||
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<bool, Error> {
|
||||
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)
|
||||
}
|
||||
}
|
10
yunohost-api/src/lib.rs
Normal file
10
yunohost-api/src/lib.rs
Normal file
|
@ -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};
|
30
yunohost-api/src/permissions/mod.rs
Normal file
30
yunohost-api/src/permissions/mod.rs
Normal file
|
@ -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<SSOWatConfig>,
|
||||
}
|
||||
|
||||
impl YunohostPermissions {
|
||||
pub fn new() -> Result<YunohostPermissions, Error> {
|
||||
Self::from_path("/etc/ssowat/conf.json")
|
||||
}
|
||||
|
||||
pub fn from_path<T: AsRef<Path>>(path: T) -> Result<YunohostPermissions, Error> {
|
||||
let path = path.as_ref();
|
||||
let ssowat: JsonCache<SSOWatConfig> = JsonCache::load(path)?;
|
||||
Ok(YunohostPermissions {
|
||||
ssowat,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ssowat_config(&self) -> Result<SSOWatConfig, Error> {
|
||||
self.ssowat.get()
|
||||
}
|
||||
}
|
50
yunohost-api/src/permissions/ssowat.rs
Normal file
50
yunohost-api/src/permissions/ssowat.rs
Normal file
|
@ -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<String>,
|
||||
permissions: HashMap<PermissionName, Permission>,
|
||||
portal_domain: String,
|
||||
portal_path: String,
|
||||
redirected_urls: HashMap<String, String>,
|
||||
theme: String,
|
||||
}
|
||||
|
||||
impl SSOWatConfig {
|
||||
pub fn permission_for_uri(&self, uri: &Url) -> Option<PermissionName> {
|
||||
// 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<String>,
|
||||
use_remote_user_in_nginx_conf: bool,
|
||||
users: Vec<Username>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct PermissionName {
|
||||
#[serde(flatten)]
|
||||
name: String,
|
||||
}
|
Loading…
Reference in New Issue
Block a user