Login screen and api endpoint reorganization

This commit is contained in:
Maurus Decimus
2026-04-05 19:57:23 +02:00
parent eecec0aa31
commit fde37b670f
29 changed files with 1005 additions and 1606 deletions

View File

@@ -12,7 +12,13 @@ pub mod telemetry;
// SPDX-SnippetEnd
pub mod diagnose;
use crate::management::diagnose::{DeliveryStage, spawn_delivery_diagnose};
use crate::{
api::diagnose::{DeliveryStage, spawn_delivery_diagnose},
auth::{
authenticate::Authenticator, oauth::auth::OAuthApiHandler,
permissions::PermissionsApiHandler,
},
};
use common::{
Server,
auth::{AccessToken, oauth::GrantType},
@@ -31,27 +37,61 @@ use std::time::Duration;
use utils::url_params::UrlParams;
pub trait ManagementApi: Sync + Send {
fn handle_api_manage_request(
fn handle_api_request(
&self,
req: &mut HttpRequest,
access_token: &AccessToken,
session: &HttpSessionData,
) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
fn management_access_token(
&self,
req: &HttpRequest,
session: &HttpSessionData,
) -> impl Future<Output = trc::Result<AccessToken>> + Send;
}
impl ManagementApi for Server {
#[allow(unused_variables)]
async fn handle_api_manage_request(
async fn handle_api_request(
&self,
req: &mut HttpRequest,
access_token: &AccessToken,
session: &HttpSessionData,
) -> trc::Result<HttpResponse> {
let body = fetch_body(req, 1024 * 1024, session.session_id).await;
let is_post = req.method() == Method::POST;
let body = if is_post {
fetch_body(req, 1024 * 1024, session.session_id).await
} else {
None
};
let path = req.uri().path().split('/').skip(2).collect::<Vec<_>>();
match path.first().copied().unwrap_or_default() {
"auth" if is_post => {
self.is_http_anonymous_request_allowed(session.remote_ip)
.await?;
self.handle_login_request(
session,
body.ok_or_else(|| trc::LimitEvent::SizeRequest.into_err())?,
)
.await
}
"discover" => {
if let Some(email) = path.get(2).copied() {
self.is_http_anonymous_request_allowed(session.remote_ip)
.await?;
self.handle_discover_request(req, session, decode_path_element(email).as_ref())
.await
} else {
Err(trc::ResourceEvent::NotFound.into_err())
}
}
"permissions" => {
// Authenticate request
let (_in_flight, access_token) = self.authenticate_headers(req, session).await?;
self.handle_permissions_request(&access_token).await
}
"token" => {
let access_token = self.management_access_token(req, session).await?;
let account_id = access_token.account_id();
match path.get(1).copied() {
// SPDX-SnippetBegin
@@ -98,6 +138,7 @@ impl ManagementApi for Server {
}
}
"live" => {
let access_token = self.management_access_token(req, session).await?;
let params = UrlParams::new(req.uri().query());
let account_id = access_token.account_id();
@@ -140,16 +181,16 @@ impl ManagementApi for Server {
// SPDX-License-Identifier: LicenseRef-SEL
#[cfg(feature = "enterprise")]
("traces", _, &Method::GET) if self.core.is_enterprise_edition() => {
use crate::management::telemetry::TelemetryApi;
use crate::api::telemetry::TelemetryApi;
self.handle_telemetry_api_request(req, true, access_token)
self.handle_telemetry_api_request(req, true, &access_token)
.await
}
#[cfg(feature = "enterprise")]
("metrics", _, &Method::GET) if self.core.is_enterprise_edition() => {
use crate::management::telemetry::TelemetryApi;
use crate::api::telemetry::TelemetryApi;
self.handle_telemetry_api_request(req, false, access_token)
self.handle_telemetry_api_request(req, false, &access_token)
.await
}
// SPDX-SnippetEnd
@@ -160,10 +201,55 @@ impl ManagementApi for Server {
_ => Err(trc::ResourceEvent::NotFound.into_err()),
}
}
_ => Err(trc::ResourceEvent::NotFound.into_err()),
}
}
async fn management_access_token(
&self,
req: &HttpRequest,
session: &HttpSessionData,
) -> trc::Result<AccessToken> {
let params = UrlParams::new(req.uri().query());
if let Some(token) = params.get("token") {
// SPDX-SnippetBegin
// SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
// SPDX-License-Identifier: LicenseRef-SEL
#[cfg(feature = "enterprise")]
if self.core.is_enterprise_edition() {
let path = req.uri().path();
let (grant_type, permissions) = if path.starts_with("/api/telemetry/traces") {
(GrantType::LiveTracing, Permission::LiveTracing)
} else if path.starts_with("/api/telemetry/metrics") {
(GrantType::LiveMetrics, Permission::LiveMetrics)
} else if path.starts_with("/api/diagnose") {
(GrantType::Diagnose, Permission::LiveDeliveryTest)
} else {
return Err(trc::ResourceEvent::NotFound.into_err());
};
self.validate_access_token(grant_type.into(), token)
.await
.map(|token_info| {
AccessToken::from_permissions(token_info.account_id, [permissions])
})
} else {
self.authenticate_headers(req, session)
.await
.map(|(_, token)| token)
}
// SPDX-SnippetEnd
#[cfg(not(feature = "enterprise"))]
{
self.authenticate_headers(req, session)
.await
.map(|(_, token)| token)
}
} else {
self.authenticate_headers(req, session)
.await
.map(|(_, token)| token)
}
}
}
pub trait ToManageHttpResponse {

View File

@@ -57,7 +57,7 @@ impl TelemetryApi for Server {
for (key, value) in params.into_inner() {
if key == "filter" {
filter = value.into_owned().into();
} else if let Some(key) = Key::try_parse(key.to_ascii_lowercase().as_str()) {
} else if let Some(key) = Key::parse(key.to_ascii_lowercase().as_str()) {
key_filters.insert(key, value.into_owned());
}
}
@@ -152,8 +152,7 @@ impl TelemetryApi for Server {
"event: trace\ndata: {}\n\n",
serde_json::to_string(
&JsonEventSerializer::new(std::mem::take(&mut events))
.with_description()
.with_explanation()).unwrap_or_default()
.with_description()).unwrap_or_default()
))));
ping_interval

View File

@@ -6,3 +6,4 @@
pub mod authenticate;
pub mod oauth;
pub mod permissions;

View File

@@ -5,7 +5,7 @@
*/
use super::{DeviceAuthResponse, FormData, MAX_POST_LEN, OAuthCode};
use crate::auth::oauth::OAuthStatus;
use crate::auth::oauth::{OAuthStatus, openid::OpenIdHandler};
use common::{
KV_OAUTH, Server,
auth::{
@@ -46,22 +46,29 @@ pub struct OAuthMetadata {
}
pub trait OAuthApiHandler: Sync + Send {
fn handle_discover_request(
&self,
req: &HttpRequest,
session: &HttpSessionData,
account_name: &str,
) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
fn handle_login_request(
&self,
session: HttpSessionData,
session: &HttpSessionData,
body: Vec<u8>,
) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
fn handle_device_auth(
&self,
req: &mut HttpRequest,
session: HttpSessionData,
session: &HttpSessionData,
) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
fn handle_oauth_metadata(
&self,
req: HttpRequest,
session: HttpSessionData,
session: &HttpSessionData,
) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
}
@@ -69,9 +76,6 @@ pub trait OAuthApiHandler: Sync + Send {
#[serde(tag = "type")]
#[serde(rename_all = "camelCase")]
pub enum LoginRequest {
Discovery {
account_name: String,
},
AuthCode {
account_name: String,
account_secret: String,
@@ -83,6 +87,14 @@ pub enum LoginRequest {
redirect_uri: Option<String>,
#[serde(default)]
nonce: Option<String>,
#[serde(default)]
scope: Option<String>,
#[serde(default)]
code_challenge: Option<String>,
#[serde(default)]
code_challenge_method: Option<String>,
#[serde(default)]
state: Option<String>,
},
AuthDevice {
account_name: String,
@@ -98,8 +110,6 @@ pub enum LoginRequest {
#[serde(tag = "type")]
#[serde(rename_all = "camelCase")]
pub enum LoginResponse {
Local,
External { endpoint: String },
Authenticated { client_code: String },
Verified,
MfaRequired,
@@ -107,9 +117,28 @@ pub enum LoginResponse {
}
impl OAuthApiHandler for Server {
async fn handle_discover_request(
&self,
req: &HttpRequest,
session: &HttpSessionData,
account_name: &str,
) -> trc::Result<HttpResponse> {
let account_name = account_name.trim().to_lowercase();
if let Some(domain_name) = account_name.try_domain_part()
&& let Some(endpoint) = self
.get_directory_for_domain(domain_name)
.await?
.and_then(|directory| directory.oidc_discovery_document())
{
Ok(JsonResponse::new(endpoint).no_cache().into_http_response())
} else {
self.handle_oidc_metadata(req, session).await
}
}
async fn handle_login_request(
&self,
session: HttpSessionData,
session: &HttpSessionData,
body: Vec<u8>,
) -> trc::Result<HttpResponse> {
let request = serde_json::from_slice::<LoginRequest>(&body).map_err(|err| {
@@ -117,22 +146,6 @@ impl OAuthApiHandler for Server {
})?;
let response = match request {
LoginRequest::Discovery { account_name } => {
let account_name = account_name.trim().to_lowercase();
if let Some(domain_name) = account_name.try_domain_part() {
if let Some(endpoint) = self
.get_directory_for_domain(domain_name)
.await?
.and_then(|directory| directory.oidc_authorization_endpoint())
{
LoginResponse::External { endpoint }
} else {
LoginResponse::Local
}
} else {
LoginResponse::Local
}
}
LoginRequest::AuthCode {
account_name,
account_secret,
@@ -140,6 +153,7 @@ impl OAuthApiHandler for Server {
client_id,
redirect_uri,
nonce,
..
} => {
// Validate clientId
if client_id.len() > CLIENT_ID_MAX_LEN {
@@ -315,7 +329,7 @@ impl OAuthApiHandler for Server {
async fn handle_device_auth(
&self,
req: &mut HttpRequest,
session: HttpSessionData,
session: &HttpSessionData,
) -> trc::Result<HttpResponse> {
// Parse form
let mut form_data = FormData::from_request(req, MAX_POST_LEN, session.session_id).await?;
@@ -379,10 +393,10 @@ impl OAuthApiHandler for Server {
.await?;
// Build response
let base_url = HttpContext::new(&session, req).resolve_response_url(self);
let base_url = HttpContext::new(session, req).resolve_response_url(self);
Ok(JsonResponse::new(DeviceAuthResponse {
verification_uri: format!("{base_url}/authorize"),
verification_uri_complete: format!("{base_url}/authorize/?code={user_code}"),
verification_uri: format!("{base_url}/device"),
verification_uri_complete: format!("{base_url}/device/?code={user_code}"),
device_code,
user_code,
expires_in: self.core.oauth.oauth_expiry_user_code,
@@ -395,12 +409,12 @@ impl OAuthApiHandler for Server {
async fn handle_oauth_metadata(
&self,
req: HttpRequest,
session: HttpSessionData,
session: &HttpSessionData,
) -> trc::Result<HttpResponse> {
let base_url = HttpContext::new(&session, &req).resolve_response_url(self);
let base_url = HttpContext::new(session, &req).resolve_response_url(self);
Ok(JsonResponse::new(OAuthMetadata {
authorization_endpoint: format!("{base_url}/authorize/code",),
authorization_endpoint: format!("{base_url}/login",),
token_endpoint: format!("{base_url}/auth/token"),
device_authorization_endpoint: format!("{base_url}/auth/device"),
introspection_endpoint: format!("{base_url}/auth/introspect"),

View File

@@ -34,8 +34,8 @@ pub trait OpenIdHandler: Sync + Send {
fn handle_oidc_metadata(
&self,
req: HttpRequest,
session: HttpSessionData,
req: &HttpRequest,
session: &HttpSessionData,
) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
}
@@ -57,10 +57,10 @@ impl OpenIdHandler for Server {
async fn handle_oidc_metadata(
&self,
req: HttpRequest,
session: HttpSessionData,
req: &HttpRequest,
session: &HttpSessionData,
) -> trc::Result<HttpResponse> {
let base_url = HttpContext::new(&session, &req).resolve_response_url(self);
let base_url = HttpContext::new(session, req).resolve_response_url(self);
Ok(JsonResponse::new(OpenIdMetadata {
authorization_endpoint: format!("{base_url}/authorize/code",),

View File

@@ -0,0 +1,47 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use common::{Server, auth::AccessToken};
use http_proto::{HttpResponse, JsonResponse, ToHttpResponse};
use registry::schema::enums::Permission;
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
pub struct Permissions {
pub permissions: Vec<Permission>,
#[serde(rename = "isEnterprise")]
pub is_enterprise: bool,
}
pub trait PermissionsApiHandler: Sync + Send {
fn handle_permissions_request(
&self,
access_token: &AccessToken,
) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
}
impl PermissionsApiHandler for Server {
async fn handle_permissions_request(
&self,
access_token: &AccessToken,
) -> trc::Result<HttpResponse> {
#[cfg(not(feature = "enterprise"))]
let is_enterprise = false;
// SPDX-SnippetBegin
// SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
// SPDX-License-Identifier: LicenseRef-SEL
#[cfg(feature = "enterprise")]
let is_enterprise = self.core.is_enterprise_edition();
// SPDX-SnippetEnd
Ok(JsonResponse::new(Permissions {
permissions: access_token.permissions(),
is_enterprise,
})
.into_http_response())
}
}

View File

@@ -4,14 +4,13 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
pub mod api;
pub mod auth;
pub mod form;
pub mod management;
pub mod request;
use std::sync::Arc;
use common::Inner;
use std::sync::Arc;
#[derive(Clone)]
pub struct HttpSessionManager {

View File

@@ -6,6 +6,7 @@
use crate::{
HttpSessionManager,
api::{ManagementApi, ToManageHttpResponse},
auth::{
authenticate::{Authenticator, HttpHeaders},
oauth::{
@@ -14,11 +15,9 @@ use crate::{
},
},
form::FormHandler,
management::{ManagementApi, ToManageHttpResponse},
};
use common::{
BuildServer, Inner, KV_ACME, Server,
auth::{AccessToken, oauth::GrantType},
ipc::PushEvent,
manager::application::Resource,
network::{SessionData, SessionManager, SessionStream},
@@ -50,7 +49,6 @@ use std::{net::IpAddr, str::FromStr, sync::Arc};
use store::dispatch::lookup::KeyValue;
use trc::SecurityEvent;
use types::{blob::BlobId, id::Id};
use utils::url_params::UrlParams;
pub trait ParseHttp: Sync + Send {
fn parse_http_request(
@@ -273,14 +271,14 @@ impl ParseHttp for Server {
self.is_http_anonymous_request_allowed(session.remote_ip)
.await?;
return self.handle_oauth_metadata(req, session).await;
return self.handle_oauth_metadata(req, &session).await;
}
("openid-configuration", &Method::GET) => {
// Limit anonymous requests
self.is_http_anonymous_request_allowed(session.remote_ip)
.await?;
return self.handle_oidc_metadata(req, session).await;
return self.handle_oidc_metadata(&req, &session).await;
}
("acme-challenge", &Method::GET) if self.has_acme_http_providers() => {
if let Some(token) = path.next() {
@@ -347,21 +345,11 @@ impl ParseHttp for Server {
_ => (),
},
"auth" => match (path.next().unwrap_or_default(), req.method()) {
("login", &Method::POST) => {
self.is_http_anonymous_request_allowed(session.remote_ip)
.await?;
let bytes = fetch_body(&mut req, 4096, session.session_id)
.await
.ok_or_else(|| trc::LimitEvent::SizeRequest.into_err())?;
return self.handle_login_request(session, bytes).await;
}
("device", &Method::POST) => {
self.is_http_anonymous_request_allowed(session.remote_ip)
.await?;
return self.handle_device_auth(&mut req, session).await;
return self.handle_device_auth(&mut req, &session).await;
}
("token", &Method::POST) => {
self.is_http_anonymous_request_allowed(session.remote_ip)
@@ -410,45 +398,7 @@ impl ParseHttp for Server {
return Ok(JsonProblemResponse(StatusCode::NO_CONTENT).into_http_response());
}
let params = UrlParams::new(req.uri().query());
let access_token = if let Some(token) = params.get("token") {
// SPDX-SnippetBegin
// SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
// SPDX-License-Identifier: LicenseRef-SEL
#[cfg(feature = "enterprise")]
if self.core.is_enterprise_edition() {
let path = req.uri().path();
let (grant_type, permissions) = if path.starts_with("/api/telemetry/traces")
{
(GrantType::LiveTracing, Permission::LiveTracing)
} else if path.starts_with("/api/telemetry/metrics") {
(GrantType::LiveMetrics, Permission::LiveMetrics)
} else if path.starts_with("/api/diagnose") {
(GrantType::Diagnose, Permission::LiveDeliveryTest)
} else {
return Err(trc::ResourceEvent::NotFound.into_err());
};
AccessToken::from_permissions(
self.validate_access_token(grant_type.into(), token)
.await?
.account_id,
[permissions],
)
} else {
self.authenticate_headers(&req, &session).await?.1
}
// SPDX-SnippetEnd
#[cfg(not(feature = "enterprise"))]
{
self.authenticate_headers(&req, &session).await?.1
}
} else {
self.authenticate_headers(&req, &session).await?.1
};
return self
.handle_api_manage_request(&mut req, &access_token, &session)
.await;
return self.handle_api_request(&mut req, &session).await;
}
"mail" => {
if req.method() == Method::GET
@@ -593,7 +543,7 @@ impl ParseHttp for Server {
// SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
// SPDX-License-Identifier: LicenseRef-SEL
#[cfg(feature = "enterprise")]
"logo.svg" if self.is_enterprise_edition() => {
"logo" if self.is_enterprise_edition() => {
match self
.logo_resource(
req.headers()
@@ -607,17 +557,13 @@ impl ParseHttp for Server {
Ok(Some(resource)) => {
return Ok(resource.into_http_response());
}
Ok(None) => (),
Ok(None) => {
return Err(trc::ResourceEvent::NotFound.into_err());
}
Err(err) => {
trc::error!(err.span_id(session.session_id));
}
}
let resource = self.inner.data.applications.get("logo.svg").await?;
if !resource.is_empty() {
return Ok(resource.into_http_response());
}
}
// SPDX-SnippetEnd
"form" => {
@@ -642,6 +588,14 @@ impl ParseHttp for Server {
}
}
}
"login" | "device" => {
let page = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../resources/html-templates/login.html.min"
));
return Ok(HtmlResponse::new(page.to_string()).into_http_response());
}
_ => {
let path = req.uri().path();
let resource = self