fixed #196 - HTTP tickets support

This commit is contained in:
Eugene Pankov 2022-07-30 18:40:03 +02:00
parent 112a6581f0
commit 8ea3250d4b
No known key found for this signature in database
GPG key ID: 5896FCBBDD1CF4F4
13 changed files with 325 additions and 155 deletions

View file

@ -1,4 +1,4 @@
use crate::common::SessionExt;
use crate::common::{SessionExt, SessionAuthorization};
use crate::session::SessionStore;
use anyhow::Context;
use poem::session::Session;
@ -83,7 +83,7 @@ impl Api {
.set_username(username.clone())
.await?;
info!(%username, "Authenticated");
session.set_username(username);
session.set_auth(SessionAuthorization::User(username));
Ok(LoginResponse::Success)
}
x => {

View file

@ -6,7 +6,7 @@ use poem_openapi::{ApiResponse, Object, OpenApi};
use serde::Serialize;
use warpgate_common::Services;
use crate::common::SessionExt;
use crate::common::{SessionAuthorization, SessionExt};
pub struct Api;
@ -24,6 +24,7 @@ pub struct Info {
selected_target: Option<String>,
external_host: Option<String>,
ports: PortsInfo,
authorized_via_ticket: bool,
}
#[derive(ApiResponse)]
@ -53,6 +54,10 @@ impl Api {
username: session.get_username(),
selected_target: session.get_target_name(),
external_host: external_host.map(&str::to_string),
authorized_via_ticket: matches!(
session.get_auth(),
Some(SessionAuthorization::Ticket { .. })
),
ports: if session.is_authenticated() {
PortsInfo {
ssh: if config.store.ssh.enable {

View file

@ -1,12 +1,11 @@
use futures::stream;
use futures::StreamExt;
use futures::{stream, StreamExt};
use poem::web::Data;
use poem_openapi::payload::Json;
use poem_openapi::{ApiResponse, Enum, Object, OpenApi};
use serde::Serialize;
use warpgate_common::{Services, TargetOptions};
use crate::common::{endpoint_auth, SessionUsername};
use crate::common::{endpoint_auth, SessionAuthorization};
pub struct Api;
@ -42,24 +41,30 @@ impl Api {
async fn api_get_all_targets(
&self,
services: Data<&Services>,
username: Data<&SessionUsername>,
auth: Data<&SessionAuthorization>,
) -> poem::Result<GetTargetsResponse> {
let targets = {
let mut config_provider = services.config_provider.lock().await;
config_provider.list_targets().await?
};
let mut targets = stream::iter(targets)
.filter_map(|t| {
.filter(|t| {
let services = services.clone();
let username = &username;
let auth = auth.clone();
let name = t.name.clone();
async move {
let mut config_provider = services.config_provider.lock().await;
match config_provider
.authorize_target(&username.0 .0, &t.name)
.await
{
Ok(true) => Some(t),
_ => None,
match auth {
SessionAuthorization::Ticket { target_name, .. } => target_name == name,
SessionAuthorization::User(_) => {
let mut config_provider = services.config_provider.lock().await;
match config_provider
.authorize_target(auth.username(), &name)
.await
{
Ok(true) => true,
_ => false,
}
}
}
}
})

View file

@ -9,7 +9,7 @@ use tokio::sync::Mutex;
use tracing::*;
use warpgate_common::{Services, Target, TargetHTTPOptions, TargetOptions, WarpgateServerHandle};
use crate::common::{gateway_redirect, SessionExt, SessionUsername};
use crate::common::{gateway_redirect, SessionAuthorization, SessionExt};
use crate::proxy::{proxy_normal_request, proxy_websocket_request};
#[derive(Deserialize)]
@ -24,7 +24,6 @@ pub async fn catchall_endpoint(
ws: Option<WebSocket>,
session: &Session,
body: Body,
username: Data<&SessionUsername>,
services: Data<&Services>,
server_handle: Option<Data<&Arc<Mutex<WarpgateServerHandle>>>>,
) -> poem::Result<Response> {
@ -34,16 +33,6 @@ pub async fn catchall_endpoint(
session.set_target_name(target.name.clone());
if !services
.config_provider
.lock()
.await
.authorize_target(&username.0 .0, &target.name)
.await?
{
return Ok(gateway_redirect(req).into_response());
}
if let Some(server_handle) = server_handle {
server_handle.lock().await.set_target(&target).await?;
}
@ -68,8 +57,48 @@ async fn get_target_for_request(
) -> poem::Result<Option<(Target, TargetHTTPOptions)>> {
let session: &Session = <_>::from_request_without_body(req).await?;
let params: QueryParams = req.params()?;
let auth: Data<&SessionAuthorization> = <_>::from_request_without_body(req).await?;
if let Some(target_name) = params.warpgate_target.or(session.get_target_name()) {
let selected_target_name;
let need_role_auth;
let host_based_target_name = if let Some(host) = req.original_uri().host() {
services
.config
.lock()
.await
.store
.targets
.iter()
.filter_map(|t| match t.options {
TargetOptions::Http(ref options) => Some((t, options)),
_ => None,
})
.filter(|(_, o)| o.external_host.as_deref() == Some(host))
.next()
.map(|(t, _)| t.name.clone())
} else {
None
};
match *auth {
SessionAuthorization::Ticket { target_name, .. } => {
selected_target_name = Some(target_name.clone());
need_role_auth = false;
}
SessionAuthorization::User(_) => {
need_role_auth = true;
selected_target_name =
host_based_target_name.or(if let Some(warpgate_target) = params.warpgate_target {
Some(warpgate_target)
} else {
session.get_target_name()
});
}
};
if let Some(target_name) = selected_target_name {
let target = {
services
.config
@ -87,29 +116,21 @@ async fn get_target_for_request(
.map(|(t, o)| (t.clone(), o.clone()))
};
return Ok(target);
if let Some(target) = target {
if need_role_auth
&& !services
.config_provider
.lock()
.await
.authorize_target(&auth.username(), &target.0.name)
.await?
{
return Ok(None);
}
return Ok(Some(target));
}
}
let Some(host) = req.original_uri().host() else {
return Ok(None);
};
let target = {
services
.config
.lock()
.await
.store
.targets
.iter()
.filter_map(|t| match t.options {
TargetOptions::Http(ref options) => Some((t, options)),
_ => None,
})
.filter(|(_, o)| o.external_host.as_deref() == Some(host))
.next()
.map(|(t, o)| (t.clone(), o.clone()))
};
return Ok(target);
return Ok(None);
}

View file

@ -1,14 +1,17 @@
use std::sync::Arc;
use std::time::Duration;
use http::StatusCode;
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
use poem::session::Session;
use poem::web::{Data, Redirect};
use poem::{Endpoint, EndpointExt, FromRequest, IntoResponse, Request, Response};
use std::time::Duration;
use serde::{Deserialize, Serialize};
use warpgate_common::{ProtocolName, Services, TargetOptions};
pub const PROTOCOL_NAME: ProtocolName = "HTTP";
static USERNAME_SESSION_KEY: &str = "username";
static TARGET_SESSION_KEY: &str = "target_name";
static AUTH_SESSION_KEY: &str = "auth";
pub static SESSION_MAX_AGE: Duration = Duration::from_secs(60 * 30);
pub static COOKIE_MAX_AGE: Duration = Duration::from_secs(60 * 60 * 24);
@ -18,7 +21,8 @@ pub trait SessionExt {
fn set_target_name(&self, target_name: String);
fn is_authenticated(&self) -> bool;
fn get_username(&self) -> Option<String>;
fn set_username(&self, username: String);
fn get_auth(&self) -> Option<SessionAuthorization>;
fn set_auth(&self, auth: SessionAuthorization);
}
impl SessionExt for Session {
@ -27,7 +31,7 @@ impl SessionExt for Session {
}
fn get_target_name(&self) -> Option<String> {
self.get::<String>(TARGET_SESSION_KEY)
self.get(TARGET_SESSION_KEY)
}
fn set_target_name(&self, target_name: String) {
@ -39,26 +43,49 @@ impl SessionExt for Session {
}
fn get_username(&self) -> Option<String> {
self.get::<String>(USERNAME_SESSION_KEY)
return self.get_auth().map(|x| x.username().to_owned());
}
fn set_username(&self, username: String) {
self.set(USERNAME_SESSION_KEY, username);
fn get_auth(&self) -> Option<SessionAuthorization> {
self.get(AUTH_SESSION_KEY)
}
fn set_auth(&self, auth: SessionAuthorization) {
self.set(AUTH_SESSION_KEY, auth);
}
}
#[derive(Clone)]
pub struct SessionUsername(pub String);
#[derive(Clone, Serialize, Deserialize)]
pub enum SessionAuthorization {
User(String),
Ticket {
username: String,
target_name: String,
},
}
async fn is_user_admin(req: &Request, username: &SessionUsername) -> poem::Result<bool> {
impl SessionAuthorization {
pub fn username(&self) -> &String {
match self {
SessionAuthorization::User(username) => username,
SessionAuthorization::Ticket { username, .. } => username,
}
}
}
async fn is_user_admin(req: &Request, auth: &SessionAuthorization) -> poem::Result<bool> {
let services: Data<&Services> = <_>::from_request_without_body(&req).await?;
let SessionAuthorization::User(username) = auth else {
return Ok(false)
};
let mut config_provider = services.config_provider.lock().await;
let targets = config_provider.list_targets().await?;
for target in targets {
if matches!(target.options, TargetOptions::WebAdmin(_))
&& config_provider
.authorize_target(&username.0, &target.name)
.authorize_target(&username, &target.name)
.await?
{
drop(config_provider);
@ -70,8 +97,8 @@ async fn is_user_admin(req: &Request, username: &SessionUsername) -> poem::Resul
pub fn endpoint_admin_auth<E: Endpoint + 'static>(e: E) -> impl Endpoint {
e.around(|ep, req| async move {
let username: Data<&SessionUsername> = <_>::from_request_without_body(&req).await?;
if is_user_admin(&req, username.0).await? {
let auth: Data<&SessionAuthorization> = <_>::from_request_without_body(&req).await?;
if is_user_admin(&req, &auth).await? {
return Ok(ep.call(req).await?.into_response());
}
Err(poem::Error::from_status(StatusCode::UNAUTHORIZED))
@ -80,9 +107,9 @@ pub fn endpoint_admin_auth<E: Endpoint + 'static>(e: E) -> impl Endpoint {
pub fn page_admin_auth<E: Endpoint + 'static>(e: E) -> impl Endpoint {
e.around(|ep, req| async move {
let username: Data<&SessionUsername> = <_>::from_request_without_body(&req).await?;
let auth: Data<&SessionAuthorization> = <_>::from_request_without_body(&req).await?;
let session: &Session = <_>::from_request_without_body(&req).await?;
if is_user_admin(&req, username.0).await? {
if is_user_admin(&req, &auth).await? {
return Ok(ep.call(req).await?.into_response());
}
session.clear();
@ -90,29 +117,33 @@ pub fn page_admin_auth<E: Endpoint + 'static>(e: E) -> impl Endpoint {
})
}
pub async fn _inner_auth<E: Endpoint + 'static>(
ep: Arc<E>,
req: Request,
) -> poem::Result<Option<E::Output>> {
let session: &Session = FromRequest::from_request_without_body(&req).await?;
Ok(match session.get_auth() {
Some(auth) => Some(ep.data(auth).call(req).await?),
_ => None,
})
}
pub fn endpoint_auth<E: Endpoint + 'static>(e: E) -> impl Endpoint {
e.around(|ep, req| async move {
let session: &Session = FromRequest::from_request_without_body(&req).await?;
match session.get_username() {
Some(username) => Ok(ep.data(SessionUsername(username)).call(req).await?),
None => Err(poem::Error::from_status(StatusCode::UNAUTHORIZED)),
}
_inner_auth(ep, req)
.await?
.ok_or_else(|| poem::Error::from_status(StatusCode::UNAUTHORIZED))
})
}
pub fn page_auth<E: Endpoint + 'static>(e: E) -> impl Endpoint {
e.around(|ep, req| async move {
let session: &Session = FromRequest::from_request_without_body(&req).await?;
match session.get_username() {
Some(username) => Ok(ep
.data(SessionUsername(username))
.call(req)
.await?
.into_response()),
None => Ok(gateway_redirect(&req).into_response()),
}
let err_resp = gateway_redirect(&req).into_response();
Ok(_inner_auth(ep, req)
.await?
.map(IntoResponse::into_response)
.unwrap_or(err_resp))
})
}

View file

@ -4,6 +4,7 @@ mod catchall;
mod common;
mod error;
mod logging;
mod middleware;
mod proxy;
mod session;
mod session_handle;
@ -21,7 +22,7 @@ use logging::{log_request_result, span_for_request};
use poem::endpoint::{EmbeddedFileEndpoint, EmbeddedFilesEndpoint};
use poem::listener::{Listener, RustlsConfig, TcpListener};
use poem::middleware::SetHeader;
use poem::session::MemoryStorage;
use poem::session::{CookieConfig, MemoryStorage, ServerSession};
use poem::web::Data;
use poem::{Endpoint, EndpointExt, FromRequest, IntoEndpoint, IntoResponse, Route, Server};
use poem_openapi::OpenApiService;
@ -34,9 +35,10 @@ use warpgate_common::{
};
use warpgate_web::Assets;
use crate::common::{endpoint_admin_auth, endpoint_auth, page_auth};
use crate::common::{endpoint_admin_auth, endpoint_auth, page_auth, COOKIE_MAX_AGE};
use crate::error::error_page;
use crate::session::{SessionMiddleware, SessionStore, SharedSessionStorage};
use crate::middleware::{CookieHostMiddleware, TicketMiddleware};
use crate::session::{SessionStore, SharedSessionStorage};
pub struct HTTPProtocolServer {
services: Services,
@ -121,7 +123,15 @@ impl ProtocolServer for HTTPProtocolServer {
SetHeader::new()
.overriding(http::header::STRICT_TRANSPORT_SECURITY, "max-age=31536000"),
)
.with(SessionMiddleware::new(session_storage.clone()))
.with(TicketMiddleware::new())
.with(ServerSession::new(
CookieConfig::default()
.secure(false)
.max_age(COOKIE_MAX_AGE)
.name("warpgate-http-session"),
session_storage.clone(),
))
.with(CookieHostMiddleware::new())
.data(self.services.clone())
.data(session_store.clone())
.data(session_storage);

View file

@ -0,0 +1,49 @@
use async_trait::async_trait;
use http::header::Entry;
use poem::web::cookie::Cookie;
use poem::{Endpoint, IntoResponse, Middleware, Request, Response};
pub struct CookieHostMiddleware {}
impl CookieHostMiddleware {
pub fn new() -> Self {
Self {}
}
}
pub struct CookieHostMiddlewareEndpoint<E: Endpoint> {
inner: E,
}
impl<E: Endpoint> Middleware<E> for CookieHostMiddleware {
type Output = CookieHostMiddlewareEndpoint<E>;
fn transform(&self, inner: E) -> Self::Output {
CookieHostMiddlewareEndpoint { inner }
}
}
#[async_trait]
impl<E: Endpoint> Endpoint for CookieHostMiddlewareEndpoint<E> {
type Output = Response;
async fn call(&self, req: Request) -> poem::Result<Self::Output> {
let host = req.original_uri().host().map(|x| x.to_string());
let mut resp = self.inner.call(req).await?.into_response();
if let Some(host) = host {
if let Entry::Occupied(mut entry) = resp.headers_mut().entry(http::header::SET_COOKIE) {
if let Ok(cookie_str) = entry.get().to_str() {
if let Ok(mut cookie) = Cookie::parse(cookie_str) {
cookie.set_domain(host);
if let Ok(value) = cookie.to_string().parse() {
entry.insert(value);
}
}
}
}
}
Ok(resp)
}
}

View file

@ -0,0 +1,5 @@
mod cookie_host;
mod ticket;
pub use cookie_host::*;
pub use ticket::*;

View file

@ -0,0 +1,91 @@
use async_trait::async_trait;
use poem::session::Session;
use poem::web::{Data, FromRequest};
use poem::{Endpoint, Middleware, Request};
use serde::Deserialize;
use warpgate_common::{authorize_ticket, Secret, Services};
use crate::common::{SessionExt};
pub struct TicketMiddleware {}
impl TicketMiddleware {
pub fn new() -> Self {
TicketMiddleware {}
}
}
pub struct TicketMiddlewareEndpoint<E: Endpoint> {
inner: E,
}
impl<E: Endpoint> Middleware<E> for TicketMiddleware {
type Output = TicketMiddlewareEndpoint<E>;
fn transform(&self, inner: E) -> Self::Output {
TicketMiddlewareEndpoint { inner }
}
}
#[derive(Deserialize)]
struct QueryParams {
#[serde(rename = "warpgate-ticket")]
ticket: Option<String>,
}
#[async_trait]
impl<E: Endpoint> Endpoint for TicketMiddlewareEndpoint<E> {
type Output = E::Output;
async fn call(&self, req: Request) -> poem::Result<Self::Output> {
let mut session_is_temporary = false;
let session: &Session = <_>::from_request_without_body(&req).await?;
let session = session.clone();
{
let params: QueryParams = req.params()?;
let mut ticket_value = None;
if let Some(t) = params.ticket {
ticket_value = Some(t);
}
for h in req.headers().get_all(http::header::AUTHORIZATION) {
let header_value = h.to_str().unwrap_or("").to_string();
if let Some((token_type, token_value)) = header_value.split_once(' ') {
if &token_type.to_lowercase() == "warpgate" {
ticket_value = Some(token_value.to_string());
session_is_temporary = true;
}
}
}
if let Some(ticket) = ticket_value {
let services: Data<&Services> = <_>::from_request_without_body(&req).await?;
if let Some(ticket_model) = {
let ticket = Secret::new(ticket);
let mut cp = services.config_provider.lock().await;
if let Some(res) = authorize_ticket(&services.db, &ticket).await? {
cp.consume_ticket(&res.id).await?;
Some(res)
} else {
None
}
} {
session.set_auth(crate::common::SessionAuthorization::Ticket {
username: ticket_model.username,
target_name: ticket_model.target,
});
}
}
}
let resp = self.inner.call(req).await;
if session_is_temporary {
session.clear();
}
resp
}
}

View file

@ -3,21 +3,15 @@ use std::sync::{Arc, Weak};
use std::time::{Duration, Instant};
use async_trait::async_trait;
use http::header::Entry;
use poem::middleware::CookieJarManagerEndpoint;
use poem::session::{
CookieConfig, ServerSession as PoemSessionMiddleware, ServerSessionEndpoint, Session,
SessionStorage,
};
use poem::web::cookie::Cookie;
use poem::session::{Session, SessionStorage};
use poem::web::{Data, RemoteAddr};
use poem::{Endpoint, FromRequest, IntoResponse, Middleware, Request, Response};
use poem::{FromRequest, Request};
use serde_json::Value;
use tokio::sync::Mutex;
use tracing::*;
use warpgate_common::{Services, SessionId, SessionStateInit, WarpgateServerHandle};
use crate::common::{COOKIE_MAX_AGE, PROTOCOL_NAME, SESSION_MAX_AGE};
use crate::common::{PROTOCOL_NAME, SESSION_MAX_AGE};
use crate::session_handle::{
HttpSessionHandle, SessionHandleCommand, WarpgateServerHandleFromRequest,
};
@ -190,60 +184,3 @@ impl SessionStore {
}
}
}
pub struct SessionMiddleware {
inner: PoemSessionMiddleware<SharedSessionStorage>,
}
impl SessionMiddleware {
pub fn new(session_storage: SharedSessionStorage) -> Self {
Self {
inner: PoemSessionMiddleware::new(
CookieConfig::default()
.secure(false)
.max_age(COOKIE_MAX_AGE)
.name("warpgate-http-session"),
session_storage,
),
}
}
}
pub struct SessionMiddlewareEndpoint<E: Endpoint> {
inner: E,
}
impl<E: Endpoint> Middleware<E> for SessionMiddleware {
type Output = SessionMiddlewareEndpoint<
CookieJarManagerEndpoint<ServerSessionEndpoint<SharedSessionStorage, E>>,
>;
fn transform(&self, ep: E) -> Self::Output {
SessionMiddlewareEndpoint {
inner: self.inner.transform(ep),
}
}
}
#[async_trait]
impl<E: Endpoint> Endpoint for SessionMiddlewareEndpoint<E> {
type Output = Response;
async fn call(&self, req: Request) -> poem::Result<Self::Output> {
let host = req.original_uri().host().map(|x| x.to_string());
let mut resp = self.inner.call(req).await?.into_response();
if let Some(host) = host {
if let Entry::Occupied(mut entry) = resp.headers_mut().entry(http::header::SET_COOKIE) {
if let Ok(cookie_str) = entry.get().to_str() {
if let Ok(mut cookie) = Cookie::parse(cookie_str) {
cookie.set_domain(host);
if let Ok(value) = cookie.to_string().parse() {
entry.insert(value);
}
}
}
}
}
Ok(resp)
}
}

View file

@ -24,6 +24,7 @@
$: exampleMySQLCommand = makeExampleMySQLCommand(opts)
$: exampleMySQLURI = makeExampleMySQLURI(opts)
$: targetURL = targetName ? makeTargetURL(opts) : ''
$: authHeader = `Authorization: Warpgate ${ticketSecret}`
</script>
{#if targetKind === TargetKind.Ssh}
@ -43,6 +44,12 @@
<input type="text" class="form-control" readonly value={targetURL} />
<CopyButton text={targetURL} />
</FormGroup>
Alternatively, set the <code>Authorization</code> header when accessing the URL:
<FormGroup floating label="Authorization header" class="d-flex align-items-center">
<input type="text" class="form-control" readonly value={authHeader} />
<CopyButton text={authHeader} />
</FormGroup>
{/if}
{#if targetKind === TargetKind.MySql}

View file

@ -43,7 +43,12 @@ init()
</div>
{#if $serverInfo?.username}
<div class="ms-auto">{$serverInfo.username}</div>
<div class="ms-auto">
{$serverInfo.username}
{#if $serverInfo.authorizedViaTicket}
<span class="ml-2">(ticket auth)</span>
{/if}
</div>
<button class="btn btn-link" on:click={logout} title="Log out">
<Fa icon={faSignOut} fw />
</button>

View file

@ -95,7 +95,8 @@
"type": "object",
"required": [
"version",
"ports"
"ports",
"authorized_via_ticket"
],
"properties": {
"version": {
@ -112,6 +113,9 @@
},
"ports": {
"$ref": "#/components/schemas/PortsInfo"
},
"authorized_via_ticket": {
"type": "boolean"
}
}
},