use topcoat::{
context::Cx,
router::{
Body, RouteFuture,
request::{client_ip, uri},
response::IntoResponse,
},
view::{BoxView, ViewExt},
};
use super::{
PanelUser,
external::leave_for,
infrastructure_failure, panel_root, resolved,
session::{delete_session, fresh_session, record, select_tenant, stop, sweep_expired_sessions},
signed, unauthenticated_error,
};
use crate::{
TenantId,
auth::realm::{Realm, current_realm, hosted, panel_prefix},
};
pub(super) const MAX_LOGIN_BYTES: usize = 64 * 1024;
pub const LOGIN_FIELD: &str = "email";
pub const PASSWORD_FIELD: &str = "password";
pub const NEXT_FIELD: &str = "next";
pub(super) const GENERIC_ERROR: &str = "tablo-login-failed";
pub const TENANT_FIELD: &str = "tenant";
pub(crate) fn login_url(cx: &Cx) -> String {
format!("{}/login", panel_prefix(cx))
}
pub(crate) fn logout_url(cx: &Cx) -> String {
format!("{}/logout", panel_prefix(cx))
}
pub(super) fn login_url_with_next(cx: &Cx) -> String {
let mut url = login_url(cx);
let request = uri(cx);
let requested = match request.query() {
Some(query) => format!("{}?{query}", request.path()),
None => request.path().to_string(),
};
if let Some(next) = safe_next(&requested) {
let mut serializer = form_urlencoded::Serializer::new(String::new());
serializer.append_pair(NEXT_FIELD, next);
url.push('?');
url.push_str(&serializer.finish());
}
url
}
pub fn safe_next(next: &str) -> Option<&str> {
crate::outcome::same_origin_path(next)
}
pub(super) fn next_from_query(cx: &Cx) -> Option<String> {
let query = uri(cx).query()?;
form_urlencoded::parse(query.as_bytes())
.find(|(key, _)| key == NEXT_FIELD)
.map(|(_, value)| value.into_owned())
.filter(|value| safe_next(value).is_some())
}
#[derive(Debug, Clone, Copy)]
enum LoginError {
Credentials,
Unavailable,
}
impl LoginError {
fn message(self) -> &'static str {
match self {
Self::Credentials => GENERIC_ERROR,
Self::Unavailable => "tablo-login-unavailable",
}
}
fn status(self) -> http::StatusCode {
match self {
Self::Credentials => http::StatusCode::FORBIDDEN,
Self::Unavailable => http::StatusCode::SERVICE_UNAVAILABLE,
}
}
}
async fn login_response(
cx: &Cx,
error: Option<LoginError>,
next: String,
) -> topcoat::Result<topcoat::router::response::Response> {
let page = render_login_page(cx, error, next).await?;
page.single().await?.into_response(cx)
}
pub(super) fn login_page(cx: &Cx, _body: Body) -> RouteFuture<'_> {
let next = next_from_query(cx).unwrap_or_default();
Box::pin(login_response(cx, None, next))
}
pub(super) fn login_post(cx: &Cx, body: Body) -> RouteFuture<'_> {
Box::pin(async move {
let parts = crate::auth::input::read_form(cx, body).await?;
crate::csrf::verify(cx, parts.last(crate::csrf::FIELD_NAME))?;
let next = parts
.last(NEXT_FIELD)
.and_then(safe_next)
.map(str::to_string)
.or_else(|| next_from_query(cx))
.unwrap_or_default();
let login = parts.last(LOGIN_FIELD).map(str::trim);
let password = parts.last(PASSWORD_FIELD);
let panel = current_realm(cx).ok_or_else(topcoat::router::error::not_found)?;
let client = client_ip(cx);
let verified = match (panel.auth.authenticator(), login, password) {
(Some(authenticator), Some(login), Some(password))
if !login.is_empty() && !password.is_empty() =>
{
super::throttle::warn_unforwarded(cx);
if !panel
.auth
.login_throttle()
.attempt(cx, "sign-in", login, client)
.await?
|| !panel
.auth
.account_throttle_of()
.attempt(cx, "account", login, None)
.await?
{
tracing::warn!(
panel = %panel.prefix,
client = ?client,
"sign-in attempt throttled"
);
return login_response(cx, Some(LoginError::Credentials), next).await;
}
match authenticator.verify(cx, login, password).await {
Ok(user) => user,
Err(error) => return failed(cx, error, next).await,
}
}
_ => None,
};
let Some(user) = verified.filter(|user| user.can_access_panel(&panel.prefix)) else {
return login_response(cx, Some(LoginError::Credentials), next).await;
};
if let Some(login) = login {
panel
.auth
.login_throttle()
.clear(cx, "sign-in", login, client)
.await?;
panel
.auth
.account_throttle_of()
.clear(cx, "account", login, None)
.await?;
}
admit(cx, panel, &*user, next).await
})
}
pub(super) async fn admit(
cx: &Cx,
panel: &Realm,
user: &dyn PanelUser,
next: String,
) -> topcoat::Result<topcoat::router::response::Response> {
if let Err(error) = sweep_expired_sessions(cx).await {
tracing::error!(error = %error, "expired-session sweep failed");
}
if let Err(error) = super::token::sweep_expired(cx).await {
tracing::error!(error = %error, "expired-token sweep failed");
}
if panel.auth.two_factor_config().is_some() {
match super::two_factor::enrolled(cx, user).await {
Ok(Some(kind)) => {
return super::two_factor::begin_challenge(cx, user, kind, &next).await;
}
Ok(None) => {}
Err(error) => return failed(cx, error, next).await,
}
}
let recorded = match fresh_session(cx).await {
Ok(session) => record(cx, &session, user, panel).await,
Err(error) => Err(error),
};
if let Err(error) = recorded {
return failed(cx, error, next).await;
}
let target = if next.is_empty() {
panel_root(cx)
} else {
next
};
topcoat::router::error::see_other(target).into_response(cx)
}
async fn failed(
cx: &Cx,
error: topcoat::Error,
next: String,
) -> topcoat::Result<topcoat::router::response::Response> {
let error = infrastructure_failure(error);
if crate::error::TabloError::is_infrastructure(&error) {
return login_response(cx, Some(LoginError::Unavailable), next).await;
}
Err(error)
}
pub(super) fn logout_post(cx: &Cx, body: Body) -> RouteFuture<'_> {
Box::pin(async move {
let signed = resolved(cx);
let external = current_realm(cx).and_then(|panel| panel.auth.external());
if signed.is_none() && external.is_none() {
return Err(unauthenticated_error(cx));
}
let parts = crate::auth::input::read_form(cx, body).await?;
crate::csrf::verify(cx, parts.last(crate::csrf::FIELD_NAME))?;
if let Some(signed) = signed {
crate::auth::impersonation::stop_on_sign_out(cx, signed).await?;
}
if let Some(hash) = stop(cx).await? {
delete_session(cx, &hash).await?;
}
if let Some(signed) = signed {
crate::bus::bus(cx).wake(&signed.realm.prefix, &signed.user.user_id());
}
match external {
Some(external) => leave_for(cx, external.logout_url(cx)).await,
None => topcoat::router::error::see_other(login_url(cx)).into_response(cx),
}
})
}
pub(super) fn tenant_post(cx: &Cx, body: Body) -> RouteFuture<'_> {
Box::pin(async move {
let Some(signed) = signed(cx) else {
return Err(unauthenticated_error(cx));
};
let parts = crate::auth::input::read_form(cx, body).await?;
crate::csrf::verify(cx, parts.last(crate::csrf::FIELD_NAME))?;
let tenant = parts
.last(TENANT_FIELD)
.and_then(|value| value.parse::<TenantId>().ok())
.filter(|tenant| {
signed
.user
.tenants()
.iter()
.any(|membership| membership.tenant == *tenant)
})
.ok_or_else(topcoat::router::error::forbidden)?;
select_tenant(cx, tenant).await?;
crate::bus::bus(cx).wake(&signed.realm.prefix, &signed.user.user_id());
topcoat::router::error::see_other(panel_root(cx)).into_response(cx)
})
}
pub(crate) fn tenant_url(cx: &Cx) -> String {
format!("{}/tenant", panel_prefix(cx))
}
async fn render_login_page<'a>(
cx: &'a Cx,
error: Option<LoginError>,
next: String,
) -> topcoat::Result<BoxView<'a>> {
let csrf = crate::csrf::ensure_token(cx);
let action = login_url(cx);
let providers = super::oidc::buttons(cx, &next);
let hint =
current_realm(cx).and_then(|panel| panel.login_hint.as_ref().map(|hint| hint.localize(cx)));
let forgot = super::password_reset::offered(cx).then(|| super::password_reset::forgot_url(cx));
let sign_up = super::registration::offered(cx)
.then(|| super::registration::with_next(super::registration::register_url(cx), &next));
let notice = (error.is_none() && *topcoat::router::request::method(cx) == http::Method::GET)
.then(|| crate::flash::take_notification(cx))
.flatten();
let password = crate::schema::fields::password_reveal(
cx,
"password".to_string(),
topcoat::view::view! {
tablo_ui::input(
attrs: topcoat::view::attributes! {
id="password"
name=(PASSWORD_FIELD)
type="password"
required=""
autocomplete="current-password"
}
)
}
.boxed(),
);
let form = topcoat::view::view! {
<form method="post" action=(action) class="flex flex-col gap-5">
(crate::csrf::field(cx, &csrf))
<input type="hidden" name=(NEXT_FIELD) value=(next)>
if let Some(error) = error {
tablo_ui::alert(
variant: tablo_ui::AlertVariant::Destructive,
attrs: topcoat::view::attributes! { role="alert" },
tablo_ui::alert_title((crate::t!(cx, error.message())))
)
}
if let Some(notice) = notice {
tablo_ui::alert(
variant: match notice.status {
crate::NotificationStatus::Error => tablo_ui::AlertVariant::Destructive,
_ => tablo_ui::AlertVariant::Neutral,
},
attrs: topcoat::view::attributes! { role="status" },
tablo_ui::alert_title((notice.title))
if let Some(description) = notice.description {
tablo_ui::alert_description((description))
}
)
}
tablo_ui::field(
tablo_ui::field_label(
attrs: topcoat::view::attributes! { for="email" },
(crate::t!(cx, "tablo-login-identifier"))
)
tablo_ui::input(
attrs: topcoat::view::attributes! {
id="email"
name=(LOGIN_FIELD)
type="text"
required=""
autocomplete="username"
autofocus=""
}
)
)
tablo_ui::field(
tablo_ui::field_label(
attrs: topcoat::view::attributes! { for="password" },
(crate::t!(cx, "tablo-auth-password"))
)
(password)
)
if let Some(forgot) = forgot {
<a
(crate::navigation::runtime_link(cx, &forgot))
class="-mt-2 self-end text-sm text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
>
(crate::t!(cx, "tablo-login-forgot"))
</a>
}
tablo_ui::button(
variant: tablo_ui::ButtonVariant::Primary,
attrs: topcoat::view::attributes! { type="submit" class="w-full" },
(crate::t!(cx, "tablo-login-submit"))
)
</form>
if let Some(providers) = providers {
<div class="mt-5">(providers)</div>
}
}
.boxed();
super::page::render(
cx,
super::page::AuthPage {
title: crate::t!(cx, "tablo-login-title"),
description: crate::t!(
cx,
"tablo-login-description",
brand = hosted(cx)?.brand_name(cx)
),
status: error.map(|error| error.status()),
form,
switch: sign_up.map(|href| {
super::page::switch_link(cx, "tablo-login-no-account", &href, "tablo-login-sign-up")
}),
note: hint,
},
)
.await
}