This commit is contained in:
selfhoster selfhoster 2023-08-18 10:59:50 +02:00
commit c5731665a3
25 changed files with 1110 additions and 0 deletions

11
src/cli.rs Normal file
View 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
View 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
View 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
View file

@ -0,0 +1,3 @@
pub async fn route() -> &'static str {
"Hello world"
}

57
src/routes/login.rs Normal file
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,2 @@
pub mod time;
pub mod socket;

130
src/utils/socket.rs Normal file
View 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
View file

@ -0,0 +1,5 @@
use chrono::Utc;
pub fn now() -> i64 {
Utc::now().timestamp()
}

26
src/webserver/mod.rs Normal file
View 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!"
}