mod gate;
mod login;
mod password;
mod registration;
mod session;
mod throttle;
use std::{
any::{Any, TypeId},
future::Future,
sync::Arc,
};
use topcoat::{
context::{Cx, try_request_context},
router::{
error::{forbidden, redirect, unauthorized},
request::{method, original_headers, original_method, uri},
},
};
use uuid::Uuid;
pub(crate) use self::{
gate::{PanelGate, RuntimeGate},
login::{
MAX_LOGIN_BYTES, login_page, login_post, logout_post, logout_url, safe_next, tenant_post,
tenant_url,
},
registration::{DynRegistrar, register_page, register_post},
};
pub use self::{
login::{LOGIN_FIELD, NEXT_FIELD, PASSWORD_FIELD, TENANT_FIELD},
password::{AdminUser, PasswordAuth, create_admin, hash_password, verify_password},
registration::{MIN_PASSWORD_CHARS, Registrar, SignUp, new_password_errors},
session::{AuthSession, SESSION_LIFETIME, mint_session, revoke_sessions_for_user},
throttle::LoginThrottle,
};
use crate::{
BoxFuture,
panel::{
panel_prefix,
state::{PanelState, Panels, current, panels},
},
tenancy::Membership,
};
pub trait PanelUser: Any + Send + Sync {
fn user_id(&self) -> String;
fn display_name(&self) -> &str;
fn can_access_panel(&self) -> bool {
true
}
fn tenants(&self) -> &[Membership] {
&[]
}
}
pub trait Authenticator: Send + Sync + 'static {
type User: PanelUser;
fn verify(
&self,
cx: &Cx,
login: &str,
password: &str,
) -> impl Future<Output = topcoat::Result<Option<Self::User>>> + Send;
fn find_by_id(
&self,
cx: &Cx,
id: &str,
) -> impl Future<Output = topcoat::Result<Option<Self::User>>> + Send;
}
pub(crate) trait DynAuthenticator: Send + Sync {
fn verify<'a>(
&'a self,
cx: &'a Cx,
login: &'a str,
password: &'a str,
) -> BoxFuture<'a, topcoat::Result<Option<Arc<dyn PanelUser>>>>;
fn find_by_id<'a>(
&'a self,
cx: &'a Cx,
id: &'a str,
) -> BoxFuture<'a, topcoat::Result<Option<Arc<dyn PanelUser>>>>;
fn user_type(&self) -> TypeId;
}
impl<A: Authenticator> DynAuthenticator for A {
fn verify<'a>(
&'a self,
cx: &'a Cx,
login: &'a str,
password: &'a str,
) -> BoxFuture<'a, topcoat::Result<Option<Arc<dyn PanelUser>>>> {
Box::pin(async move {
let user = Authenticator::verify(self, cx, login, password).await?;
Ok(user.map(|user| Arc::new(user) as Arc<dyn PanelUser>))
})
}
fn find_by_id<'a>(
&'a self,
cx: &'a Cx,
id: &'a str,
) -> BoxFuture<'a, topcoat::Result<Option<Arc<dyn PanelUser>>>> {
Box::pin(async move {
let user = Authenticator::find_by_id(self, cx, id).await?;
Ok(user.map(|user| Arc::new(user) as Arc<dyn PanelUser>))
})
}
fn user_type(&self) -> TypeId {
TypeId::of::<A::User>()
}
}
pub struct Auth {
authenticator: Option<Box<dyn DynAuthenticator>>,
throttle: LoginThrottle,
registrar: Option<Box<dyn DynRegistrar>>,
sign_up_throttle: LoginThrottle,
}
impl Auth {
#[must_use]
pub fn password() -> Self {
Self::custom(PasswordAuth)
}
#[must_use]
pub fn custom(authenticator: impl Authenticator) -> Self {
Self {
authenticator: Some(Box::new(authenticator)),
throttle: LoginThrottle::default(),
registrar: None,
sign_up_throttle: LoginThrottle::default(),
}
}
#[must_use]
pub fn disabled() -> Self {
Self {
authenticator: None,
throttle: LoginThrottle::off(),
registrar: None,
sign_up_throttle: LoginThrottle::off(),
}
}
#[must_use]
pub fn throttle(mut self, throttle: LoginThrottle) -> Self {
self.throttle = throttle;
self
}
#[must_use]
pub fn registration(mut self, registrar: impl Registrar) -> Self {
self.registrar = Some(Box::new(registrar));
self
}
#[must_use]
pub fn sign_up_throttle(mut self, throttle: LoginThrottle) -> Self {
self.sign_up_throttle = throttle;
self
}
#[must_use]
pub fn is_disabled(&self) -> bool {
self.authenticator.is_none()
}
pub(crate) fn authenticator(&self) -> Option<&dyn DynAuthenticator> {
self.authenticator.as_deref()
}
pub(crate) fn login_throttle(&self) -> &LoginThrottle {
&self.throttle
}
pub(crate) fn registrar(&self) -> Option<&dyn DynRegistrar> {
self.authenticator.as_ref().and(self.registrar.as_deref())
}
pub(crate) fn throttle_sign_ups(&self) -> &LoginThrottle {
&self.sign_up_throttle
}
}
impl Default for Auth {
fn default() -> Self {
Self::password()
}
}
impl std::fmt::Debug for Auth {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.is_disabled() {
f.write_str("Auth::disabled")
} else {
f.debug_struct("Auth")
.field("throttle", &self.throttle)
.field("registration", &self.registrar.is_some())
.finish_non_exhaustive()
}
}
}
#[derive(Clone)]
pub(crate) struct SignedIn {
pub(crate) user: Arc<dyn PanelUser>,
pub(crate) panel: Arc<PanelState>,
pub(crate) tenant: Option<Uuid>,
}
pub(crate) fn resolved(cx: &Cx) -> Option<&SignedIn> {
let signed = try_request_context::<SignedIn>(cx)?;
match current(cx) {
Some(panel) if !Arc::ptr_eq(&signed.panel, panel) => None,
_ => Some(signed),
}
}
pub(crate) fn signed(cx: &Cx) -> Option<&SignedIn> {
resolved(cx).filter(|signed| signed.user.can_access_panel())
}
pub fn membership(cx: &Cx) -> Option<&Membership> {
let tenant = crate::tenant_id(cx)?;
signed(cx)?
.user
.tenants()
.iter()
.find(|m| m.tenant == tenant)
}
pub(crate) fn session_tenant(cx: &Cx) -> Option<Uuid> {
let signed = signed(cx)?;
let tenants = signed.user.tenants();
signed
.tenant
.and_then(|tenant| tenants.iter().find(|m| m.tenant == tenant))
.or_else(|| tenants.first())
.map(|membership| membership.tenant)
}
pub fn user<U: PanelUser>(cx: &Cx) -> Option<&U> {
let user: &dyn Any = &*signed(cx)?.user;
user.downcast_ref()
}
pub fn require_user<U: PanelUser>(cx: &Cx) -> topcoat::Result<&U> {
match signed(cx) {
Some(_) => user(cx).ok_or_else(|| forbidden().into()),
None => Err(unauthenticated_error(cx)),
}
}
pub fn signed_in(cx: &Cx) -> bool {
signed(cx).is_some()
}
pub fn enforced(cx: &Cx) -> bool {
match current(cx) {
Some(panel) => panel.gates(),
None => panels(cx).is_some_and(Panels::any_gates),
}
}
pub fn guard(cx: &Cx) -> topcoat::Result<()> {
if enforced(cx) && !signed_in(cx) {
return Err(unauthenticated_error(cx));
}
Ok(())
}
fn unauthenticated_error(cx: &Cx) -> topcoat::Error {
let path = uri(cx).path();
let page_method = matches!(*method(cx), http::Method::GET | http::Method::HEAD);
let rerun = !matches!(*original_method(cx), http::Method::GET | http::Method::HEAD)
&& original_headers(cx).get(&topcoat::runtime::RUNTIME_HEADER) == Some(&RERUN_MARKER);
if path.starts_with(crate::topcoat_compat::RUNTIME_PREFIX) || !page_method || rerun {
unauthorized().into()
} else {
redirect(login::login_url_with_next(cx)).into()
}
}
fn panel_root(cx: &Cx) -> String {
panel_prefix(cx)
}
fn infrastructure_failure(error: impl Into<topcoat::Error>) -> topcoat::Error {
crate::error::driver_failure(error, UNAVAILABLE_ERROR)
}
const UNAVAILABLE_ERROR: &str = "Sign-in is unavailable right now. Try again shortly.";
static RERUN_MARKER: http::HeaderValue = http::HeaderValue::from_static("true");
pub(crate) fn check_models_registered(
db: &toasty::Db,
auth: &Auth,
) -> Result<(), crate::DeclarationErrorKind> {
let Some(authenticator) = auth.authenticator() else {
return Ok(());
};
let schema = crate::toasty_compat::model::AppSchema::of_db(db);
let registered = |name: &str| schema.registers(name);
let shipped_user = authenticator.user_type() == TypeId::of::<AdminUser>();
let models: Vec<&'static str> = [
(!registered("AuthSession")).then_some("AuthSession"),
(shipped_user && !registered("AdminUser")).then_some("AdminUser"),
]
.into_iter()
.flatten()
.collect();
if models.is_empty() {
Ok(())
} else {
Err(crate::DeclarationErrorKind::MissingAuthModels { models })
}
}
pub(crate) fn check_registration(auth: &Auth) -> Vec<crate::DeclarationErrorKind> {
use crate::{DeclarationErrorKind, declaration::SignUpFault};
let Some(registrar) = auth.registrar.as_deref() else {
return Vec::new();
};
let Some(authenticator) = auth.authenticator() else {
return vec![DeclarationErrorKind::SignUp(SignUpFault::AuthDisabled)];
};
let mut kinds = Vec::new();
let (user, user_name) = registrar.user_type();
if user != authenticator.user_type() {
kinds.push(DeclarationErrorKind::SignUp(SignUpFault::UserType {
registrar: user_name,
}));
}
let schema = registrar.schema();
kinds.extend(schema.declaration_errors());
kinds.extend(schema.empty_choices());
kinds.extend(schema.fields().filter_map(|field| {
let name = field.name().to_string();
let fault = if registration::RESERVED_KEYS.contains(&name.as_str()) {
SignUpFault::ReservedField(name)
} else if field.is_file() {
SignUpFault::FileField(name)
} else if field
.as_choice()
.is_some_and(|choice| choice.is_relationship())
{
SignUpFault::RelationshipField(name)
} else {
return None;
};
Some(DeclarationErrorKind::SignUp(fault))
}));
kinds
}
#[cfg(test)]
mod tests;