kochab/src/user_management/routes.rs

362 lines
15 KiB
Rust

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::Server<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::Server<A> {}
}