Skip to main content

tablo_core/auth/
login.rs

1//! The panel's sign-in routes: the login page and POST, logout, and the
2//! tenant switch.
3
4use topcoat::{
5    context::Cx,
6    router::{Body, RouteFuture, request::uri, response::IntoResponse},
7    session,
8    view::{BoxView, ViewExt},
9};
10
11use super::{
12    UNAVAILABLE_ERROR, infrastructure_failure, panel_root, resolved,
13    session::{delete_session, record, select_tenant, sweep_expired_sessions},
14    signed, unauthenticated_error,
15};
16use crate::panel::{Panel, panel_prefix, state::current};
17
18/// Caps the login body at 64 KiB; the panel's 10 MiB form cap covers multipart
19/// uploads the login route never carries.
20pub(crate) const MAX_LOGIN_BYTES: usize = 64 * 1024;
21
22/// Form field carrying the login identifier (the shipped default reads it as
23/// an email address).
24pub const LOGIN_FIELD: &str = "email";
25/// Form field carrying the password.
26pub const PASSWORD_FIELD: &str = "password";
27/// Hidden form field carrying the validated post-login destination.
28pub const NEXT_FIELD: &str = "next";
29
30/// Renders for every failed login so accounts cannot be enumerated and panel
31/// membership stays private.
32pub(super) const GENERIC_ERROR: &str = "Invalid email or password.";
33
34/// Form field carrying the tenant the tenant switch selects.
35pub const TENANT_FIELD: &str = "tenant";
36
37/// Where the panel's login page lives: `{prefix}/login`.
38pub(super) fn login_url(cx: &Cx) -> String {
39    format!("{}/login", panel_prefix(cx))
40}
41
42/// Where the shell's logout control posts: `{prefix}/logout`.
43pub(crate) fn logout_url(cx: &Cx) -> String {
44    format!("{}/logout", panel_prefix(cx))
45}
46
47/// The login URL with a validated `next` back to the requested page.
48pub(super) fn login_url_with_next(cx: &Cx) -> String {
49    let mut url = login_url(cx);
50    let request = uri(cx);
51    let requested = match request.query() {
52        Some(query) => format!("{}?{query}", request.path()),
53        None => request.path().to_string(),
54    };
55    if let Some(next) = safe_next(&requested) {
56        let mut serializer = form_urlencoded::Serializer::new(String::new());
57        serializer.append_pair(NEXT_FIELD, next);
58        url.push('?');
59        url.push_str(&serializer.finish());
60    }
61    url
62}
63
64/// Accepts only same-origin relative paths as a post-login destination;
65/// rejects absolute URLs, `//host` targets, backslashes, and control characters.
66pub(crate) fn safe_next(next: &str) -> Option<&str> {
67    let next = next.trim();
68    if !next.starts_with('/') || next.starts_with("//") {
69        return None;
70    }
71    if next.contains('\\') || next.chars().any(|c| c.is_control()) {
72        return None;
73    }
74    Some(next)
75}
76
77/// The validated `?next=` the login page embeds as a hidden field.
78fn next_from_query(cx: &Cx) -> Option<String> {
79    let query = uri(cx).query()?;
80    form_urlencoded::parse(query.as_bytes())
81        .find(|(key, _)| key == NEXT_FIELD)
82        .map(|(_, value)| value.into_owned())
83        .filter(|value| safe_next(value).is_some())
84}
85
86/// Renders a failed attempt as either a credential rejection or a sign-in
87/// outage, never mixing the two.
88#[derive(Debug, Clone, Copy)]
89enum LoginError {
90    /// Wrong password, unknown account, empty fields, or valid credentials
91    /// without panel access; answers 403.
92    Credentials,
93    /// The database behind sign-in could not answer; answers 503.
94    Unavailable,
95}
96
97impl LoginError {
98    fn message(self) -> &'static str {
99        match self {
100            Self::Credentials => GENERIC_ERROR,
101            Self::Unavailable => UNAVAILABLE_ERROR,
102        }
103    }
104
105    fn status(self) -> http::StatusCode {
106        match self {
107            Self::Credentials => http::StatusCode::FORBIDDEN,
108            Self::Unavailable => http::StatusCode::SERVICE_UNAVAILABLE,
109        }
110    }
111}
112
113async fn login_response(
114    cx: &Cx,
115    error: Option<LoginError>,
116    next: String,
117) -> topcoat::Result<topcoat::router::response::Response> {
118    let page = render_login_page(cx, error, next).await?;
119    page.single().await?.into_response(cx)
120}
121
122/// `GET {prefix}/login` — the standalone login page.
123pub(crate) fn login_page(cx: &Cx, _body: Body) -> RouteFuture<'_> {
124    let next = next_from_query(cx).unwrap_or_default();
125    Box::pin(login_response(cx, None, next))
126}
127
128/// `POST {prefix}/login` — verify, rotate the session, redirect to `next`.
129pub(crate) fn login_post(cx: &Cx, body: Body) -> RouteFuture<'_> {
130    Box::pin(async move {
131        let values = crate::panel::parse_form_body(cx, body).await?.values;
132        crate::csrf::verify(cx, &values)?;
133        let next = values
134            .get(NEXT_FIELD)
135            .and_then(|value| safe_next(value))
136            .map(str::to_string)
137            .or_else(|| next_from_query(cx))
138            .unwrap_or_default();
139        let login = values.get(LOGIN_FIELD).map(|value| value.trim());
140        let password = values.get(PASSWORD_FIELD).map(String::as_str);
141        let panel = current(cx).ok_or_else(topcoat::router::error::not_found)?;
142        let verified = match (panel.auth.authenticator(), login, password) {
143            (Some(authenticator), Some(login), Some(password))
144                if !login.is_empty() && !password.is_empty() =>
145            {
146                match authenticator.verify(cx, login, password).await {
147                    Ok(user) => user,
148                    Err(error) => return failed(cx, error, next).await,
149                }
150            }
151            _ => None,
152        };
153        // One 403 for every failure: wrong password, unknown account, empty
154        // fields, or valid credentials without panel access.
155        let Some(user) = verified.filter(|user| user.can_access_panel()) else {
156            return login_response(cx, Some(LoginError::Credentials), next).await;
157        };
158        // Rotate on login so a presented token cannot be replayed.
159        if let Some(hash) = session::token_hash(cx).await? {
160            delete_session(cx, &hash).await?;
161        }
162        if let Err(error) = sweep_expired_sessions(cx).await {
163            tracing::error!(error = %error, "expired-session sweep failed");
164        }
165        let session = session::start(cx).await?;
166        if let Err(error) = record(cx, &session, &*user, panel).await {
167            return failed(cx, error, next).await;
168        }
169        let target = if next.is_empty() {
170            panel_root(cx)
171        } else {
172            next
173        };
174        // Success stays on the `Ok` path so `Set-Cookie` flushes
175        // (upstream topcoat#126).
176        topcoat::router::error::see_other(target).into_response(cx)
177    })
178}
179
180/// Maps a driver failure to the outage page; an app-authored error keeps its
181/// mapping.
182async fn failed(
183    cx: &Cx,
184    error: topcoat::Error,
185    next: String,
186) -> topcoat::Result<topcoat::router::response::Response> {
187    let error = infrastructure_failure(error);
188    if crate::error::TabloError::is_infrastructure(&error) {
189        return login_response(cx, Some(LoginError::Unavailable), next).await;
190    }
191    Err(error)
192}
193
194/// `POST {prefix}/logout` — delete the session row and clear the cookie.
195pub(crate) fn logout_post(cx: &Cx, body: Body) -> RouteFuture<'_> {
196    Box::pin(async move {
197        // Any resolved user may log out, including one without panel access,
198        // so the session row and cookie do not linger to expiry.
199        if resolved(cx).is_none() {
200            return Err(unauthenticated_error(cx));
201        }
202        let values = crate::panel::parse_form_body(cx, body).await?.values;
203        crate::csrf::verify(cx, &values)?;
204        if let Some(hash) = session::stop(cx).await? {
205            delete_session(cx, &hash).await?;
206        }
207        let target = login_url(cx);
208        topcoat::router::error::see_other(target).into_response(cx)
209    })
210}
211
212/// `POST {prefix}/tenant` — act for another of the user's tenants, then land
213/// on the panel root; a tenant outside the user's memberships answers 403.
214pub(crate) fn tenant_post(cx: &Cx, body: Body) -> RouteFuture<'_> {
215    Box::pin(async move {
216        let Some(signed) = signed(cx) else {
217            return Err(unauthenticated_error(cx));
218        };
219        let values = crate::panel::parse_form_body(cx, body).await?.values;
220        crate::csrf::verify(cx, &values)?;
221        let tenant = values
222            .get(TENANT_FIELD)
223            .and_then(|value| uuid::Uuid::parse_str(value).ok())
224            .filter(|tenant| {
225                signed
226                    .user
227                    .tenants()
228                    .iter()
229                    .any(|membership| membership.tenant == *tenant)
230            })
231            .ok_or_else(topcoat::router::error::forbidden)?;
232        select_tenant(cx, tenant).await?;
233        topcoat::router::error::see_other(panel_root(cx)).into_response(cx)
234    })
235}
236
237/// Where the shell's tenant switcher posts: `{prefix}/tenant`.
238pub(crate) fn tenant_url(cx: &Cx) -> String {
239    format!("{}/tenant", panel_prefix(cx))
240}
241
242/// Renders the standalone login document with brand, CSRF field, and one error
243/// slot.
244async fn render_login_page<'a>(
245    cx: &'a Cx,
246    error: Option<LoginError>,
247    next: String,
248) -> topcoat::Result<BoxView<'a>> {
249    let csrf = crate::csrf::ensure_token(cx);
250    let action = login_url(cx);
251    let brand = Panel::render_brand(cx).await?;
252    let hint = current(cx).and_then(|panel| panel.login_hint.clone());
253    let body = topcoat::view::view! {
254        cx =>
255        <div class="flex min-h-svh items-center justify-center bg-muted p-6">
256            <div
257                class="flex w-full max-w-sm flex-col gap-6 rounded-xl border border-border bg-card p-6 text-card-foreground shadow-sm"
258            >
259                if let Some(error) = error {
260                    (error.status())
261                }
262                <div class="flex flex-col items-center gap-2">
263                    (brand)
264                    <h1 class="text-lg font-semibold text-foreground">"Sign in"</h1>
265                </div>
266                <form method="post" action=(action) class="flex flex-col gap-4">
267                    (crate::csrf::field(cx, &csrf))
268                    <input type="hidden" name=(NEXT_FIELD) value=(next)>
269                    if let Some(error) = error {
270                        tablo_ui::alert(
271                            variant: tablo_ui::AlertVariant::Destructive,
272                            attrs: topcoat::view::attributes! { role="alert" },
273                            tablo_ui::alert_title((error.message()))
274                        )
275                    }
276                    tablo_ui::field(
277                        tablo_ui::field_label(
278                            attrs: topcoat::view::attributes! { for="email" },
279                            "Email or username"
280                        )
281                        tablo_ui::input(
282                            attrs: topcoat::view::attributes! {
283                                id="email"
284                                name=(LOGIN_FIELD)
285                                type="text"
286                                required=""
287                                autocomplete="username"
288                                autofocus=""
289                            }
290                        )
291                    )
292                    tablo_ui::field(
293                        tablo_ui::field_label(
294                            attrs: topcoat::view::attributes! { for="password" },
295                            "Password"
296                        )
297                        tablo_ui::input(
298                            attrs: topcoat::view::attributes! {
299                                id="password"
300                                name=(PASSWORD_FIELD)
301                                type="password"
302                                required=""
303                                autocomplete="current-password"
304                            }
305                        )
306                    )
307                    tablo_ui::button(
308                        variant: tablo_ui::ButtonVariant::Primary,
309                        attrs: topcoat::view::attributes! { type="submit" class="w-full" },
310                        "Sign in"
311                    )
312                </form>
313                if let Some(hint) = hint {
314                    <p class="text-center text-xs text-muted-foreground">(hint)</p>
315                }
316            </div>
317        </div>
318    }
319    .boxed();
320    Panel::render_document(cx, "Sign in".to_string(), body).await
321}