1use 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
18pub(crate) const MAX_LOGIN_BYTES: usize = 64 * 1024;
21
22pub const LOGIN_FIELD: &str = "email";
25pub const PASSWORD_FIELD: &str = "password";
27pub const NEXT_FIELD: &str = "next";
29
30pub(super) const GENERIC_ERROR: &str = "Invalid email or password.";
33
34pub const TENANT_FIELD: &str = "tenant";
36
37pub(super) fn login_url(cx: &Cx) -> String {
39 format!("{}/login", panel_prefix(cx))
40}
41
42pub(crate) fn logout_url(cx: &Cx) -> String {
44 format!("{}/logout", panel_prefix(cx))
45}
46
47pub(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
64pub(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
77fn 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#[derive(Debug, Clone, Copy)]
89enum LoginError {
90 Credentials,
93 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
122pub(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
128pub(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 let Some(user) = verified.filter(|user| user.can_access_panel()) else {
156 return login_response(cx, Some(LoginError::Credentials), next).await;
157 };
158 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 topcoat::router::error::see_other(target).into_response(cx)
177 })
178}
179
180async 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
194pub(crate) fn logout_post(cx: &Cx, body: Body) -> RouteFuture<'_> {
196 Box::pin(async move {
197 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
212pub(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
237pub(crate) fn tenant_url(cx: &Cx) -> String {
239 format!("{}/tenant", panel_prefix(cx))
240}
241
242async 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}