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
|
### Added
|
||||||
- `document` API for creating Gemini documents
|
- `document` API for creating Gemini documents
|
||||||
- preliminary timeout API, incl a special case for complex MIMEs by [@Alch-Emi]
|
- 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`
|
- `redirect_temporary_lossy` for `Response` and `ResponseHeader`
|
||||||
- `bad_request_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
|
- 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]
|
- Docments can be converted into responses with std::convert::Into [@Alch-Emi]
|
||||||
- Added ratelimiting API [@Alch-Emi]
|
- Added ratelimiting API [@Alch-Emi]
|
||||||
### Improved
|
### Improved
|
||||||
- build time and size by [@Alch-Emi](https://github.com/Alch-Emi)
|
|
||||||
- build time and size by [@Alch-Emi]
|
- build time and size by [@Alch-Emi]
|
||||||
|
- Improved error handling in serve_dir [@Alch-Emi]
|
||||||
### Changed
|
### Changed
|
||||||
- Added route API [@Alch-Emi](https://github.com/Alch-Emi)
|
- 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
|
## [0.3.0] - 2020-11-14
|
||||||
### Added
|
### Added
|
||||||
|
|
19
Cargo.toml
19
Cargo.toml
|
@ -9,6 +9,9 @@ repository = "https://github.com/panicbit/northstar"
|
||||||
documentation = "https://docs.rs/northstar"
|
documentation = "https://docs.rs/northstar"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
|
user_management = ["sled", "bincode", "serde/derive", "crc32fast"]
|
||||||
|
user_management_advanced = ["rust-argon2", "ring", "user_management"]
|
||||||
|
user_management_routes = ["user_management"]
|
||||||
default = ["serve_dir"]
|
default = ["serve_dir"]
|
||||||
serve_dir = ["mime_guess", "tokio/fs"]
|
serve_dir = ["mime_guess", "tokio/fs"]
|
||||||
ratelimiting = ["dashmap"]
|
ratelimiting = ["dashmap"]
|
||||||
|
@ -21,18 +24,30 @@ tokio = { version = "0.3.1", features = ["io-util","net","time", "rt"] }
|
||||||
mime = "0.3.16"
|
mime = "0.3.16"
|
||||||
uriparse = "0.6.3"
|
uriparse = "0.6.3"
|
||||||
percent-encoding = "2.1.0"
|
percent-encoding = "2.1.0"
|
||||||
futures-core = "0.3.7"
|
|
||||||
log = "0.4.11"
|
log = "0.4.11"
|
||||||
webpki = "0.21.0"
|
webpki = "0.21.0"
|
||||||
lazy_static = "1.4.0"
|
lazy_static = "1.4.0"
|
||||||
mime_guess = { version = "2.0.3", optional = true }
|
mime_guess = { version = "2.0.3", optional = true }
|
||||||
dashmap = { version = "3.11.10", 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]
|
[dev-dependencies]
|
||||||
env_logger = "0.8.1"
|
env_logger = "0.8.1"
|
||||||
futures-util = "0.3.7"
|
|
||||||
tokio = { version = "0.3.1", features = ["macros", "rt-multi-thread", "sync"] }
|
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]]
|
[[example]]
|
||||||
name = "ratelimiting"
|
name = "ratelimiting"
|
||||||
required-features = ["ratelimiting"]
|
required-features = ["ratelimiting"]
|
||||||
|
|
|
@ -1,9 +1,7 @@
|
||||||
use anyhow::*;
|
use anyhow::*;
|
||||||
use futures_core::future::BoxFuture;
|
|
||||||
use futures_util::FutureExt;
|
|
||||||
use log::LevelFilter;
|
use log::LevelFilter;
|
||||||
use tokio::sync::RwLock;
|
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::collections::HashMap;
|
||||||
use std::sync::Arc;
|
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.
|
/// 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
|
/// Any time this user visits the site in the future, they'll get a personalized welcome
|
||||||
/// message.
|
/// message.
|
||||||
fn handle_request(users: Arc<RwLock<HashMap<CertBytes, String>>>, request: Request) -> BoxFuture<'static, Result<Response>> {
|
async fn handle_request(users: Arc<RwLock<HashMap<CertBytes, String>>>, request: Request) -> Result<Response> {
|
||||||
async move {
|
if let Some(Certificate(cert_bytes)) = request.certificate() {
|
||||||
if let Some(Certificate(cert_bytes)) = request.certificate() {
|
// The user provided a certificate
|
||||||
// The user provided a certificate
|
let users_read = users.read().await;
|
||||||
let users_read = users.read().await;
|
if let Some(user) = users_read.get(cert_bytes) {
|
||||||
if let Some(user) = users_read.get(cert_bytes) {
|
// The user has already registered
|
||||||
// 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(
|
Ok(
|
||||||
Response::success_with_body(
|
Response::success_gemini(
|
||||||
&GEMINI_MIME,
|
format!(
|
||||||
format!("Welcome {}!", user)
|
"Your account has been created {}! Welcome!",
|
||||||
|
username
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// The user still needs to register
|
// The user didn't provide input, and should be prompted
|
||||||
drop(users_read);
|
Response::input("What username would you like?")
|
||||||
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?")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} 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 anyhow::*;
|
||||||
use futures_core::future::BoxFuture;
|
|
||||||
use futures_util::FutureExt;
|
|
||||||
use log::LevelFilter;
|
use log::LevelFilter;
|
||||||
use northstar::{Server, Request, Response, GEMINI_PORT, Document};
|
use northstar::{Server, Response, GEMINI_PORT, Document};
|
||||||
use northstar::document::HeadingLevel::*;
|
use northstar::document::HeadingLevel::*;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
|
@ -11,40 +9,34 @@ async fn main() -> Result<()> {
|
||||||
.filter_module("northstar", LevelFilter::Debug)
|
.filter_module("northstar", LevelFilter::Debug)
|
||||||
.init();
|
.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))
|
Server::bind(("localhost", GEMINI_PORT))
|
||||||
.add_route("/",handle_request)
|
.add_route("/", response)
|
||||||
.serve()
|
.serve()
|
||||||
.await
|
.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 anyhow::*;
|
||||||
use futures_core::future::BoxFuture;
|
|
||||||
use futures_util::FutureExt;
|
|
||||||
use log::LevelFilter;
|
use log::LevelFilter;
|
||||||
use northstar::{Document, document::HeadingLevel, Request, Response, GEMINI_PORT};
|
use northstar::{Document, document::HeadingLevel, Request, Response, GEMINI_PORT};
|
||||||
|
|
||||||
|
@ -18,25 +16,19 @@ async fn main() -> Result<()> {
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_base(req: Request) -> BoxFuture<'static, Result<Response>> {
|
async fn handle_base(req: Request) -> Result<Response> {
|
||||||
let doc = generate_doc("base", &req);
|
let doc = generate_doc("base", &req);
|
||||||
async move {
|
Ok(doc.into())
|
||||||
Ok(Response::document(doc))
|
|
||||||
}.boxed()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_short(req: Request) -> BoxFuture<'static, Result<Response>> {
|
async fn handle_short(req: Request) -> Result<Response> {
|
||||||
let doc = generate_doc("short", &req);
|
let doc = generate_doc("short", &req);
|
||||||
async move {
|
Ok(doc.into())
|
||||||
Ok(Response::document(doc))
|
|
||||||
}.boxed()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_long(req: Request) -> BoxFuture<'static, Result<Response>> {
|
async fn handle_long(req: Request) -> Result<Response> {
|
||||||
let doc = generate_doc("long", &req);
|
let doc = generate_doc("long", &req);
|
||||||
async move {
|
Ok(doc.into())
|
||||||
Ok(Response::document(doc))
|
|
||||||
}.boxed()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_doc(route_name: &str, req: &Request) -> Document {
|
fn generate_doc(route_name: &str, req: &Request) -> Document {
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use anyhow::*;
|
use anyhow::*;
|
||||||
use futures_core::future::BoxFuture;
|
|
||||||
use futures_util::FutureExt;
|
|
||||||
use log::LevelFilter;
|
use log::LevelFilter;
|
||||||
use northstar::{Server, Request, Response, GEMINI_PORT};
|
use northstar::{Server, GEMINI_PORT};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
|
@ -11,17 +11,8 @@ async fn main() -> Result<()> {
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
Server::bind(("localhost", GEMINI_PORT))
|
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()
|
.serve()
|
||||||
.await
|
.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;
|
#[macro_use] extern crate log;
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
panic::AssertUnwindSafe,
|
|
||||||
convert::TryFrom,
|
convert::TryFrom,
|
||||||
io::BufReader,
|
io::BufReader,
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
|
@ -10,7 +9,6 @@ use std::{
|
||||||
};
|
};
|
||||||
#[cfg(feature = "ratelimiting")]
|
#[cfg(feature = "ratelimiting")]
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
use futures_core::future::BoxFuture;
|
|
||||||
use tokio::{
|
use tokio::{
|
||||||
prelude::*,
|
prelude::*,
|
||||||
io::{self, BufStream},
|
io::{self, BufStream},
|
||||||
|
@ -34,8 +32,14 @@ use ratelimiting::RateLimiter;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
pub mod util;
|
pub mod util;
|
||||||
pub mod routing;
|
pub mod routing;
|
||||||
|
pub mod handling;
|
||||||
#[cfg(feature = "ratelimiting")]
|
#[cfg(feature = "ratelimiting")]
|
||||||
pub mod 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 mime;
|
||||||
pub use uriparse as uri;
|
pub use uriparse as uri;
|
||||||
|
@ -44,18 +48,18 @@ pub use types::*;
|
||||||
pub const REQUEST_URI_MAX_LEN: usize = 1024;
|
pub const REQUEST_URI_MAX_LEN: usize = 1024;
|
||||||
pub const GEMINI_PORT: u16 = 1965;
|
pub const GEMINI_PORT: u16 = 1965;
|
||||||
|
|
||||||
type Handler = Arc<dyn Fn(Request) -> HandlerResponse + Send + Sync>;
|
use handling::Handler;
|
||||||
pub (crate) type HandlerResponse = BoxFuture<'static, Result<Response>>;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Server {
|
pub struct Server {
|
||||||
tls_acceptor: TlsAcceptor,
|
tls_acceptor: TlsAcceptor,
|
||||||
listener: Arc<TcpListener>,
|
|
||||||
routes: Arc<RoutingNode<Handler>>,
|
routes: Arc<RoutingNode<Handler>>,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
complex_timeout: Option<Duration>,
|
complex_timeout: Option<Duration>,
|
||||||
#[cfg(feature="ratelimiting")]
|
#[cfg(feature="ratelimiting")]
|
||||||
rate_limits: Arc<RoutingNode<RateLimiter<IpAddr>>>,
|
rate_limits: Arc<RoutingNode<RateLimiter<IpAddr>>>,
|
||||||
|
#[cfg(feature="user_management")]
|
||||||
|
manager: UserManager,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Server {
|
impl Server {
|
||||||
|
@ -63,12 +67,12 @@ impl Server {
|
||||||
Builder::bind(addr)
|
Builder::bind(addr)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn serve(self) -> Result<()> {
|
async fn serve(self, listener: TcpListener) -> Result<()> {
|
||||||
#[cfg(feature = "ratelimiting")]
|
#[cfg(feature = "ratelimiting")]
|
||||||
tokio::spawn(prune_ratelimit_log(self.rate_limits.clone()));
|
tokio::spawn(prune_ratelimit_log(self.rate_limits.clone()));
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let (stream, _addr) = self.listener.accept().await
|
let (stream, _addr) = listener.accept().await
|
||||||
.context("Failed to accept client")?;
|
.context("Failed to accept client")?;
|
||||||
let this = self.clone();
|
let this = self.clone();
|
||||||
|
|
||||||
|
@ -89,7 +93,11 @@ impl Server {
|
||||||
.context("Failed to establish TLS session")?;
|
.context("Failed to establish TLS session")?;
|
||||||
let mut stream = BufStream::new(stream);
|
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")?;
|
.context("Failed to receive request")?;
|
||||||
|
|
||||||
Result::<_, anyhow::Error>::Ok((request, stream))
|
Result::<_, anyhow::Error>::Ok((request, stream))
|
||||||
|
@ -120,19 +128,8 @@ impl Server {
|
||||||
request.set_cert(client_cert);
|
request.set_cert(client_cert);
|
||||||
|
|
||||||
let response = if let Some((trailing, handler)) = self.routes.match_request(&request) {
|
let response = if let Some((trailing, handler)) = self.routes.match_request(&request) {
|
||||||
|
|
||||||
request.set_trailing(trailing);
|
request.set_trailing(trailing);
|
||||||
|
handler.handle(request).await
|
||||||
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")?
|
|
||||||
} else {
|
} else {
|
||||||
Response::not_found()
|
Response::not_found()
|
||||||
};
|
};
|
||||||
|
@ -201,6 +198,41 @@ impl Server {
|
||||||
}
|
}
|
||||||
None
|
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> {
|
pub struct Builder<A> {
|
||||||
|
@ -212,6 +244,8 @@ pub struct Builder<A> {
|
||||||
routes: RoutingNode<Handler>,
|
routes: RoutingNode<Handler>,
|
||||||
#[cfg(feature="ratelimiting")]
|
#[cfg(feature="ratelimiting")]
|
||||||
rate_limits: RoutingNode<RateLimiter<IpAddr>>,
|
rate_limits: RoutingNode<RateLimiter<IpAddr>>,
|
||||||
|
#[cfg(feature="user_management")]
|
||||||
|
data_dir: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<A: ToSocketAddrs> Builder<A> {
|
impl<A: ToSocketAddrs> Builder<A> {
|
||||||
|
@ -225,9 +259,20 @@ impl<A: ToSocketAddrs> Builder<A> {
|
||||||
routes: RoutingNode::default(),
|
routes: RoutingNode::default(),
|
||||||
#[cfg(feature="ratelimiting")]
|
#[cfg(feature="ratelimiting")]
|
||||||
rate_limits: RoutingNode::default(),
|
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
|
/// 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
|
/// 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.
|
/// "endpoint". Entering a relative or malformed path will result in a panic.
|
||||||
///
|
///
|
||||||
/// For more information about routing mechanics, see the docs for [`RoutingNode`].
|
/// For more information about routing mechanics, see the docs for [`RoutingNode`].
|
||||||
pub fn add_route<H>(mut self, path: &'static str, handler: H) -> Self
|
pub fn add_route(mut self, path: &'static str, handler: impl Into<Handler>) -> Self {
|
||||||
where
|
self.routes.add_route(path, handler.into());
|
||||||
H: Fn(Request) -> HandlerResponse + Send + Sync + 'static,
|
|
||||||
{
|
|
||||||
self.routes.add_route(path, Arc::new(handler));
|
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -369,46 +411,19 @@ impl<A: ToSocketAddrs> Builder<A> {
|
||||||
|
|
||||||
let server = Server {
|
let server = Server {
|
||||||
tls_acceptor: TlsAcceptor::from(config),
|
tls_acceptor: TlsAcceptor::from(config),
|
||||||
listener: Arc::new(listener),
|
|
||||||
routes: Arc::new(self.routes),
|
routes: Arc::new(self.routes),
|
||||||
timeout: self.timeout,
|
timeout: self.timeout,
|
||||||
complex_timeout: self.complex_body_timeout_override,
|
complex_timeout: self.complex_body_timeout_override,
|
||||||
#[cfg(feature="ratelimiting")]
|
#[cfg(feature="ratelimiting")]
|
||||||
rate_limits: Arc::new(self.rate_limits),
|
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<()> {
|
async fn send_response_header(header: &ResponseHeader, stream: &mut (impl AsyncWrite + Unpin)) -> Result<()> {
|
||||||
let header = format!(
|
let header = format!(
|
||||||
"{status} {meta}\r\n",
|
"{status} {meta}\r\n",
|
||||||
|
|
|
@ -3,22 +3,40 @@ use anyhow::*;
|
||||||
use percent_encoding::percent_decode_str;
|
use percent_encoding::percent_decode_str;
|
||||||
use uriparse::URIReference;
|
use uriparse::URIReference;
|
||||||
use rustls::Certificate;
|
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 {
|
pub struct Request {
|
||||||
uri: URIReference<'static>,
|
uri: URIReference<'static>,
|
||||||
input: Option<String>,
|
input: Option<String>,
|
||||||
certificate: Option<Certificate>,
|
certificate: Option<Certificate>,
|
||||||
trailing_segments: Option<Vec<String>>,
|
trailing_segments: Option<Vec<String>>,
|
||||||
|
#[cfg(feature="user_management")]
|
||||||
|
manager: UserManager,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Request {
|
impl Request {
|
||||||
pub fn from_uri(uri: URIReference<'static>) -> Result<Self> {
|
pub fn from_uri(
|
||||||
Self::with_certificate(uri, None)
|
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(
|
pub fn with_certificate(
|
||||||
mut uri: URIReference<'static>,
|
mut uri: URIReference<'static>,
|
||||||
certificate: Option<Certificate>
|
certificate: Option<Certificate>,
|
||||||
|
#[cfg(feature="user_management")]
|
||||||
|
manager: UserManager,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
uri.normalize();
|
uri.normalize();
|
||||||
|
|
||||||
|
@ -38,6 +56,8 @@ impl Request {
|
||||||
input,
|
input,
|
||||||
certificate,
|
certificate,
|
||||||
trailing_segments: None,
|
trailing_segments: None,
|
||||||
|
#[cfg(feature="user_management")]
|
||||||
|
manager,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -94,6 +114,18 @@ impl Request {
|
||||||
pub const fn certificate(&self) -> Option<&Certificate> {
|
pub const fn certificate(&self) -> Option<&Certificate> {
|
||||||
self.certificate.as_ref()
|
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 {
|
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 {
|
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> {
|
pub fn input(prompt: impl Cowy<str>) -> Result<Self> {
|
||||||
|
@ -34,27 +38,27 @@ impl Response {
|
||||||
Self::new(header)
|
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 {
|
pub fn redirect_temporary_lossy<'a>(location: impl TryInto<URIReference<'a>>) -> Self {
|
||||||
let header = ResponseHeader::redirect_temporary_lossy(location);
|
let header = ResponseHeader::redirect_temporary_lossy(location);
|
||||||
Self::new(header)
|
Self::new(header)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a successful response with a preconfigured body
|
/// Create a successful response with a given body and MIME
|
||||||
///
|
pub fn success(mime: &Mime, body: impl Into<Body>) -> Self {
|
||||||
/// This is equivilent to:
|
Self {
|
||||||
///
|
header: ResponseHeader::success(mime),
|
||||||
/// ```ignore
|
body: Some(body.into()),
|
||||||
/// Response::success(mime)
|
}
|
||||||
/// .with_body(body)
|
}
|
||||||
/// ```
|
|
||||||
pub fn success_with_body(mime: &Mime, body: impl Into<Body>) -> Self {
|
/// Create a successful response with a `text/gemini` MIME
|
||||||
Self::success(mime)
|
pub fn success_gemini(body: impl Into<Body>) -> Self {
|
||||||
.with_body(body)
|
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> {
|
pub fn server_error(reason: impl Cowy<str>) -> Result<Self> {
|
||||||
|
@ -96,8 +100,20 @@ impl Response {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<D: Borrow<Document>> From<D> for Response {
|
impl AsRef<Option<Body>> for Response {
|
||||||
fn from(doc: D) -> Self {
|
fn as_ref(&self) -> &Option<Body> {
|
||||||
Self::document(doc)
|
&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")]
|
#[cfg(feature="serve_dir")]
|
||||||
use crate::types::{Document, document::HeadingLevel::*};
|
use crate::types::{Document, document::HeadingLevel::*};
|
||||||
|
#[cfg(feature="serve_dir")]
|
||||||
use crate::types::Response;
|
use crate::types::Response;
|
||||||
use std::panic::{catch_unwind, AssertUnwindSafe};
|
use std::future::Future;
|
||||||
use std::task::Poll;
|
|
||||||
use futures_core::future::Future;
|
|
||||||
use tokio::time;
|
use tokio::time;
|
||||||
|
|
||||||
#[cfg(feature="serve_dir")]
|
#[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 {
|
let file = match File::open(path).await {
|
||||||
Ok(file) => file,
|
Ok(file) => file,
|
||||||
Err(err) => match err.kind() {
|
Err(err) => match err.kind() {
|
||||||
io::ErrorKind::NotFound => return Ok(Response::not_found()),
|
std::io::ErrorKind::PermissionDenied => {
|
||||||
_ => return Err(err.into()),
|
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")]
|
#[cfg(feature="serve_dir")]
|
||||||
pub async fn serve_dir<D: AsRef<Path>, P: AsRef<Path>>(dir: D, virtual_path: &[P]) -> Result<Response> {
|
pub async fn serve_dir<D: AsRef<Path>, P: AsRef<Path>>(dir: D, virtual_path: &[P]) -> Result<Response> {
|
||||||
debug!("Dir: {}", dir.as_ref().display());
|
debug!("Dir: {}", dir.as_ref().display());
|
||||||
let dir = dir.as_ref().canonicalize()
|
let dir = dir.as_ref();
|
||||||
.context("Failed to canonicalize directory")?;
|
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();
|
let mut path = dir.to_path_buf();
|
||||||
|
|
||||||
for segment in virtual_path {
|
for segment in virtual_path {
|
||||||
path.push(segment);
|
path.push(segment);
|
||||||
}
|
}
|
||||||
|
|
||||||
let path = path.canonicalize()
|
let path = match path.canonicalize() {
|
||||||
.context("Failed to canonicalize path")?;
|
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) {
|
if !path.starts_with(&dir) {
|
||||||
return Ok(Response::not_found());
|
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")]
|
#[cfg(feature="serve_dir")]
|
||||||
async fn serve_dir_listing<P: AsRef<Path>, B: AsRef<Path>>(path: P, virtual_path: &[B]) -> Result<Response> {
|
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,
|
Ok(dir) => dir,
|
||||||
Err(err) => match err.kind() {
|
Err(err) => match err.kind() {
|
||||||
io::ErrorKind::NotFound => return Ok(Response::not_found()),
|
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")]
|
#[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()
|
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>`,
|
/// A convenience trait alias for `AsRef<T> + Into<T::Owned>`,
|
||||||
/// most commonly used to accept `&str` or `String`:
|
/// most commonly used to accept `&str` or `String`:
|
||||||
///
|
///
|
||||||
|
@ -128,35 +178,6 @@ where
|
||||||
T: ToOwned + ?Sized,
|
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> {
|
pub(crate) async fn opt_timeout<T>(duration: Option<time::Duration>, future: impl Future<Output = T>) -> Result<T, time::error::Elapsed> {
|
||||||
match duration {
|
match duration {
|
||||||
Some(duration) => time::timeout(duration, future).await,
|
Some(duration) => time::timeout(duration, future).await,
|
||||||
|
|
Loading…
Reference in a new issue