Merge several pending changes into a virtual master
Octopus merges are just a cheap trick to make weak branches stronger
This commit is contained in:
commit
16721a7321
|
@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
### Added
|
||||
- `document` API for creating Gemini documents
|
||||
- preliminary timeout API, incl a special case for complex MIMEs by [@Alch-Emi]
|
||||
- `Response::success_with_body` by [@Alch-Emi]
|
||||
- `Response::success_*` variants by [@Alch-Emi]
|
||||
- `redirect_temporary_lossy` for `Response` and `ResponseHeader`
|
||||
- `bad_request_lossy` for `Response` and `ResponseHeader`
|
||||
- support for a lot more mime-types in `guess_mime_from_path`, backed by the `mime_guess` crate
|
||||
|
@ -17,10 +17,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- Docments can be converted into responses with std::convert::Into [@Alch-Emi]
|
||||
- Added ratelimiting API [@Alch-Emi]
|
||||
### Improved
|
||||
- build time and size by [@Alch-Emi](https://github.com/Alch-Emi)
|
||||
- build time and size by [@Alch-Emi]
|
||||
- Improved error handling in serve_dir [@Alch-Emi]
|
||||
### Changed
|
||||
- Added route API [@Alch-Emi](https://github.com/Alch-Emi)
|
||||
- API for adding handlers now accepts async handlers [@Alch-Emi](https://github.com/Alch-Emi)
|
||||
- `Response::success` now takes a request body [@Alch-Emi]
|
||||
|
||||
## [0.3.0] - 2020-11-14
|
||||
### Added
|
||||
|
|
19
Cargo.toml
19
Cargo.toml
|
@ -9,6 +9,9 @@ repository = "https://github.com/panicbit/northstar"
|
|||
documentation = "https://docs.rs/northstar"
|
||||
|
||||
[features]
|
||||
user_management = ["sled", "bincode", "serde/derive", "crc32fast"]
|
||||
user_management_advanced = ["rust-argon2", "ring", "user_management"]
|
||||
user_management_routes = ["user_management"]
|
||||
default = ["serve_dir"]
|
||||
serve_dir = ["mime_guess", "tokio/fs"]
|
||||
ratelimiting = ["dashmap"]
|
||||
|
@ -21,18 +24,30 @@ tokio = { version = "0.3.1", features = ["io-util","net","time", "rt"] }
|
|||
mime = "0.3.16"
|
||||
uriparse = "0.6.3"
|
||||
percent-encoding = "2.1.0"
|
||||
futures-core = "0.3.7"
|
||||
log = "0.4.11"
|
||||
webpki = "0.21.0"
|
||||
lazy_static = "1.4.0"
|
||||
mime_guess = { version = "2.0.3", optional = true }
|
||||
dashmap = { version = "3.11.10", optional = true }
|
||||
sled = { version = "0.34.6", optional = true }
|
||||
bincode = { version = "1.3.1", optional = true }
|
||||
serde = { version = "1.0", optional = true }
|
||||
rust-argon2 = { version = "0.8.2", optional = true }
|
||||
crc32fast = { version = "1.2.1", optional = true }
|
||||
ring = { version = "0.16.15", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
env_logger = "0.8.1"
|
||||
futures-util = "0.3.7"
|
||||
tokio = { version = "0.3.1", features = ["macros", "rt-multi-thread", "sync"] }
|
||||
|
||||
[[example]]
|
||||
name = "user_management"
|
||||
required-features = ["user_management_routes"]
|
||||
|
||||
[[example]]
|
||||
name = "serve_dir"
|
||||
required-features = ["serve_dir"]
|
||||
|
||||
[[example]]
|
||||
name = "ratelimiting"
|
||||
required-features = ["ratelimiting"]
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
use anyhow::*;
|
||||
use futures_core::future::BoxFuture;
|
||||
use futures_util::FutureExt;
|
||||
use log::LevelFilter;
|
||||
use tokio::sync::RwLock;
|
||||
use northstar::{Certificate, GEMINI_MIME, GEMINI_PORT, Request, Response, Server};
|
||||
use northstar::{Certificate, GEMINI_PORT, Request, Response, Server};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
|
@ -31,44 +29,38 @@ async fn main() -> Result<()> {
|
|||
/// selecting a username. They'll then get a message confirming their account creation.
|
||||
/// Any time this user visits the site in the future, they'll get a personalized welcome
|
||||
/// message.
|
||||
fn handle_request(users: Arc<RwLock<HashMap<CertBytes, String>>>, request: Request) -> BoxFuture<'static, Result<Response>> {
|
||||
async move {
|
||||
if let Some(Certificate(cert_bytes)) = request.certificate() {
|
||||
// The user provided a certificate
|
||||
let users_read = users.read().await;
|
||||
if let Some(user) = users_read.get(cert_bytes) {
|
||||
// The user has already registered
|
||||
async fn handle_request(users: Arc<RwLock<HashMap<CertBytes, String>>>, request: Request) -> Result<Response> {
|
||||
if let Some(Certificate(cert_bytes)) = request.certificate() {
|
||||
// The user provided a certificate
|
||||
let users_read = users.read().await;
|
||||
if let Some(user) = users_read.get(cert_bytes) {
|
||||
// The user has already registered
|
||||
Ok(
|
||||
Response::success_gemini(format!("Welcome {}!", user))
|
||||
)
|
||||
} else {
|
||||
// The user still needs to register
|
||||
drop(users_read);
|
||||
if let Some(query_part) = request.uri().query() {
|
||||
// The user provided some input (a username request)
|
||||
let username = query_part.as_str();
|
||||
let mut users_write = users.write().await;
|
||||
users_write.insert(cert_bytes.clone(), username.to_owned());
|
||||
Ok(
|
||||
Response::success_with_body(
|
||||
&GEMINI_MIME,
|
||||
format!("Welcome {}!", user)
|
||||
Response::success_gemini(
|
||||
format!(
|
||||
"Your account has been created {}! Welcome!",
|
||||
username
|
||||
)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
// The user still needs to register
|
||||
drop(users_read);
|
||||
if let Some(query_part) = request.uri().query() {
|
||||
// The user provided some input (a username request)
|
||||
let username = query_part.as_str();
|
||||
let mut users_write = users.write().await;
|
||||
users_write.insert(cert_bytes.clone(), username.to_owned());
|
||||
Ok(
|
||||
Response::success_with_body(
|
||||
&GEMINI_MIME,
|
||||
format!(
|
||||
"Your account has been created {}! Welcome!",
|
||||
username
|
||||
)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
// The user didn't provide input, and should be prompted
|
||||
Response::input("What username would you like?")
|
||||
}
|
||||
// The user didn't provide input, and should be prompted
|
||||
Response::input("What username would you like?")
|
||||
}
|
||||
} else {
|
||||
// The user didn't provide a certificate
|
||||
Ok(Response::client_certificate_required())
|
||||
}
|
||||
}.boxed()
|
||||
} else {
|
||||
// The user didn't provide a certificate
|
||||
Ok(Response::client_certificate_required())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
use anyhow::*;
|
||||
use futures_core::future::BoxFuture;
|
||||
use futures_util::FutureExt;
|
||||
use log::LevelFilter;
|
||||
use northstar::{Server, Request, Response, GEMINI_PORT, Document};
|
||||
use northstar::{Server, Response, GEMINI_PORT, Document};
|
||||
use northstar::document::HeadingLevel::*;
|
||||
|
||||
#[tokio::main]
|
||||
|
@ -11,40 +9,34 @@ async fn main() -> Result<()> {
|
|||
.filter_module("northstar", LevelFilter::Debug)
|
||||
.init();
|
||||
|
||||
let response: Response = Document::new()
|
||||
.add_preformatted(include_str!("northstar_logo.txt"))
|
||||
.add_blank_line()
|
||||
.add_link("https://docs.rs/northstar", "Documentation")
|
||||
.add_link("https://github.com/panicbit/northstar", "GitHub")
|
||||
.add_blank_line()
|
||||
.add_heading(H1, "Usage")
|
||||
.add_blank_line()
|
||||
.add_text("Add the latest version of northstar to your `Cargo.toml`.")
|
||||
.add_blank_line()
|
||||
.add_heading(H2, "Manually")
|
||||
.add_blank_line()
|
||||
.add_preformatted_with_alt("toml", r#"northstar = "0.3.0" # check crates.io for the latest version"#)
|
||||
.add_blank_line()
|
||||
.add_heading(H2, "Automatically")
|
||||
.add_blank_line()
|
||||
.add_preformatted_with_alt("sh", "cargo add northstar")
|
||||
.add_blank_line()
|
||||
.add_heading(H1, "Generating a key & certificate")
|
||||
.add_blank_line()
|
||||
.add_preformatted_with_alt("sh", concat!(
|
||||
"mkdir cert && cd cert\n",
|
||||
"openssl req -x509 -nodes -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365",
|
||||
))
|
||||
.into();
|
||||
|
||||
Server::bind(("localhost", GEMINI_PORT))
|
||||
.add_route("/",handle_request)
|
||||
.add_route("/", response)
|
||||
.serve()
|
||||
.await
|
||||
}
|
||||
|
||||
fn handle_request(_request: Request) -> BoxFuture<'static, Result<Response>> {
|
||||
async move {
|
||||
let response = Document::new()
|
||||
.add_preformatted(include_str!("northstar_logo.txt"))
|
||||
.add_blank_line()
|
||||
.add_link("https://docs.rs/northstar", "Documentation")
|
||||
.add_link("https://github.com/panicbit/northstar", "GitHub")
|
||||
.add_blank_line()
|
||||
.add_heading(H1, "Usage")
|
||||
.add_blank_line()
|
||||
.add_text("Add the latest version of northstar to your `Cargo.toml`.")
|
||||
.add_blank_line()
|
||||
.add_heading(H2, "Manually")
|
||||
.add_blank_line()
|
||||
.add_preformatted_with_alt("toml", r#"northstar = "0.3.0" # check crates.io for the latest version"#)
|
||||
.add_blank_line()
|
||||
.add_heading(H2, "Automatically")
|
||||
.add_blank_line()
|
||||
.add_preformatted_with_alt("sh", "cargo add northstar")
|
||||
.add_blank_line()
|
||||
.add_heading(H1, "Generating a key & certificate")
|
||||
.add_blank_line()
|
||||
.add_preformatted_with_alt("sh", concat!(
|
||||
"mkdir cert && cd cert\n",
|
||||
"openssl req -x509 -nodes -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365",
|
||||
))
|
||||
.into();
|
||||
Ok(response)
|
||||
}
|
||||
.boxed()
|
||||
}
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
use anyhow::*;
|
||||
use futures_core::future::BoxFuture;
|
||||
use futures_util::FutureExt;
|
||||
use log::LevelFilter;
|
||||
use northstar::{Document, document::HeadingLevel, Request, Response, GEMINI_PORT};
|
||||
|
||||
|
@ -18,25 +16,19 @@ async fn main() -> Result<()> {
|
|||
.await
|
||||
}
|
||||
|
||||
fn handle_base(req: Request) -> BoxFuture<'static, Result<Response>> {
|
||||
async fn handle_base(req: Request) -> Result<Response> {
|
||||
let doc = generate_doc("base", &req);
|
||||
async move {
|
||||
Ok(Response::document(doc))
|
||||
}.boxed()
|
||||
Ok(doc.into())
|
||||
}
|
||||
|
||||
fn handle_short(req: Request) -> BoxFuture<'static, Result<Response>> {
|
||||
async fn handle_short(req: Request) -> Result<Response> {
|
||||
let doc = generate_doc("short", &req);
|
||||
async move {
|
||||
Ok(Response::document(doc))
|
||||
}.boxed()
|
||||
Ok(doc.into())
|
||||
}
|
||||
|
||||
fn handle_long(req: Request) -> BoxFuture<'static, Result<Response>> {
|
||||
async fn handle_long(req: Request) -> Result<Response> {
|
||||
let doc = generate_doc("long", &req);
|
||||
async move {
|
||||
Ok(Response::document(doc))
|
||||
}.boxed()
|
||||
Ok(doc.into())
|
||||
}
|
||||
|
||||
fn generate_doc(route_name: &str, req: &Request) -> Document {
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::*;
|
||||
use futures_core::future::BoxFuture;
|
||||
use futures_util::FutureExt;
|
||||
use log::LevelFilter;
|
||||
use northstar::{Server, Request, Response, GEMINI_PORT};
|
||||
use northstar::{Server, GEMINI_PORT};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
|
@ -11,17 +11,8 @@ async fn main() -> Result<()> {
|
|||
.init();
|
||||
|
||||
Server::bind(("localhost", GEMINI_PORT))
|
||||
.add_route("/", handle_request)
|
||||
.add_route("/", PathBuf::from("public")) // Serve directory listings & file contents
|
||||
.add_route("/about", PathBuf::from("README.md")) // Serve a single file
|
||||
.serve()
|
||||
.await
|
||||
}
|
||||
|
||||
fn handle_request(request: Request) -> BoxFuture<'static, Result<Response>> {
|
||||
async move {
|
||||
let path = request.path_segments();
|
||||
let response = northstar::util::serve_dir("public", &path).await?;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
.boxed()
|
||||
}
|
||||
|
|
83
examples/user_management.rs
Normal file
83
examples/user_management.rs
Normal file
|
@ -0,0 +1,83 @@
|
|||
use anyhow::*;
|
||||
use log::LevelFilter;
|
||||
use northstar::{
|
||||
GEMINI_PORT,
|
||||
Document,
|
||||
Request,
|
||||
Response,
|
||||
Server,
|
||||
user_management::{
|
||||
user::RegisteredUser,
|
||||
UserManagementRoutes,
|
||||
},
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
/// An ultra-simple demonstration of authentication.
|
||||
///
|
||||
/// The user should be able to set a secret string that only they can see. They should be
|
||||
/// able to change this at any time to any thing. Both the string and the user account
|
||||
/// will persist across restarts.
|
||||
///
|
||||
/// This method sets up and starts the server
|
||||
async fn main() -> Result<()> {
|
||||
// Turn on logging
|
||||
env_logger::builder()
|
||||
.filter_module("northstar", LevelFilter::Debug)
|
||||
.init();
|
||||
|
||||
Server::bind(("0.0.0.0", GEMINI_PORT))
|
||||
|
||||
// Add our main routes
|
||||
.add_authenticated_route("/", handle_main)
|
||||
.add_authenticated_input_route("/update", "Enter your new string:", handle_update)
|
||||
|
||||
// Add routes for handling user authentication
|
||||
.add_um_routes::<String>("/")
|
||||
|
||||
// Start the server
|
||||
.serve()
|
||||
.await
|
||||
}
|
||||
|
||||
/// The landing page
|
||||
///
|
||||
/// Displays the user's current secret string, or prompts the user to sign in if they
|
||||
/// haven't. Includes links to update your string (`/update`) or your account
|
||||
/// (`/account`). Even though we haven't added an explicit handler for `/account`, this
|
||||
/// route is managed by northstar.
|
||||
///
|
||||
/// Because this route is registered as an authenticated route, any connections without a
|
||||
/// certificate will be prompted to add a certificate and register.
|
||||
async fn handle_main(_req: Request, user: RegisteredUser<String>) -> Result<Response> {
|
||||
// If the user is signed in, render and return their page
|
||||
let response = Document::new()
|
||||
.add_text("Your personal secret string:")
|
||||
.add_text(user.as_ref())
|
||||
.add_blank_line()
|
||||
.add_link("/update", "Change your string")
|
||||
.add_link("/account", "Update your account")
|
||||
.into();
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// The update endpoint
|
||||
///
|
||||
/// Users can update their secret string here.
|
||||
async fn handle_update(_request: Request, mut user: RegisteredUser<String>, input: String) -> Result<Response> {
|
||||
|
||||
// The user has already been prompted to log in if they weren't and asked to give an
|
||||
// input string, so all we need to do is...
|
||||
|
||||
// Update the users data
|
||||
*user.as_mut() = input;
|
||||
|
||||
// Render a response
|
||||
let response = Document::new()
|
||||
.add_text("String updated!")
|
||||
.add_blank_line()
|
||||
.add_link("/", "Back")
|
||||
.into();
|
||||
Ok(response)
|
||||
|
||||
}
|
211
src/handling.rs
Normal file
211
src/handling.rs
Normal file
|
@ -0,0 +1,211 @@
|
|||
//! Types for handling requests
|
||||
//!
|
||||
//! The main type is the [`Handler`], which wraps a more specific type of handler and
|
||||
//! manages delegating responses to it.
|
||||
//!
|
||||
//! For most purposes, you should never have to manually create any of these structs
|
||||
//! yourself, though it may be useful to look at the implementations of [`From`] on
|
||||
//! [`Handler`], as these are the things that can be used as handlers for routes.
|
||||
use anyhow::Result;
|
||||
|
||||
use std::{
|
||||
pin::Pin,
|
||||
future::Future,
|
||||
task::Poll,
|
||||
panic::{catch_unwind, AssertUnwindSafe},
|
||||
};
|
||||
#[cfg(feature = "serve_dir")]
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::{Document, types::{Body, Response, Request}};
|
||||
|
||||
/// A struct representing something capable of handling a request.
|
||||
///
|
||||
/// In the future, this may have multiple varieties, but at the minute, it just wraps an
|
||||
/// [`Fn`](std::ops::Fn).
|
||||
///
|
||||
/// The most useful part of the documentation for this is the implementations of [`From`]
|
||||
/// on it, as this is what can be passed to
|
||||
/// [`Builder::add_route`](crate::Builder::add_route) in order to create a new route.
|
||||
/// Each implementation has bespoke docs that describe how the type is used, and what
|
||||
/// response is produced.
|
||||
pub enum Handler {
|
||||
FnHandler(HandlerInner),
|
||||
StaticHandler(Response),
|
||||
#[cfg(feature = "serve_dir")]
|
||||
FilesHandler(PathBuf),
|
||||
}
|
||||
|
||||
/// Since we can't store train objects, we need to wrap fn handlers in a box
|
||||
type HandlerInner = Box<dyn Fn(Request) -> HandlerResponse + Send + Sync>;
|
||||
/// Same with dyn Futures
|
||||
type HandlerResponse = Pin<Box<dyn Future<Output = Result<Response>> + Send>>;
|
||||
|
||||
impl Handler {
|
||||
/// Handle an incoming request
|
||||
///
|
||||
/// This delegates to the request to the appropriate method of handling it, whether
|
||||
/// that's fetching a file or directory listing, cloning a static response, or handing
|
||||
/// the request to a wrapped handler function.
|
||||
///
|
||||
/// Any unexpected errors that occur will be printed to the log and potentially
|
||||
/// reported to the user, depending on the handler type.
|
||||
pub async fn handle(&self, request: Request) -> Response {
|
||||
match self {
|
||||
Self::FnHandler(inner) => {
|
||||
let fut_handle = (inner)(request);
|
||||
let fut_handle = AssertUnwindSafe(fut_handle);
|
||||
|
||||
HandlerCatchUnwind::new(fut_handle).await
|
||||
.unwrap_or_else(|err| {
|
||||
error!("Handler failed: {:?}", err);
|
||||
Response::server_error("").unwrap()
|
||||
})
|
||||
},
|
||||
Self::StaticHandler(response) => {
|
||||
let body = response.as_ref();
|
||||
match body {
|
||||
None => Response::new(response.header().clone()),
|
||||
Some(Body::Bytes(bytes)) => {
|
||||
Response::new(response.header().clone())
|
||||
.with_body(bytes.clone())
|
||||
},
|
||||
_ => {
|
||||
error!(concat!(
|
||||
"Cannot construct a static handler with a reader-based body! ",
|
||||
" We're sending a response so that the client doesn't crash, but",
|
||||
" given that this is a release build you should really fix this."
|
||||
));
|
||||
Response::server_error(
|
||||
"Very bad server error, go tell the sysadmin to look at the logs."
|
||||
).unwrap()
|
||||
}
|
||||
}
|
||||
},
|
||||
#[cfg(feature = "serve_dir")]
|
||||
Self::FilesHandler(path) => {
|
||||
let resp = if path.is_dir() {
|
||||
crate::util::serve_dir(path, request.trailing_segments()).await
|
||||
} else {
|
||||
let mime = crate::util::guess_mime_from_path(&path);
|
||||
crate::util::serve_file(path, &mime).await
|
||||
};
|
||||
resp.unwrap_or_else(|e| {
|
||||
warn!("Unexpected error serving from {}: {:?}", path.display(), e);
|
||||
Response::server_error("").unwrap()
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<H, R> From<H> for Handler
|
||||
where
|
||||
H: 'static + Fn(Request) -> R + Send + Sync,
|
||||
R: 'static + Future<Output = Result<Response>> + Send,
|
||||
{
|
||||
/// Wrap an [`Fn`] in a [`Handler`] struct
|
||||
///
|
||||
/// This automatically boxes both the [`Fn`] and the [`Fn`]'s response.
|
||||
///
|
||||
/// Any requests passed to the handler will be directly handed down to the handler,
|
||||
/// with the request as the first argument. The response provided will be sent to the
|
||||
/// requester. If the handler panics or returns an [`Err`], this will be logged, and
|
||||
/// the requester will be sent a [`SERVER_ERROR`](Response::server_error()).
|
||||
fn from(handler: H) -> Self {
|
||||
Self::FnHandler(
|
||||
Box::new(move|req| Box::pin((handler)(req)) as HandlerResponse)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// We tolerate a fallible `impl From` because this is *really* not the kind of thing the
|
||||
// user should be catching in runtime.
|
||||
#[allow(clippy::fallible_impl_from)]
|
||||
impl From<Response> for Handler {
|
||||
/// Serve an unchanging response
|
||||
///
|
||||
/// Any and all requests to this handler will be responded to with the same response,
|
||||
/// no matter what. This is good for static content that is provided by your app.
|
||||
/// For serving files & directories, try looking at creating a handler from a path
|
||||
///
|
||||
/// ## Panics
|
||||
/// This response type **CANNOT** be created using Responses with [`Reader`] bodies.
|
||||
/// Attempting to do this will cause a panic. Don't.
|
||||
///
|
||||
/// [`Reader`]: Body::Reader
|
||||
fn from(response: Response) -> Self {
|
||||
#[cfg(debug_assertions)] {
|
||||
// We have another check once the handler is actually called that is not
|
||||
// disabled for release builds
|
||||
if let Some(Body::Reader(_)) = response.as_ref() {
|
||||
panic!("Cannot construct a static handler with a reader-based body");
|
||||
}
|
||||
}
|
||||
Self::StaticHandler(response)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Document> for Handler {
|
||||
/// Serve an unchanging response, shorthand for From<Response>
|
||||
///
|
||||
/// This document will be sent in response to any requests that arrive at this
|
||||
/// handler. As with all documents, this will be a successful response with a
|
||||
/// `text/gemini` MIME.
|
||||
fn from(doc: &Document) -> Self {
|
||||
Self::StaticHandler(doc.into())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "serve_dir")]
|
||||
impl From<PathBuf> for Handler {
|
||||
/// Serve files from a directory
|
||||
///
|
||||
/// Any requests directed to this handler will be served from this path. For example,
|
||||
/// if a handler serving files from the path `./public/` and bound to `/serve`
|
||||
/// receives a request for `/serve/file.txt`, it will respond with the contents of the
|
||||
/// file at `./public/file.txt`.
|
||||
///
|
||||
/// This is equivilent to serving files using [`util::serve_dir()`], and as such will
|
||||
/// include directory listings.
|
||||
///
|
||||
/// The path to a single file can be passed in order to serve only a single file for
|
||||
/// any and all requests.
|
||||
///
|
||||
/// [`util::serve_dir()`]: crate::util::serve_dir()
|
||||
fn from(path: PathBuf) -> Self {
|
||||
Self::FilesHandler(path)
|
||||
}
|
||||
}
|
||||
|
||||
/// A utility for catching unwinds on Futures.
|
||||
///
|
||||
/// This is adapted from the futures-rs CatchUnwind, in an effort to reduce the large
|
||||
/// amount of dependencies tied into the feature that provides this simple struct.
|
||||
#[must_use = "futures do nothing unless polled"]
|
||||
struct HandlerCatchUnwind {
|
||||
future: AssertUnwindSafe<HandlerResponse>,
|
||||
}
|
||||
|
||||
impl HandlerCatchUnwind {
|
||||
fn new(future: AssertUnwindSafe<HandlerResponse>) -> Self {
|
||||
Self { future }
|
||||
}
|
||||
}
|
||||
|
||||
impl Future for HandlerCatchUnwind {
|
||||
type Output = Result<Response>;
|
||||
|
||||
fn poll(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context
|
||||
) -> Poll<Self::Output> {
|
||||
match catch_unwind(AssertUnwindSafe(|| self.future.as_mut().poll(cx))) {
|
||||
Ok(res) => res,
|
||||
Err(e) => {
|
||||
error!("Handler panic! {:?}", e);
|
||||
Poll::Ready(Response::server_error(""))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
125
src/lib.rs
125
src/lib.rs
|
@ -1,7 +1,6 @@
|
|||
#[macro_use] extern crate log;
|
||||
|
||||
use std::{
|
||||
panic::AssertUnwindSafe,
|
||||
convert::TryFrom,
|
||||
io::BufReader,
|
||||
sync::Arc,
|
||||
|
@ -10,7 +9,6 @@ use std::{
|
|||
};
|
||||
#[cfg(feature = "ratelimiting")]
|
||||
use std::net::IpAddr;
|
||||
use futures_core::future::BoxFuture;
|
||||
use tokio::{
|
||||
prelude::*,
|
||||
io::{self, BufStream},
|
||||
|
@ -34,8 +32,14 @@ use ratelimiting::RateLimiter;
|
|||
pub mod types;
|
||||
pub mod util;
|
||||
pub mod routing;
|
||||
pub mod handling;
|
||||
#[cfg(feature = "ratelimiting")]
|
||||
pub mod ratelimiting;
|
||||
#[cfg(feature = "user_management")]
|
||||
pub mod user_management;
|
||||
|
||||
#[cfg(feature="user_management")]
|
||||
use user_management::UserManager;
|
||||
|
||||
pub use mime;
|
||||
pub use uriparse as uri;
|
||||
|
@ -44,18 +48,18 @@ pub use types::*;
|
|||
pub const REQUEST_URI_MAX_LEN: usize = 1024;
|
||||
pub const GEMINI_PORT: u16 = 1965;
|
||||
|
||||
type Handler = Arc<dyn Fn(Request) -> HandlerResponse + Send + Sync>;
|
||||
pub (crate) type HandlerResponse = BoxFuture<'static, Result<Response>>;
|
||||
use handling::Handler;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Server {
|
||||
tls_acceptor: TlsAcceptor,
|
||||
listener: Arc<TcpListener>,
|
||||
routes: Arc<RoutingNode<Handler>>,
|
||||
timeout: Duration,
|
||||
complex_timeout: Option<Duration>,
|
||||
#[cfg(feature="ratelimiting")]
|
||||
rate_limits: Arc<RoutingNode<RateLimiter<IpAddr>>>,
|
||||
#[cfg(feature="user_management")]
|
||||
manager: UserManager,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
|
@ -63,12 +67,12 @@ impl Server {
|
|||
Builder::bind(addr)
|
||||
}
|
||||
|
||||
async fn serve(self) -> Result<()> {
|
||||
async fn serve(self, listener: TcpListener) -> Result<()> {
|
||||
#[cfg(feature = "ratelimiting")]
|
||||
tokio::spawn(prune_ratelimit_log(self.rate_limits.clone()));
|
||||
|
||||
loop {
|
||||
let (stream, _addr) = self.listener.accept().await
|
||||
let (stream, _addr) = listener.accept().await
|
||||
.context("Failed to accept client")?;
|
||||
let this = self.clone();
|
||||
|
||||
|
@ -89,7 +93,11 @@ impl Server {
|
|||
.context("Failed to establish TLS session")?;
|
||||
let mut stream = BufStream::new(stream);
|
||||
|
||||
let request = receive_request(&mut stream).await
|
||||
#[cfg(feature="user_management")]
|
||||
let request = self.receive_request(&mut stream).await
|
||||
.context("Failed to receive request")?;
|
||||
#[cfg(not(feature="user_management"))]
|
||||
let request = Self::receive_request(&mut stream).await
|
||||
.context("Failed to receive request")?;
|
||||
|
||||
Result::<_, anyhow::Error>::Ok((request, stream))
|
||||
|
@ -120,19 +128,8 @@ impl Server {
|
|||
request.set_cert(client_cert);
|
||||
|
||||
let response = if let Some((trailing, handler)) = self.routes.match_request(&request) {
|
||||
|
||||
request.set_trailing(trailing);
|
||||
|
||||
let handler = (handler)(request);
|
||||
let handler = AssertUnwindSafe(handler);
|
||||
|
||||
util::HandlerCatchUnwind::new(handler).await
|
||||
.unwrap_or_else(|_| Response::server_error(""))
|
||||
.or_else(|err| {
|
||||
error!("Handler failed: {:?}", err);
|
||||
Response::server_error("")
|
||||
})
|
||||
.context("Request handler failed")?
|
||||
handler.handle(request).await
|
||||
} else {
|
||||
Response::not_found()
|
||||
};
|
||||
|
@ -201,6 +198,41 @@ impl Server {
|
|||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn receive_request(
|
||||
#[cfg(feature="user_management")]
|
||||
&self,
|
||||
stream: &mut (impl AsyncBufRead + Unpin)
|
||||
) -> Result<Request> {
|
||||
let limit = REQUEST_URI_MAX_LEN + "\r\n".len();
|
||||
let mut stream = stream.take(limit as u64);
|
||||
let mut uri = Vec::new();
|
||||
|
||||
stream.read_until(b'\n', &mut uri).await?;
|
||||
|
||||
if !uri.ends_with(b"\r\n") {
|
||||
if uri.len() < REQUEST_URI_MAX_LEN {
|
||||
bail!("Request header not terminated with CRLF")
|
||||
} else {
|
||||
bail!("Request URI too long")
|
||||
}
|
||||
}
|
||||
|
||||
// Strip CRLF
|
||||
uri.pop();
|
||||
uri.pop();
|
||||
|
||||
let uri = URIReference::try_from(&*uri)
|
||||
.context("Request URI is invalid")?
|
||||
.into_owned();
|
||||
let request = Request::from_uri(
|
||||
uri,
|
||||
#[cfg(feature="user_management")]
|
||||
self.manager.clone(),
|
||||
) .context("Failed to create request from URI")?;
|
||||
|
||||
Ok(request)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Builder<A> {
|
||||
|
@ -212,6 +244,8 @@ pub struct Builder<A> {
|
|||
routes: RoutingNode<Handler>,
|
||||
#[cfg(feature="ratelimiting")]
|
||||
rate_limits: RoutingNode<RateLimiter<IpAddr>>,
|
||||
#[cfg(feature="user_management")]
|
||||
data_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl<A: ToSocketAddrs> Builder<A> {
|
||||
|
@ -225,9 +259,20 @@ impl<A: ToSocketAddrs> Builder<A> {
|
|||
routes: RoutingNode::default(),
|
||||
#[cfg(feature="ratelimiting")]
|
||||
rate_limits: RoutingNode::default(),
|
||||
#[cfg(feature="user_management")]
|
||||
data_dir: "data".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature="user_management")]
|
||||
/// Sets the directory to store user data in
|
||||
///
|
||||
/// Defaults to `./data` if not specified
|
||||
pub fn set_database_dir(mut self, path: impl Into<PathBuf>) -> Self {
|
||||
self.data_dir = path.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the directory that northstar should look for TLS certs and keys into
|
||||
///
|
||||
/// Northstar will look for files called `cert.pem` and `key.pem` in the provided
|
||||
|
@ -333,11 +378,8 @@ impl<A: ToSocketAddrs> Builder<A> {
|
|||
/// "endpoint". Entering a relative or malformed path will result in a panic.
|
||||
///
|
||||
/// For more information about routing mechanics, see the docs for [`RoutingNode`].
|
||||
pub fn add_route<H>(mut self, path: &'static str, handler: H) -> Self
|
||||
where
|
||||
H: Fn(Request) -> HandlerResponse + Send + Sync + 'static,
|
||||
{
|
||||
self.routes.add_route(path, Arc::new(handler));
|
||||
pub fn add_route(mut self, path: &'static str, handler: impl Into<Handler>) -> Self {
|
||||
self.routes.add_route(path, handler.into());
|
||||
self
|
||||
}
|
||||
|
||||
|
@ -369,46 +411,19 @@ impl<A: ToSocketAddrs> Builder<A> {
|
|||
|
||||
let server = Server {
|
||||
tls_acceptor: TlsAcceptor::from(config),
|
||||
listener: Arc::new(listener),
|
||||
routes: Arc::new(self.routes),
|
||||
timeout: self.timeout,
|
||||
complex_timeout: self.complex_body_timeout_override,
|
||||
#[cfg(feature="ratelimiting")]
|
||||
rate_limits: Arc::new(self.rate_limits),
|
||||
#[cfg(feature="user_management")]
|
||||
manager: UserManager::new(self.data_dir)?,
|
||||
};
|
||||
|
||||
server.serve().await
|
||||
server.serve(listener).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn receive_request(stream: &mut (impl AsyncBufRead + Unpin)) -> Result<Request> {
|
||||
let limit = REQUEST_URI_MAX_LEN + "\r\n".len();
|
||||
let mut stream = stream.take(limit as u64);
|
||||
let mut uri = Vec::new();
|
||||
|
||||
stream.read_until(b'\n', &mut uri).await?;
|
||||
|
||||
if !uri.ends_with(b"\r\n") {
|
||||
if uri.len() < REQUEST_URI_MAX_LEN {
|
||||
bail!("Request header not terminated with CRLF")
|
||||
} else {
|
||||
bail!("Request URI too long")
|
||||
}
|
||||
}
|
||||
|
||||
// Strip CRLF
|
||||
uri.pop();
|
||||
uri.pop();
|
||||
|
||||
let uri = URIReference::try_from(&*uri)
|
||||
.context("Request URI is invalid")?
|
||||
.into_owned();
|
||||
let request = Request::from_uri(uri)
|
||||
.context("Failed to create request from URI")?;
|
||||
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
async fn send_response_header(header: &ResponseHeader, stream: &mut (impl AsyncWrite + Unpin)) -> Result<()> {
|
||||
let header = format!(
|
||||
"{status} {meta}\r\n",
|
||||
|
|
|
@ -3,22 +3,40 @@ use anyhow::*;
|
|||
use percent_encoding::percent_decode_str;
|
||||
use uriparse::URIReference;
|
||||
use rustls::Certificate;
|
||||
#[cfg(feature="user_management")]
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
|
||||
#[cfg(feature="user_management")]
|
||||
use crate::user_management::{UserManager, User};
|
||||
|
||||
pub struct Request {
|
||||
uri: URIReference<'static>,
|
||||
input: Option<String>,
|
||||
certificate: Option<Certificate>,
|
||||
trailing_segments: Option<Vec<String>>,
|
||||
#[cfg(feature="user_management")]
|
||||
manager: UserManager,
|
||||
}
|
||||
|
||||
impl Request {
|
||||
pub fn from_uri(uri: URIReference<'static>) -> Result<Self> {
|
||||
Self::with_certificate(uri, None)
|
||||
pub fn from_uri(
|
||||
uri: URIReference<'static>,
|
||||
#[cfg(feature="user_management")]
|
||||
manager: UserManager,
|
||||
) -> Result<Self> {
|
||||
Self::with_certificate(
|
||||
uri,
|
||||
None,
|
||||
#[cfg(feature="user_management")]
|
||||
manager
|
||||
)
|
||||
}
|
||||
|
||||
pub fn with_certificate(
|
||||
mut uri: URIReference<'static>,
|
||||
certificate: Option<Certificate>
|
||||
certificate: Option<Certificate>,
|
||||
#[cfg(feature="user_management")]
|
||||
manager: UserManager,
|
||||
) -> Result<Self> {
|
||||
uri.normalize();
|
||||
|
||||
|
@ -38,6 +56,8 @@ impl Request {
|
|||
input,
|
||||
certificate,
|
||||
trailing_segments: None,
|
||||
#[cfg(feature="user_management")]
|
||||
manager,
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -94,6 +114,18 @@ impl Request {
|
|||
pub const fn certificate(&self) -> Option<&Certificate> {
|
||||
self.certificate.as_ref()
|
||||
}
|
||||
|
||||
#[cfg(feature="user_management")]
|
||||
/// Attempt to determine the user who sent this request
|
||||
///
|
||||
/// May return a variant depending on if the client used a client certificate, and if
|
||||
/// they've registered as a user yet.
|
||||
pub fn user<UserData>(&self) -> Result<User<UserData>>
|
||||
where
|
||||
UserData: Serialize + DeserializeOwned
|
||||
{
|
||||
Ok(self.manager.get_user(self.certificate())?)
|
||||
}
|
||||
}
|
||||
|
||||
impl ops::Deref for Request {
|
||||
|
|
|
@ -20,8 +20,12 @@ impl Response {
|
|||
}
|
||||
}
|
||||
|
||||
#[deprecated(
|
||||
since = "0.4.0",
|
||||
note = "Deprecated in favor of Response::success_gemini() or Document::into()"
|
||||
)]
|
||||
pub fn document(document: impl Borrow<Document>) -> Self {
|
||||
Self::success_with_body(&GEMINI_MIME, document)
|
||||
Self::success_gemini(document)
|
||||
}
|
||||
|
||||
pub fn input(prompt: impl Cowy<str>) -> Result<Self> {
|
||||
|
@ -34,27 +38,27 @@ impl Response {
|
|||
Self::new(header)
|
||||
}
|
||||
|
||||
pub fn success(mime: &Mime) -> Self {
|
||||
let header = ResponseHeader::success(mime);
|
||||
Self::new(header)
|
||||
}
|
||||
|
||||
pub fn redirect_temporary_lossy<'a>(location: impl TryInto<URIReference<'a>>) -> Self {
|
||||
let header = ResponseHeader::redirect_temporary_lossy(location);
|
||||
Self::new(header)
|
||||
}
|
||||
|
||||
/// Create a successful response with a preconfigured body
|
||||
///
|
||||
/// This is equivilent to:
|
||||
///
|
||||
/// ```ignore
|
||||
/// Response::success(mime)
|
||||
/// .with_body(body)
|
||||
/// ```
|
||||
pub fn success_with_body(mime: &Mime, body: impl Into<Body>) -> Self {
|
||||
Self::success(mime)
|
||||
.with_body(body)
|
||||
/// Create a successful response with a given body and MIME
|
||||
pub fn success(mime: &Mime, body: impl Into<Body>) -> Self {
|
||||
Self {
|
||||
header: ResponseHeader::success(mime),
|
||||
body: Some(body.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a successful response with a `text/gemini` MIME
|
||||
pub fn success_gemini(body: impl Into<Body>) -> Self {
|
||||
Self::success(&GEMINI_MIME, body)
|
||||
}
|
||||
|
||||
/// Create a successful response with a `text/plain` MIME
|
||||
pub fn success_plain(body: impl Into<Body>) -> Self {
|
||||
Self::success(&mime::TEXT_PLAIN, body)
|
||||
}
|
||||
|
||||
pub fn server_error(reason: impl Cowy<str>) -> Result<Self> {
|
||||
|
@ -96,8 +100,20 @@ impl Response {
|
|||
}
|
||||
}
|
||||
|
||||
impl<D: Borrow<Document>> From<D> for Response {
|
||||
fn from(doc: D) -> Self {
|
||||
Self::document(doc)
|
||||
impl AsRef<Option<Body>> for Response {
|
||||
fn as_ref(&self) -> &Option<Body> {
|
||||
&self.body
|
||||
}
|
||||
}
|
||||
|
||||
impl AsMut<Option<Body>> for Response {
|
||||
fn as_mut(&mut self) -> &mut Option<Body> {
|
||||
&mut self.body
|
||||
}
|
||||
}
|
||||
|
||||
impl<D: Borrow<Document>> From<D> for Response {
|
||||
fn from(doc: D) -> Self {
|
||||
Self::success_gemini(doc)
|
||||
}
|
||||
}
|
||||
|
|
120
src/user_management/manager.rs
Normal file
120
src/user_management/manager.rs
Normal file
|
@ -0,0 +1,120 @@
|
|||
use rustls::Certificate;
|
||||
use serde::{Deserialize, Serialize, de::DeserializeOwned};
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::user_management::{User, Result};
|
||||
use crate::user_management::user::{RegisteredUser, NotSignedInUser, PartialUser};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
/// Data stored in the certificate tree about a certain certificate
|
||||
pub struct CertificateData {
|
||||
#[serde(with = "CertificateDef")]
|
||||
/// The certificate in question
|
||||
pub certificate: Certificate,
|
||||
|
||||
/// The username of the user to which this certificate is registered
|
||||
pub owner_username: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(remote = "Certificate")]
|
||||
struct CertificateDef(Vec<u8>);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// A struct containing information for managing users.
|
||||
///
|
||||
/// Wraps a [`sled::Db`]
|
||||
pub struct UserManager {
|
||||
db: sled::Db,
|
||||
pub (crate) users: sled::Tree, // user_id:String maps to data:UserData
|
||||
pub (crate) certificates: sled::Tree, // certificate:u64 maps to data:CertificateData
|
||||
}
|
||||
|
||||
impl UserManager {
|
||||
|
||||
/// Create or open a new UserManager
|
||||
///
|
||||
/// The `dir` argument is the path to a data directory, to be populated using sled.
|
||||
/// This will be created if it does not exist.
|
||||
pub fn new(dir: impl AsRef<Path>) -> Result<Self> {
|
||||
let db = sled::open(dir)?;
|
||||
Ok(Self {
|
||||
users: db.open_tree("users")?,
|
||||
certificates: db.open_tree("certificates")?,
|
||||
db,
|
||||
})
|
||||
}
|
||||
|
||||
/// Produce a u32 hash from a certificate, used for [`lookup_certificate()`](Self::lookup_certificate())
|
||||
pub fn hash_certificate(cert: &Certificate) -> u32 {
|
||||
let mut hasher = crc32fast::Hasher::new();
|
||||
hasher.update(cert.0.as_ref());
|
||||
hasher.finalize()
|
||||
}
|
||||
|
||||
/// Lookup information about a certificate based on it's u32 hash
|
||||
///
|
||||
/// # Errors
|
||||
/// An error is thrown if there is an error reading from the database or if data
|
||||
/// recieved from the database is corrupt
|
||||
pub fn lookup_certificate(&self, cert: u32) -> Result<Option<CertificateData>> {
|
||||
if let Some(bytes) = self.certificates.get(cert.to_le_bytes())? {
|
||||
Ok(Some(bincode::deserialize(&bytes)?))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Lookup information about a user by username
|
||||
///
|
||||
/// # Errors
|
||||
/// An error is thrown if there is an error reading from the database or if data
|
||||
/// recieved from the database is corrupt
|
||||
pub fn lookup_user<UserData>(
|
||||
&self,
|
||||
username: impl AsRef<str>
|
||||
) -> Result<Option<RegisteredUser<UserData>>>
|
||||
where
|
||||
UserData: Serialize + DeserializeOwned
|
||||
{
|
||||
if let Some(bytes) = self.users.get(username.as_ref())? {
|
||||
let inner: PartialUser<UserData> = bincode::deserialize_from(bytes.as_ref())?;
|
||||
Ok(Some(RegisteredUser::new(username.as_ref().to_owned(), None, self.clone(), inner)))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to determine the user who sent a request based on the certificate.
|
||||
///
|
||||
/// # Errors
|
||||
/// An error is thrown if there is an error reading from the database or if data
|
||||
/// recieved from the database is corrupt
|
||||
///
|
||||
/// # Panics
|
||||
/// Pancis if the database is corrupt
|
||||
pub fn get_user<UserData>(
|
||||
&self,
|
||||
cert: Option<&Certificate>
|
||||
) -> Result<User<UserData>>
|
||||
where
|
||||
UserData: Serialize + DeserializeOwned
|
||||
{
|
||||
if let Some(certificate) = cert {
|
||||
let cert_hash = Self::hash_certificate(certificate);
|
||||
if let Some(certificate_data) = self.lookup_certificate(cert_hash)? {
|
||||
let user_inner = self.lookup_user(&certificate_data.owner_username)?
|
||||
.expect("Database corruption: Certificate data refers to non-existant user");
|
||||
Ok(User::SignedIn(user_inner.with_cert(certificate_data.certificate)))
|
||||
} else {
|
||||
Ok(User::NotSignedIn(NotSignedInUser {
|
||||
certificate: certificate.clone(),
|
||||
manager: self.clone(),
|
||||
}))
|
||||
}
|
||||
} else {
|
||||
Ok(User::Unauthenticated)
|
||||
}
|
||||
}
|
||||
}
|
105
src/user_management/mod.rs
Normal file
105
src/user_management/mod.rs
Normal file
|
@ -0,0 +1,105 @@
|
|||
//! Tools for registering users & persisting arbitrary user data
|
||||
//!
|
||||
//! Many Gemini applications use some form of a login method in order to allow users to
|
||||
//! persist personal data, authenticate themselves, and login from multiple devices using
|
||||
//! multiple certificates.
|
||||
//!
|
||||
//! This module contains tools to help you build a system like this without stress. A
|
||||
//! typical workflow looks something like this:
|
||||
//!
|
||||
//! * Call [`Request::user()`] to retrieve information about a user
|
||||
//! * Direct any users without a certificate to create a certificate
|
||||
//! * Ask users with a certificate not yet linked to an account to create an account using
|
||||
//! [`NotSignedInUser::register()`] or link their certificate to an existing account
|
||||
//! with a password using [`NotSignedInUser::attach()`].
|
||||
//! * You should now have a [`RegisteredUser`] either from registering/attaching a
|
||||
//! [`NotSignedInUser`] or because the user was already registered
|
||||
//! * Access and modify user data using [`RegisteredUser::as_mut()`], changes are
|
||||
//! automatically persisted to the database (on user drop).
|
||||
//!
|
||||
//! Use of this module requires the `user_management` feature to be enabled
|
||||
pub mod user;
|
||||
mod manager;
|
||||
#[cfg(feature = "user_management_routes")]
|
||||
mod routes;
|
||||
#[cfg(feature = "user_management_routes")]
|
||||
pub use routes::UserManagementRoutes;
|
||||
pub use manager::UserManager;
|
||||
pub use user::User;
|
||||
pub use manager::CertificateData;
|
||||
// Imports for docs
|
||||
#[allow(unused_imports)]
|
||||
use user::{NotSignedInUser, RegisteredUser};
|
||||
#[allow(unused_imports)]
|
||||
use crate::types::Request;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum UserManagerError {
|
||||
UsernameNotUnique,
|
||||
PasswordNotSet,
|
||||
DatabaseError(sled::Error),
|
||||
DatabaseTransactionError(sled::transaction::TransactionError),
|
||||
DeserializeError(bincode::Error),
|
||||
#[cfg(feature = "user_management_advanced")]
|
||||
Argon2Error(argon2::Error),
|
||||
}
|
||||
|
||||
impl From<sled::Error> for UserManagerError {
|
||||
fn from(error: sled::Error) -> Self {
|
||||
Self::DatabaseError(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<sled::transaction::TransactionError> for UserManagerError {
|
||||
fn from(error: sled::transaction::TransactionError) -> Self {
|
||||
Self::DatabaseTransactionError(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<bincode::Error> for UserManagerError {
|
||||
fn from(error: bincode::Error) -> Self {
|
||||
Self::DeserializeError(error)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "user_management_advanced")]
|
||||
impl From<argon2::Error> for UserManagerError {
|
||||
fn from(error: argon2::Error) -> Self {
|
||||
Self::Argon2Error(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for UserManagerError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::DatabaseError(e) => Some(e),
|
||||
Self::DatabaseTransactionError(e) => Some(e),
|
||||
Self::DeserializeError(e) => Some(e),
|
||||
#[cfg(feature = "user_management_advanced")]
|
||||
Self::Argon2Error(e) => Some(e),
|
||||
_ => None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for UserManagerError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::result::Result<(), std::fmt::Error> {
|
||||
match self {
|
||||
Self::UsernameNotUnique =>
|
||||
write!(f, "Attempted to create a user using a username that's already been taken"),
|
||||
Self::PasswordNotSet =>
|
||||
write!(f, "Attempted to check the password of a user who has not set one yet"),
|
||||
Self::DatabaseError(e) =>
|
||||
write!(f, "Error accessing the user database: {}", e),
|
||||
Self::DatabaseTransactionError(e) =>
|
||||
write!(f, "Error accessing the user database: {}", e),
|
||||
Self::DeserializeError(e) =>
|
||||
write!(f, "Recieved messy data from database, possible corruption: {}", e),
|
||||
#[cfg(feature = "user_management_advanced")]
|
||||
Self::Argon2Error(e) =>
|
||||
write!(f, "Argon2 Error, likely malformed password hash, possible database corruption: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, UserManagerError>;
|
7
src/user_management/pages/askcert/exists.gmi
Normal file
7
src/user_management/pages/askcert/exists.gmi
Normal file
|
@ -0,0 +1,7 @@
|
|||
# Account Exists
|
||||
|
||||
Hi {username}!
|
||||
|
||||
It looks like you already have an account all set up, and you're good to go. The link below will take you back to the app.
|
||||
|
||||
=> {redirect} Back
|
11
src/user_management/pages/askcert/success.gmi
Normal file
11
src/user_management/pages/askcert/success.gmi
Normal file
|
@ -0,0 +1,11 @@
|
|||
# Certificate Found!
|
||||
|
||||
Your certificate was found, and you're good to go.
|
||||
|
||||
If this is your first time, please create an account to get started!
|
||||
|
||||
=> /account/register Sign Up
|
||||
|
||||
If you already have an account, and this is a new certificate that you'd like to link, you can login using your password (if you've set it) below.
|
||||
|
||||
=> /account/login Log In
|
7
src/user_management/pages/login/success.gmi
Normal file
7
src/user_management/pages/login/success.gmi
Normal file
|
@ -0,0 +1,7 @@
|
|||
# Success!
|
||||
|
||||
Welcome {username}!
|
||||
|
||||
Your certificate has been linked.
|
||||
|
||||
=> {redirect} Back to app
|
8
src/user_management/pages/login/wrong.gmi
Normal file
8
src/user_management/pages/login/wrong.gmi
Normal file
|
@ -0,0 +1,8 @@
|
|||
# Wrong username or password
|
||||
|
||||
Sorry {username},
|
||||
|
||||
It looks like that username and password didn't match.
|
||||
|
||||
=> /account/login/{username} Try another password
|
||||
=> /account/login That's not me!
|
5
src/user_management/pages/nopass/askcert/success.gmi
Normal file
5
src/user_management/pages/nopass/askcert/success.gmi
Normal file
|
@ -0,0 +1,5 @@
|
|||
# Certificate Found!
|
||||
|
||||
Your certificate was found, all that's left to do is pick a username!
|
||||
|
||||
=> /account/register Sign Up
|
5
src/user_management/pages/nopass/nsi.gmi
Normal file
5
src/user_management/pages/nopass/nsi.gmi
Normal file
|
@ -0,0 +1,5 @@
|
|||
# Welcome!
|
||||
|
||||
To continue, please create an account.
|
||||
|
||||
=> /account/register Set up my account
|
5
src/user_management/pages/nopass/register/exists.gmi
Normal file
5
src/user_management/pages/nopass/register/exists.gmi
Normal file
|
@ -0,0 +1,5 @@
|
|||
# Username Exists
|
||||
|
||||
Unfortunately, it looks like the username {username} is already taken.
|
||||
|
||||
=> /account/register Choose a different username
|
5
src/user_management/pages/nopass/register/success.gmi
Normal file
5
src/user_management/pages/nopass/register/success.gmi
Normal file
|
@ -0,0 +1,5 @@
|
|||
# Account Created!
|
||||
|
||||
Welcome {username}! Your account has been created.
|
||||
|
||||
=> {redirect} Back to app
|
8
src/user_management/pages/nsi.gmi
Normal file
8
src/user_management/pages/nsi.gmi
Normal file
|
@ -0,0 +1,8 @@
|
|||
# Welcome!
|
||||
|
||||
To continue, please create an account, or log in to link this certificate to an existing account.
|
||||
|
||||
=> /account/login Log In
|
||||
=> /account/register Sign Up
|
||||
|
||||
Note: You can only link a new certificate if you have set a password using your original certificate. If you haven't already, please log in and set a password.
|
5
src/user_management/pages/password/success.gmi
Normal file
5
src/user_management/pages/password/success.gmi
Normal file
|
@ -0,0 +1,5 @@
|
|||
# Password Updated
|
||||
|
||||
To add a certificate, log in using the new certificate and provide your username and password.
|
||||
|
||||
=> /account Back to settings
|
6
src/user_management/pages/register/exists.gmi
Normal file
6
src/user_management/pages/register/exists.gmi
Normal file
|
@ -0,0 +1,6 @@
|
|||
# Username Exists
|
||||
|
||||
Unfortunately, it looks like the username {username} is already taken. If this is your account, and you have a password set, you can link this certificate to your account. Otherwise, please choose another username.
|
||||
|
||||
=> /account/register Choose a different username
|
||||
=> /account/login/{username} Link this certificate
|
6
src/user_management/pages/register/success.gmi
Normal file
6
src/user_management/pages/register/success.gmi
Normal file
|
@ -0,0 +1,6 @@
|
|||
# Account Created!
|
||||
|
||||
Welcome {username}! Your account has been created. The link below will take you back to the app, or you can take a moment to review your account settings, including adding a password.
|
||||
|
||||
=> {redirect} Back
|
||||
=> /account Settings
|
9
src/user_management/pages/unauth.gmi
Normal file
9
src/user_management/pages/unauth.gmi
Normal file
|
@ -0,0 +1,9 @@
|
|||
# Welcome!
|
||||
|
||||
It seems like you don't have a client certificate enabled. In order to log in, you need to connect using a client certificate. If your client supports it, you can use the link below to activate a certificate.
|
||||
|
||||
=> /account/askcert Choose a Certificate
|
||||
|
||||
If your client can't automatically manage client certificates, check the link below for a list of clients that support client certificates.
|
||||
|
||||
=> /account/clients Clients
|
361
src/user_management/routes.rs
Normal file
361
src/user_management/routes.rs
Normal file
|
@ -0,0 +1,361 @@
|
|||
use anyhow::Result;
|
||||
use tokio::net::ToSocketAddrs;
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
|
||||
use std::future::Future;
|
||||
|
||||
use crate::{Document, Request, Response};
|
||||
use crate::types::document::HeadingLevel;
|
||||
use crate::user_management::{User, RegisteredUser, UserManagerError};
|
||||
|
||||
const UNAUTH: &str = include_str!("pages/unauth.gmi");
|
||||
#[cfg(feature = "user_management_advanced")]
|
||||
const NSI: &str = include_str!("pages/nsi.gmi");
|
||||
#[cfg(not(feature = "user_management_advanced"))]
|
||||
const NSI: &str = include_str!("pages/nopass/nsi.gmi");
|
||||
|
||||
/// Import this trait to use [`add_um_routes()`](Self::add_um_routes())
|
||||
pub trait UserManagementRoutes: private::Sealed {
|
||||
/// Add pre-configured routes to the serve to handle authentication
|
||||
///
|
||||
/// Specifically, the following routes are added:
|
||||
/// * `/account`, the main settings & login page
|
||||
/// * `/account/askcert`, a page which always prompts for a certificate
|
||||
/// * `/account/register`, for users to register a new account
|
||||
/// * `/account/login`, for users to link their certificate to an existing account
|
||||
/// * `/account/password`, to change the user's password
|
||||
///
|
||||
/// If this method is used, no more routes should be added under `/account`. If you
|
||||
/// would like to direct a user to login from your application, you should send them
|
||||
/// to `/account`, which will start the login/registration flow.
|
||||
///
|
||||
/// The `redir` argument allows you to specify the point that users will be directed
|
||||
/// to return to once their account has been created.
|
||||
fn add_um_routes<UserData: Serialize + DeserializeOwned + Default + 'static>(self, redir: &'static str) -> Self;
|
||||
|
||||
/// Add a special route that requires users to be logged in
|
||||
///
|
||||
/// In addition to the normal [`Request`], your handler will recieve a copy of the
|
||||
/// [`RegisteredUser`] for the current user. If a user tries to connect to the page
|
||||
/// without logging in, they will be prompted to register or link an account.
|
||||
///
|
||||
/// To use this method, ensure that [`add_um_routes()`](Self::add_um_routes()) has
|
||||
/// also been called.
|
||||
fn add_authenticated_route<UserData, Handler, F>(
|
||||
self,
|
||||
path: &'static str,
|
||||
handler: Handler,
|
||||
) -> Self
|
||||
where
|
||||
UserData: Serialize + DeserializeOwned + 'static + Send + Sync,
|
||||
Handler: Clone + Send + Sync + 'static + Fn(Request, RegisteredUser<UserData>) -> F,
|
||||
F: Send + Sync + 'static + Future<Output = Result<Response>>;
|
||||
|
||||
/// Add a special route that requires users to be logged in AND takes input
|
||||
///
|
||||
/// Like with [`add_authenticated_route()`](Self::add_authenticated_route()), this
|
||||
/// prompts the user to log in if they haven't already, but additionally prompts the
|
||||
/// user for input before running the handler with both the user object and the input
|
||||
/// they provided.
|
||||
///
|
||||
/// To a user, this might look something like this:
|
||||
/// * Click a link to `/your/route`
|
||||
/// * See a screen asking you to sign in or create an account
|
||||
/// * Create a new account, and return to the app.
|
||||
/// * Now, clicking the link shows the prompt provided.
|
||||
/// * After entering some value, the user receives the response from the handler.
|
||||
///
|
||||
/// For a user whose already logged in, this will just look like a normal input route,
|
||||
/// where they enter some query and see a page. This method just takes the burden of
|
||||
/// having to check if the user sent a query string and respond with an INPUT response
|
||||
/// if not.
|
||||
///
|
||||
/// To use this method, ensure that [`add_um_routes()`](Self::add_um_routes()) has
|
||||
/// also been called.
|
||||
fn add_authenticated_input_route<UserData, Handler, F>(
|
||||
self,
|
||||
path: &'static str,
|
||||
prompt: &'static str,
|
||||
handler: Handler,
|
||||
) -> Self
|
||||
where
|
||||
UserData: Serialize + DeserializeOwned + 'static + Send + Sync,
|
||||
Handler: Clone + Send + Sync + 'static + Fn(Request, RegisteredUser<UserData>, String) -> F,
|
||||
F: Send + Sync + 'static + Future<Output = Result<Response>>;
|
||||
}
|
||||
|
||||
impl<A: ToSocketAddrs> UserManagementRoutes for crate::Builder<A> {
|
||||
/// Add pre-configured routes to the serve to handle authentication
|
||||
///
|
||||
/// See [`UserManagementRoutes::add_um_routes()`]
|
||||
fn add_um_routes<UserData: Serialize + DeserializeOwned + Default + 'static>(self, redir: &'static str) -> Self {
|
||||
#[allow(unused_mut)]
|
||||
let mut modified_self = self.add_route("/account", move|r|handle_base::<UserData>(r, redir))
|
||||
.add_route("/account/askcert", move|r|handle_ask_cert::<UserData>(r, redir))
|
||||
.add_route("/account/register", move|r|handle_register::<UserData>(r, redir));
|
||||
|
||||
#[cfg(feature = "user_management_advanced")] {
|
||||
modified_self = modified_self
|
||||
.add_route("/account/login", move|r|handle_login::<UserData>(r, redir))
|
||||
.add_route("/account/password", handle_password::<UserData>);
|
||||
}
|
||||
|
||||
modified_self
|
||||
}
|
||||
|
||||
/// Add a special route that requires users to be logged in
|
||||
///
|
||||
/// See [`UserManagementRoutes::add_authenticated_route()`]
|
||||
fn add_authenticated_route<UserData, Handler, F>(
|
||||
self,
|
||||
path: &'static str,
|
||||
handler: Handler,
|
||||
) -> Self
|
||||
where
|
||||
UserData: Serialize + DeserializeOwned + 'static + Send + Sync,
|
||||
Handler: Clone + Send + Sync + 'static + Fn(Request, RegisteredUser<UserData>) -> F,
|
||||
F: Send + Sync + 'static + Future<Output = Result<Response>>
|
||||
{
|
||||
self.add_route(path, move|request| {
|
||||
let handler = handler.clone();
|
||||
async move {
|
||||
Ok(match request.user::<UserData>()? {
|
||||
User::Unauthenticated => {
|
||||
Response::success_gemini(UNAUTH)
|
||||
},
|
||||
User::NotSignedIn(_) => {
|
||||
Response::success_gemini(NSI)
|
||||
},
|
||||
User::SignedIn(user) => {
|
||||
(handler)(request, user).await?
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Add a special route that requires users to be logged in AND takes input
|
||||
///
|
||||
/// See [`UserManagementRoutes::add_authenticated_input_route()`]
|
||||
fn add_authenticated_input_route<UserData, Handler, F>(
|
||||
self,
|
||||
path: &'static str,
|
||||
prompt: &'static str,
|
||||
handler: Handler,
|
||||
) -> Self
|
||||
where
|
||||
UserData: Serialize + DeserializeOwned + 'static + Send + Sync,
|
||||
Handler: Clone + Send + Sync + 'static + Fn(Request, RegisteredUser<UserData>, String) -> F,
|
||||
F: Send + Sync + 'static + Future<Output = Result<Response>>
|
||||
{
|
||||
self.add_authenticated_route(path, move|request, user| {
|
||||
let handler = handler.clone();
|
||||
async move {
|
||||
if let Some(input) = request.input().map(str::to_owned) {
|
||||
(handler.clone())(request, user, input).await
|
||||
} else {
|
||||
Response::input(prompt)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_base<UserData: Serialize + DeserializeOwned>(request: Request, redirect: &'static str) -> Result<Response> {
|
||||
Ok(match request.user::<UserData>()? {
|
||||
User::Unauthenticated => {
|
||||
Response::success_gemini(UNAUTH)
|
||||
},
|
||||
User::NotSignedIn(_) => {
|
||||
Response::success_gemini(NSI)
|
||||
},
|
||||
User::SignedIn(user) => {
|
||||
render_settings_menu(user, redirect)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async fn handle_ask_cert<UserData: Serialize + DeserializeOwned>(request: Request, redirect: &'static str) -> Result<Response> {
|
||||
Ok(match request.user::<UserData>()? {
|
||||
User::Unauthenticated => {
|
||||
Response::client_certificate_required()
|
||||
},
|
||||
User::NotSignedIn(_) => {
|
||||
#[cfg(feature = "user_management_advanced")] {
|
||||
Response::success_gemini(include_str!("pages/askcert/success.gmi"))
|
||||
}
|
||||
#[cfg(not(feature = "user_management_advanced"))] {
|
||||
Response::success_gemini(include_str!("pages/nopass/askcert/success.gmi"))
|
||||
}
|
||||
},
|
||||
User::SignedIn(user) => {
|
||||
Response::success_gemini(format!(
|
||||
include_str!("pages/askcert/exists.gmi"),
|
||||
username = user.username(),
|
||||
redirect = redirect,
|
||||
))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async fn handle_register<UserData: Serialize + DeserializeOwned + Default>(request: Request, redirect: &'static str) -> Result<Response> {
|
||||
Ok(match request.user::<UserData>()? {
|
||||
User::Unauthenticated => {
|
||||
Response::success_gemini(UNAUTH)
|
||||
},
|
||||
User::NotSignedIn(nsi) => {
|
||||
if let Some(username) = request.input() {
|
||||
match nsi.register::<UserData>(username.to_owned()) {
|
||||
Err(UserManagerError::UsernameNotUnique) => {
|
||||
#[cfg(feature = "user_management_advanced")] {
|
||||
Response::success_gemini(format!(
|
||||
include_str!("pages/register/exists.gmi"),
|
||||
username = username,
|
||||
))
|
||||
}
|
||||
#[cfg(not(feature = "user_management_advanced"))] {
|
||||
Response::success_gemini(format!(
|
||||
include_str!("pages/register/exists.gmi"),
|
||||
username = username,
|
||||
))
|
||||
}
|
||||
},
|
||||
Ok(_) => {
|
||||
#[cfg(feature = "user_management_advanced")] {
|
||||
Response::success_gemini(format!(
|
||||
include_str!("pages/register/success.gmi"),
|
||||
username = username,
|
||||
redirect = redirect,
|
||||
))
|
||||
}
|
||||
#[cfg(not(feature = "user_management_advanced"))] {
|
||||
Response::success_gemini(format!(
|
||||
include_str!("pages/nopass/register/success.gmi"),
|
||||
username = username,
|
||||
redirect = redirect,
|
||||
))
|
||||
}
|
||||
},
|
||||
Err(e) => return Err(e.into())
|
||||
}
|
||||
} else {
|
||||
Response::input_lossy("Please pick a username")
|
||||
}
|
||||
},
|
||||
User::SignedIn(user) => {
|
||||
render_settings_menu(user, redirect)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(feature = "user_management_advanced")]
|
||||
async fn handle_login<UserData: Serialize + DeserializeOwned + Default>(request: Request, redirect: &'static str) -> Result<Response> {
|
||||
Ok(match request.user::<UserData>()? {
|
||||
User::Unauthenticated => {
|
||||
Response::success_gemini(UNAUTH)
|
||||
},
|
||||
User::NotSignedIn(nsi) => {
|
||||
if let Some(username) = request.trailing_segments().get(0) {
|
||||
if let Some(password) = request.input() {
|
||||
match nsi.attach::<UserData>(username, Some(password.as_bytes())) {
|
||||
Err(UserManagerError::PasswordNotSet) | Ok(None) => {
|
||||
Response::success_gemini(format!(
|
||||
include_str!("pages/login/wrong.gmi"),
|
||||
username = username,
|
||||
))
|
||||
},
|
||||
Ok(_) => {
|
||||
Response::success_gemini(format!(
|
||||
include_str!("pages/login/success.gmi"),
|
||||
username = username,
|
||||
redirect = redirect,
|
||||
))
|
||||
},
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
} else {
|
||||
Response::input_lossy("Please enter your password")
|
||||
}
|
||||
} else if let Some(username) = request.input() {
|
||||
Response::redirect_temporary_lossy(
|
||||
format!("/account/login/{}", username).as_str()
|
||||
)
|
||||
} else {
|
||||
Response::input_lossy("Please enter your username")
|
||||
}
|
||||
},
|
||||
User::SignedIn(user) => {
|
||||
render_settings_menu(user, redirect)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(feature = "user_management_advanced")]
|
||||
async fn handle_password<UserData: Serialize + DeserializeOwned + Default>(request: Request) -> Result<Response> {
|
||||
Ok(match request.user::<UserData>()? {
|
||||
User::Unauthenticated => {
|
||||
Response::success_gemini(UNAUTH)
|
||||
},
|
||||
User::NotSignedIn(_) => {
|
||||
Response::success_gemini(NSI)
|
||||
},
|
||||
User::SignedIn(mut user) => {
|
||||
if let Some(password) = request.input() {
|
||||
user.set_password(password)?;
|
||||
Response::success_gemini(include_str!("pages/password/success.gmi"))
|
||||
} else {
|
||||
Response::input(
|
||||
format!("Please enter a {}password",
|
||||
if user.has_password() {
|
||||
"new "
|
||||
} else {
|
||||
""
|
||||
}
|
||||
)
|
||||
)?
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
fn render_settings_menu<UserData: Serialize + DeserializeOwned>(
|
||||
user: RegisteredUser<UserData>,
|
||||
redirect: &str
|
||||
) -> Response {
|
||||
let mut document = Document::new();
|
||||
document
|
||||
.add_heading(HeadingLevel::H1, "User Settings")
|
||||
.add_blank_line()
|
||||
.add_text(&format!("Welcome {}!", user.username()))
|
||||
.add_blank_line()
|
||||
.add_link(redirect, "Back to the app")
|
||||
.add_blank_line();
|
||||
|
||||
#[cfg(feature = "user_management_advanced")]
|
||||
document
|
||||
.add_text(
|
||||
if user.has_password() {
|
||||
concat!(
|
||||
"You currently have a password set. This can be used to link any new",
|
||||
" certificates or clients to your account. If you don't remember your",
|
||||
" password, or would like to change it, you may do so here.",
|
||||
)
|
||||
} else {
|
||||
concat!(
|
||||
"You don't currently have a password set! Without a password, you cannot",
|
||||
" link any new certificates to your account, and if you lose your current",
|
||||
" client or certificate, you won't be able to recover your account.",
|
||||
)
|
||||
}
|
||||
)
|
||||
.add_blank_line()
|
||||
.add_link("/account/password", if user.has_password() { "Change password" } else { "Set password" });
|
||||
|
||||
document.into()
|
||||
}
|
||||
|
||||
mod private {
|
||||
pub trait Sealed {}
|
||||
impl<A> Sealed for crate::Builder<A> {}
|
||||
}
|
398
src/user_management/user.rs
Normal file
398
src/user_management/user.rs
Normal file
|
@ -0,0 +1,398 @@
|
|||
//! Several structs representing data about users
|
||||
//!
|
||||
//! This module contains any structs needed to store and retrieve data about users. The
|
||||
//! different varieties have different purposes and come from different places.
|
||||
//!
|
||||
//! [`User`] is the most common for of user struct, and typically comes from calling
|
||||
//! [`Request::user()`](crate::types::Request::user()). This is an enum with several
|
||||
//! variants, and can be specialized into a [`NotSignedInUser`] or a [`RegisteredUser`] if
|
||||
//! the user has presented a certificate. These two subtypes have more specific
|
||||
//! information, like the user's username and active certificate.
|
||||
//!
|
||||
//! [`RegisteredUser`] is particularly signifigant in that this is the struct used to modify
|
||||
//! the data stored for almost all users. This is accomplished through the
|
||||
//! [`as_mut()`](RegisteredUser::as_mut) method. Changes made this way must be persisted
|
||||
//! using [`save()`](RegisteredUser::save()) or by dropping the user.
|
||||
use rustls::Certificate;
|
||||
use serde::{Deserialize, Serialize, de::DeserializeOwned};
|
||||
use sled::Transactional;
|
||||
|
||||
use crate::user_management::UserManager;
|
||||
use crate::user_management::Result;
|
||||
use crate::user_management::manager::CertificateData;
|
||||
|
||||
#[cfg(feature = "user_management_advanced")]
|
||||
const ARGON2_CONFIG: argon2::Config = argon2::Config {
|
||||
ad: &[],
|
||||
hash_length: 32,
|
||||
lanes: 1,
|
||||
mem_cost: 4096,
|
||||
secret: &[],
|
||||
thread_mode: argon2::ThreadMode::Sequential,
|
||||
time_cost: 3,
|
||||
variant: argon2::Variant::Argon2id,
|
||||
version: argon2::Version::Version13,
|
||||
};
|
||||
|
||||
#[cfg(feature = "user_management_advanced")]
|
||||
lazy_static::lazy_static! {
|
||||
static ref RANDOM: ring::rand::SystemRandom = ring::rand::SystemRandom::new();
|
||||
}
|
||||
|
||||
/// An struct corresponding to the data stored in the user tree
|
||||
///
|
||||
/// In order to generate a full user obj, you need to perform a lookup with a specific
|
||||
/// certificate.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub (crate) struct PartialUser<UserData> {
|
||||
pub data: UserData,
|
||||
pub certificates: Vec<u32>,
|
||||
#[cfg(feature = "user_management_advanced")]
|
||||
pub pass_hash: Option<(Vec<u8>, [u8; 32])>,
|
||||
}
|
||||
|
||||
impl<UserData> PartialUser<UserData> {
|
||||
|
||||
/// Write user data to the database
|
||||
///
|
||||
/// This MUST be called if the user data is modified using the AsMut trait, or else
|
||||
/// changes will not be written to the database
|
||||
fn store(&self, tree: &sled::Tree, username: impl AsRef<[u8]>) -> Result<()>
|
||||
where
|
||||
UserData: Serialize
|
||||
{
|
||||
tree.insert(
|
||||
&username,
|
||||
bincode::serialize(&self)?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Any information about the connecting user
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum User<UserData: Serialize + DeserializeOwned> {
|
||||
/// A user who is connected without using a client certificate.
|
||||
///
|
||||
/// This could be a user who has an account but just isn't presenting a certificate at
|
||||
/// the minute, a user whose client does not support client certificates, or a user
|
||||
/// who has not yet created a certificate for the site
|
||||
Unauthenticated,
|
||||
|
||||
/// A user who is connecting with a certificate that isn't connected to an account
|
||||
///
|
||||
/// This is typically a new user who hasn't set up an account yet, or a user
|
||||
/// connecting with a new certificate that needs to be added to an existing account.
|
||||
NotSignedIn(NotSignedInUser),
|
||||
|
||||
/// A user connecting with an identified account
|
||||
SignedIn(RegisteredUser<UserData>),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
/// Data about a user with a certificate not associated with an account
|
||||
///
|
||||
/// For more information about the user lifecycle and sign-in stages, see [`User`]
|
||||
pub struct NotSignedInUser {
|
||||
pub (crate) certificate: Certificate,
|
||||
pub (crate) manager: UserManager,
|
||||
}
|
||||
|
||||
impl NotSignedInUser {
|
||||
/// Register a new user with this certificate.
|
||||
///
|
||||
/// This creates a new user & user data entry in the database with the given username.
|
||||
/// From now on, when this user logs in with this certificate, they will be
|
||||
/// automatically authenticated, and their user data automatically retrieved.
|
||||
///
|
||||
/// # Errors
|
||||
/// The provided username must be unique, or else an error will be raised.
|
||||
///
|
||||
/// Additional errors might occur if there is a problem writing to the database
|
||||
pub fn register<UserData: Serialize + DeserializeOwned + Default>(
|
||||
self,
|
||||
username: String,
|
||||
) -> Result<RegisteredUser<UserData>> {
|
||||
if self.manager.users.contains_key(username.as_str())? {
|
||||
Err(super::UserManagerError::UsernameNotUnique)
|
||||
} else {
|
||||
let mut newser = RegisteredUser::new(
|
||||
username,
|
||||
Some(self.certificate.clone()),
|
||||
self.manager,
|
||||
PartialUser {
|
||||
data: UserData::default(),
|
||||
certificates: Vec::with_capacity(1),
|
||||
#[cfg(feature = "user_management_advanced")]
|
||||
pass_hash: None,
|
||||
},
|
||||
);
|
||||
|
||||
// As a nice bonus, calling add_certificate with a user not yet in the
|
||||
// database creates the user and adds the certificate in a single transaction.
|
||||
// Because of this, we can delegate here ^^
|
||||
newser.add_certificate(self.certificate)?;
|
||||
|
||||
Ok(newser)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "user_management_advanced")]
|
||||
/// Attach this certificate to an existing user
|
||||
///
|
||||
/// Try to add this certificate to another user using a username and password. If
|
||||
/// successful, the user this certificate is attached to will be able to automatically
|
||||
/// log in with either this certificate or any of the certificates they already had
|
||||
/// registered.
|
||||
///
|
||||
/// This method can check the user's password to ensure that they match before
|
||||
/// registering. If you want to skip this verification, perhaps because you've
|
||||
/// already verified that this user owns this account, then you can pass [`None`] as
|
||||
/// the password to skip the password check.
|
||||
///
|
||||
/// This method returns the new RegisteredUser instance representing the now-attached
|
||||
/// user, or [`None`] if the username and password didn't match.
|
||||
///
|
||||
/// Because this method both performs a bcrypt verification and a database access, it
|
||||
/// should be considered expensive.
|
||||
///
|
||||
/// If you already have a [`RegisteredUser`] that you would like to attach a
|
||||
/// certificate to, consider using [`RegisteredUser::add_certificate()`]
|
||||
///
|
||||
/// # Errors
|
||||
/// This will error if the user has yet to set a password.
|
||||
///
|
||||
/// Additional errors might occur if an error occurs during database lookup and
|
||||
/// deserialization
|
||||
pub fn attach<UserData: Serialize + DeserializeOwned>(
|
||||
self,
|
||||
username: &str,
|
||||
password: Option<&[u8]>,
|
||||
) -> Result<Option<RegisteredUser<UserData>>> {
|
||||
if let Some(mut user) = self.manager.lookup_user(username)? {
|
||||
// Perform password check, if caller wants
|
||||
if let Some(password) = password {
|
||||
if !user.check_password(password)? {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
|
||||
user.add_certificate(self.certificate)?;
|
||||
Ok(Some(user))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
/// Data about a logged in user
|
||||
///
|
||||
/// For more information about the user lifecycle and sign-in stages, see [`User`]
|
||||
pub struct RegisteredUser<UserData: Serialize + DeserializeOwned> {
|
||||
username: String,
|
||||
active_certificate: Option<Certificate>,
|
||||
manager: UserManager,
|
||||
inner: PartialUser<UserData>,
|
||||
/// Indicates that [`RegisteredUser::as_mut()`] has been called, but [`RegisteredUser::save()`] has not
|
||||
has_changed: bool,
|
||||
}
|
||||
|
||||
impl<UserData: Serialize + DeserializeOwned> RegisteredUser<UserData> {
|
||||
|
||||
/// Create a new user from parts
|
||||
pub (crate) fn new(
|
||||
username: String,
|
||||
active_certificate: Option<Certificate>,
|
||||
manager: UserManager,
|
||||
inner: PartialUser<UserData>
|
||||
) -> Self {
|
||||
Self {
|
||||
username,
|
||||
active_certificate,
|
||||
manager,
|
||||
inner,
|
||||
has_changed: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the active certificate
|
||||
///
|
||||
/// This is not to be confused with [`RegisteredUser::add_certificate`], which
|
||||
/// performs the database operations needed to register a new certificate to a user.
|
||||
/// This literally just marks the active certificate.
|
||||
pub (crate) fn with_cert(mut self, cert: Certificate) -> Self {
|
||||
self.active_certificate = Some(cert);
|
||||
self
|
||||
}
|
||||
|
||||
/// Get the [`Certificate`] that the user is currently using to connect.
|
||||
///
|
||||
/// If this user was retrieved by a [`UserManager::lookup_user()`], this will be
|
||||
/// [`None`]. In all other cases, this will be [`Some`].
|
||||
pub fn active_certificate(&self) -> Option<&Certificate> {
|
||||
self.active_certificate.as_ref()
|
||||
}
|
||||
|
||||
/// Produce a list of all [`Certificate`]s registered to this account
|
||||
pub fn all_certificates(&self) -> Vec<Certificate> {
|
||||
self.inner.certificates
|
||||
.iter()
|
||||
.map(
|
||||
|cid| self.manager.lookup_certificate(*cid)
|
||||
.expect("Database corruption: User refers to non-existant certificate")
|
||||
.expect("Error accessing database")
|
||||
.certificate
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get the user's current username.
|
||||
///
|
||||
/// NOTE: This is not guaranteed not to change.
|
||||
pub fn username(&self) -> &String {
|
||||
&self.username
|
||||
}
|
||||
|
||||
#[cfg(feature = "user_management_advanced")]
|
||||
/// Check a password against the user's password hash
|
||||
///
|
||||
/// # Errors
|
||||
/// An error is raised if the user has yet to set a password, or if the user's
|
||||
/// password hash is somehow malformed.
|
||||
pub fn check_password(
|
||||
&self,
|
||||
try_password: impl AsRef<[u8]>
|
||||
) -> Result<bool> {
|
||||
if let Some((hash, salt)) = &self.inner.pass_hash {
|
||||
Ok(argon2::verify_raw(
|
||||
try_password.as_ref(),
|
||||
salt,
|
||||
hash.as_ref(),
|
||||
&ARGON2_CONFIG,
|
||||
)?)
|
||||
} else {
|
||||
Err(super::UserManagerError::PasswordNotSet)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "user_management_advanced")]
|
||||
/// Set's the password for this user
|
||||
///
|
||||
/// By default, users have no password, meaning the cannot add any certificates beyond
|
||||
/// the one they created their account with. However, by setting their password,
|
||||
/// users are able to add more devices to their account, and recover their account if
|
||||
/// it's lost. Note that this will completely overwrite the users old password.
|
||||
///
|
||||
/// Use [`RegisteredUser::check_password()`] and [`NotSignedInUser::attach()`] to check
|
||||
/// the password against another one, or to link a new certificate.
|
||||
///
|
||||
/// Because this method uses a key derivation algorithm, this should be considered a
|
||||
/// very expensive operation.
|
||||
pub fn set_password(
|
||||
&mut self,
|
||||
password: impl AsRef<[u8]>,
|
||||
) -> Result<()> {
|
||||
let salt: [u8; 32] = ring::rand::generate(&*RANDOM)
|
||||
.expect("Error generating random salt")
|
||||
.expose();
|
||||
self.inner.pass_hash = Some((
|
||||
argon2::hash_raw(
|
||||
password.as_ref(),
|
||||
salt.as_ref(),
|
||||
&ARGON2_CONFIG,
|
||||
)?,
|
||||
salt,
|
||||
));
|
||||
self.has_changed = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write any updates to the user to the database.
|
||||
///
|
||||
/// Updates caused by calling methods directly on the user do not need to be saved.
|
||||
/// This is only for changes made to the UserData.
|
||||
pub fn save(&mut self) -> Result<()>
|
||||
where
|
||||
UserData: Serialize
|
||||
{
|
||||
self.inner.store(&self.manager.users, &self.username)?;
|
||||
self.has_changed = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register a new certificate to this user
|
||||
///
|
||||
/// This adds a new certificate to this user for use in logins. This requires a
|
||||
/// couple database accesses, one in order to link the user to the certificate, and
|
||||
/// one in order to link the certificate to the user.
|
||||
///
|
||||
/// If you have a [`NotSignedInUser`] and are looking for a way to link them to an
|
||||
/// existing user, consider [`NotSignedInUser::attach()`], which contains facilities for
|
||||
/// password checking and automatically performs the user lookup.
|
||||
pub fn add_certificate(&mut self, certificate: Certificate) -> Result<()> {
|
||||
let cert_hash = UserManager::hash_certificate(&certificate);
|
||||
|
||||
self.inner.certificates.push(cert_hash);
|
||||
|
||||
let cert_info = CertificateData {
|
||||
certificate,
|
||||
owner_username: self.username.clone(),
|
||||
};
|
||||
|
||||
let inner_serialized = bincode::serialize(&self.inner)?;
|
||||
let cert_info_serialized = bincode::serialize(&cert_info)?;
|
||||
|
||||
(&self.manager.users, &self.manager.certificates)
|
||||
.transaction(|(tx_usr, tx_crt)| {
|
||||
tx_usr.insert(
|
||||
self.username.as_str(),
|
||||
inner_serialized.clone(),
|
||||
)?;
|
||||
tx_crt.insert(
|
||||
cert_hash.to_le_bytes().as_ref(),
|
||||
cert_info_serialized.clone(),
|
||||
)?;
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "user_management_advanced")]
|
||||
/// Check if the user has a password set
|
||||
///
|
||||
/// Since authentication is done using client certificates, users aren't required to
|
||||
/// set a password up front. In some cases, it may be useful to know if a user has or
|
||||
/// has not set a password yet.
|
||||
///
|
||||
/// This returns `true` if the user has a password set, or `false` otherwise
|
||||
pub fn has_password(&self) -> bool {
|
||||
self.inner.pass_hash.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl<UserData: Serialize + DeserializeOwned> std::ops::Drop for RegisteredUser<UserData> {
|
||||
fn drop(&mut self) {
|
||||
if self.has_changed {
|
||||
if let Err(e) = self.save() {
|
||||
error!("Failed to save user data to database: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<UserData: Serialize + DeserializeOwned> AsRef<UserData> for RegisteredUser<UserData> {
|
||||
fn as_ref(&self) -> &UserData {
|
||||
&self.inner.data
|
||||
}
|
||||
}
|
||||
|
||||
impl<UserData: Serialize + DeserializeOwned> AsMut<UserData> for RegisteredUser<UserData> {
|
||||
/// NOTE: Changes made to the user data won't be persisted until RegisteredUser::save
|
||||
/// is called
|
||||
fn as_mut(&mut self) -> &mut UserData {
|
||||
self.has_changed = true;
|
||||
&mut self.inner.data
|
||||
}
|
||||
}
|
105
src/util.rs
105
src/util.rs
|
@ -10,10 +10,9 @@ use tokio::{
|
|||
};
|
||||
#[cfg(feature="serve_dir")]
|
||||
use crate::types::{Document, document::HeadingLevel::*};
|
||||
#[cfg(feature="serve_dir")]
|
||||
use crate::types::Response;
|
||||
use std::panic::{catch_unwind, AssertUnwindSafe};
|
||||
use std::task::Poll;
|
||||
use futures_core::future::Future;
|
||||
use std::future::Future;
|
||||
use tokio::time;
|
||||
|
||||
#[cfg(feature="serve_dir")]
|
||||
|
@ -23,27 +22,58 @@ pub async fn serve_file<P: AsRef<Path>>(path: P, mime: &Mime) -> Result<Response
|
|||
let file = match File::open(path).await {
|
||||
Ok(file) => file,
|
||||
Err(err) => match err.kind() {
|
||||
io::ErrorKind::NotFound => return Ok(Response::not_found()),
|
||||
_ => return Err(err.into()),
|
||||
std::io::ErrorKind::PermissionDenied => {
|
||||
warn!("Asked to serve {}, but permission denied by OS", path.display());
|
||||
return Ok(Response::not_found());
|
||||
},
|
||||
_ => return warn_unexpected(err, path, line!()),
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Response::success_with_body(mime, file))
|
||||
Ok(Response::success(mime, file))
|
||||
}
|
||||
|
||||
#[cfg(feature="serve_dir")]
|
||||
pub async fn serve_dir<D: AsRef<Path>, P: AsRef<Path>>(dir: D, virtual_path: &[P]) -> Result<Response> {
|
||||
debug!("Dir: {}", dir.as_ref().display());
|
||||
let dir = dir.as_ref().canonicalize()
|
||||
.context("Failed to canonicalize directory")?;
|
||||
let dir = dir.as_ref();
|
||||
let dir = match dir.canonicalize() {
|
||||
Ok(dir) => dir,
|
||||
Err(e) => {
|
||||
match e.kind() {
|
||||
std::io::ErrorKind::NotFound => {
|
||||
warn!("Path {} not found. Check your configuration.", dir.display());
|
||||
return Response::server_error("Server incorrectly configured")
|
||||
},
|
||||
std::io::ErrorKind::PermissionDenied => {
|
||||
warn!("Permission denied for {}. Check that the server has access.", dir.display());
|
||||
return Response::server_error("Server incorrectly configured")
|
||||
},
|
||||
_ => return warn_unexpected(e, dir, line!()),
|
||||
}
|
||||
},
|
||||
};
|
||||
let mut path = dir.to_path_buf();
|
||||
|
||||
for segment in virtual_path {
|
||||
path.push(segment);
|
||||
}
|
||||
|
||||
let path = path.canonicalize()
|
||||
.context("Failed to canonicalize path")?;
|
||||
let path = match path.canonicalize() {
|
||||
Ok(dir) => dir,
|
||||
Err(e) => {
|
||||
match e.kind() {
|
||||
std::io::ErrorKind::NotFound => return Ok(Response::not_found()),
|
||||
std::io::ErrorKind::PermissionDenied => {
|
||||
// Runs when asked to serve a file in a restricted dir
|
||||
// i.e. not /noaccess, but /noaccess/file
|
||||
warn!("Asked to serve {}, but permission denied by OS", path.display());
|
||||
return Ok(Response::not_found());
|
||||
},
|
||||
_ => return warn_unexpected(e, path.as_ref(), line!()),
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if !path.starts_with(&dir) {
|
||||
return Ok(Response::not_found());
|
||||
|
@ -59,11 +89,15 @@ pub async fn serve_dir<D: AsRef<Path>, P: AsRef<Path>>(dir: D, virtual_path: &[P
|
|||
|
||||
#[cfg(feature="serve_dir")]
|
||||
async fn serve_dir_listing<P: AsRef<Path>, B: AsRef<Path>>(path: P, virtual_path: &[B]) -> Result<Response> {
|
||||
let mut dir = match fs::read_dir(path).await {
|
||||
let mut dir = match fs::read_dir(path.as_ref()).await {
|
||||
Ok(dir) => dir,
|
||||
Err(err) => match err.kind() {
|
||||
io::ErrorKind::NotFound => return Ok(Response::not_found()),
|
||||
_ => return Err(err.into()),
|
||||
std::io::ErrorKind::PermissionDenied => {
|
||||
warn!("Asked to serve {}, but permission denied by OS", path.as_ref().display());
|
||||
return Ok(Response::not_found());
|
||||
},
|
||||
_ => return warn_unexpected(err, path.as_ref(), line!()),
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -93,7 +127,7 @@ async fn serve_dir_listing<P: AsRef<Path>, B: AsRef<Path>>(path: P, virtual_path
|
|||
));
|
||||
}
|
||||
|
||||
Ok(Response::document(document))
|
||||
Ok(document.into())
|
||||
}
|
||||
|
||||
#[cfg(feature="serve_dir")]
|
||||
|
@ -112,6 +146,22 @@ pub fn guess_mime_from_path<P: AsRef<Path>>(path: P) -> Mime {
|
|||
mime_guess::from_ext(extension).first_or_octet_stream()
|
||||
}
|
||||
|
||||
#[cfg(feature="serve_dir")]
|
||||
/// Print a warning to the log asking to file an issue and respond with "Unexpected Error"
|
||||
pub (crate) fn warn_unexpected(err: impl std::fmt::Debug, path: &Path, line: u32) -> Result<Response> {
|
||||
warn!(
|
||||
concat!(
|
||||
"Unexpected error serving path {} at util.rs:{}, please report to ",
|
||||
env!("CARGO_PKG_REPOSITORY"),
|
||||
"/issues: {:?}",
|
||||
),
|
||||
path.display(),
|
||||
line,
|
||||
err
|
||||
);
|
||||
Response::server_error("Unexpected error")
|
||||
}
|
||||
|
||||
/// A convenience trait alias for `AsRef<T> + Into<T::Owned>`,
|
||||
/// most commonly used to accept `&str` or `String`:
|
||||
///
|
||||
|
@ -128,35 +178,6 @@ where
|
|||
T: ToOwned + ?Sized,
|
||||
{}
|
||||
|
||||
/// A utility for catching unwinds on Futures.
|
||||
///
|
||||
/// This is adapted from the futures-rs CatchUnwind, in an effort to reduce the large
|
||||
/// amount of dependencies tied into the feature that provides this simple struct.
|
||||
#[must_use = "futures do nothing unless polled"]
|
||||
pub (crate) struct HandlerCatchUnwind {
|
||||
future: AssertUnwindSafe<crate::HandlerResponse>,
|
||||
}
|
||||
|
||||
impl HandlerCatchUnwind {
|
||||
pub(super) fn new(future: AssertUnwindSafe<crate::HandlerResponse>) -> Self {
|
||||
Self { future }
|
||||
}
|
||||
}
|
||||
|
||||
impl Future for HandlerCatchUnwind {
|
||||
type Output = Result<Result<Response>, Box<dyn std::any::Any + Send>>;
|
||||
|
||||
fn poll(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context
|
||||
) -> Poll<Self::Output> {
|
||||
match catch_unwind(AssertUnwindSafe(|| self.future.as_mut().poll(cx))) {
|
||||
Ok(res) => res.map(Ok),
|
||||
Err(e) => Poll::Ready(Err(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn opt_timeout<T>(duration: Option<time::Duration>, future: impl Future<Output = T>) -> Result<T, time::error::Elapsed> {
|
||||
match duration {
|
||||
Some(duration) => time::timeout(duration, future).await,
|
||||
|
|
Loading…
Reference in a new issue