API tokens

This commit is contained in:
Eugene Pankov 2022-11-10 18:32:01 +01:00
parent 8087179ea0
commit f2faa84e1e
No known key found for this signature in database
GPG key ID: 5896FCBBDD1CF4F4
32 changed files with 803 additions and 105 deletions

1
Cargo.lock generated
View file

@ -4903,6 +4903,7 @@ dependencies = [
"poem-openapi",
"regex",
"reqwest",
"sea-orm",
"serde",
"serde_json",
"tokio",

View file

@ -6,13 +6,13 @@ run *ARGS:
RUST_BACKTRACE=1 RUST_LOG=warpgate cargo run --features {{features}} -- --config config.yaml {{ARGS}}
fmt:
for p in {{projects}}; do cargo fmt --features {{features}} -p $p -v; done
for p in {{projects}}; do cargo fmt -p $p -v; done
fix *ARGS:
for p in {{projects}}; do cargo fix --features {{features}} -p $p {{ARGS}}; done
for p in {{projects}}; do cargo fix --all-features -p $p {{ARGS}}; done
clippy *ARGS:
for p in {{projects}}; do cargo cranky --features {{features}} -p $p {{ARGS}}; done
for p in {{projects}}; do cargo cranky --all-features -p $p {{ARGS}}; done
test:
for p in {{projects}}; do cargo test --features {{features}} -p $p; done

View file

@ -1,4 +1,5 @@
use poem_openapi::OpenApi;
use poem_openapi::auth::Bearer;
use poem_openapi::{OpenApi, SecurityScheme};
mod known_hosts_detail;
mod known_hosts_list;
@ -14,6 +15,23 @@ mod tickets_detail;
mod tickets_list;
mod users;
#[derive(SecurityScheme)]
#[oai(type = "bearer")]
pub(crate) struct TokenAuth(Bearer);
pub struct Api;
#[OpenApi]
impl Api {
#[oai(path = "/__", method = "get", operation_id = "_ignore_me")]
async fn _hidden(
&self,
_auth: TokenAuth, // only needed once for the security schema to be included in the spec
) -> poem::Result<()> {
Ok(())
}
}
pub fn get() -> impl OpenApi {
(
sessions_list::Api,
@ -29,5 +47,6 @@ pub fn get() -> impl OpenApi {
known_hosts_detail::Api,
ssh_keys::Api,
logs::Api,
Api,
)
}

View file

@ -54,10 +54,7 @@ impl Api {
.all(&*db)
.await
.map_err(poem::error::InternalServerError)?;
let tickets = tickets
.into_iter()
.map(Into::into)
.collect::<Vec<Ticket::Model>>();
Ok(GetTicketsResponse::Ok(Json(tickets)))
}

View file

@ -14,7 +14,6 @@ pub fn admin_api_app(services: &Services) -> impl IntoEndpoint {
let ui = api_service.swagger_ui();
let spec = api_service.spec_endpoint();
let db = services.db.clone();
let config = services.config.clone();
let config_provider = services.config_provider.clone();
let recordings = services.recordings.clone();
@ -40,7 +39,6 @@ pub fn admin_api_app(services: &Services) -> impl IntoEndpoint {
"/sessions/changes",
crate::api::sessions_list::api_get_sessions_changes_stream,
)
.data(db)
.data(config_provider)
.data(state)
.data(recordings)

View file

@ -0,0 +1,37 @@
use chrono::{DateTime, Utc};
use poem_openapi::Object;
use sea_orm::entity::prelude::*;
use serde::Serialize;
use uuid::Uuid;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Object)]
#[sea_orm(table_name = "tokens")]
#[oai(rename = "Token")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub name: String,
#[oai(skip)]
pub secret: String,
pub user_id: Uuid,
pub expiry: Option<DateTime<Utc>>,
pub created: DateTime<Utc>,
}
impl Related<super::User::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::User::Entity",
from = "Column::UserId",
to = "super::User::Column::Id"
)]
User,
}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -8,5 +8,6 @@ pub mod Session;
pub mod Target;
pub mod TargetRoleAssignment;
pub mod Ticket;
pub mod Token;
pub mod User;
pub mod UserRoleAssignment;

View file

@ -20,3 +20,8 @@ sea-orm = { version = "^0.9", features = [
sea-orm-migration = { version = "^0.9", default-features = false }
uuid = { version = "1.0", features = ["v4", "serde"] }
serde_json = "1.0"
[features]
postgres = ["sea-orm/sqlx-postgres"]
mysql = ["sea-orm/sqlx-mysql"]
sqlite = ["sea-orm/sqlx-sqlite"]

View file

@ -10,6 +10,7 @@ mod m00005_create_log_entry;
mod m00006_add_session_protocol;
mod m00007_targets_and_roles;
mod m00008_users;
mod m00009_tokens;
pub struct Migrator;
@ -25,6 +26,7 @@ impl MigratorTrait for Migrator {
Box::new(m00006_add_session_protocol::Migration),
Box::new(m00007_targets_and_roles::Migration),
Box::new(m00008_users::Migration),
Box::new(m00009_tokens::Migration),
]
}
}

View file

@ -1,7 +1,7 @@
use sea_orm::Schema;
use sea_orm_migration::prelude::*;
mod user {
pub(crate) mod user {
use sea_orm::entity::prelude::*;
use uuid::Uuid;

View file

@ -0,0 +1,59 @@
use sea_orm::Schema;
use sea_orm_migration::prelude::*;
mod token {
use chrono::{DateTime, Utc};
use sea_orm::entity::prelude::*;
use uuid::Uuid;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "tokens")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub name: String,
pub secret: String,
pub user_id: Uuid,
pub expiry: Option<DateTime<Utc>>,
pub created: DateTime<Utc>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "crate::m00008_users::user::Entity",
from = "Column::UserId",
to = "crate::m00008_users::user::Column::Id"
)]
User,
}
impl ActiveModelBehavior for ActiveModel {}
}
pub struct Migration;
impl MigrationName for Migration {
fn name(&self) -> &str {
"m00009_tokens"
}
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let builder = manager.get_database_backend();
let schema = Schema::new(builder);
manager
.create_table(schema.create_table_from_entity(token::Entity))
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(token::Entity).to_owned())
.await?;
Ok(())
}
}

View file

@ -25,6 +25,10 @@ poem = { version = "^1.3.42", features = [
] }
poem-openapi = { version = "^2.0.10", features = ["swagger-ui"] }
reqwest = { version = "0.11", features = ["rustls-tls-native-roots", "stream"] }
sea-orm = { version = "^0.9", features = [
"runtime-tokio-native-tls",
"macros",
], default-features = false }
serde = "1.0"
serde_json = "1.0"
tokio = { version = "1.20", features = ["tracing", "signal"] }

View file

@ -2,7 +2,7 @@ use std::sync::Arc;
use poem::session::Session;
use poem::web::Data;
use poem::Request;
use poem::{FromRequest, Request};
use poem_openapi::param::Path;
use poem_openapi::payload::Json;
use poem_openapi::{ApiResponse, Enum, Object, OpenApi};
@ -14,7 +14,8 @@ use warpgate_common::{Secret, WarpgateError};
use warpgate_core::Services;
use crate::common::{
authorize_session, endpoint_auth, get_auth_state_for_request, SessionAuthorization, SessionExt,
authorize_session, endpoint_auth, get_auth_state_for_request, RequestAuthorization,
SessionAuthorization, SessionExt,
};
use crate::session::SessionStore;
@ -146,7 +147,7 @@ impl Api {
}
}
#[oai(path = "/auth/otp", method = "post", operation_id = "otpLogin")]
#[oai(path = "/auth/otp", method = "post", operation_id = "otp_login")]
async fn api_auth_otp_login(
&self,
req: &Request,
@ -200,7 +201,7 @@ impl Api {
#[oai(
path = "/auth/state",
method = "get",
operation_id = "getDefaultAuthState"
operation_id = "get_default_auth_state"
)]
async fn api_default_auth_state(
&self,
@ -220,7 +221,7 @@ impl Api {
#[oai(
path = "/auth/state",
method = "delete",
operation_id = "cancelDefaultAuth"
operation_id = "cancel_default_auth"
)]
async fn api_cancel_default_auth(
&self,
@ -249,10 +250,11 @@ impl Api {
async fn api_auth_state(
&self,
services: Data<&Services>,
auth: Option<Data<&SessionAuthorization>>,
req: &Request,
id: Path<Uuid>,
) -> poem::Result<AuthStateResponse> {
let state_arc = get_auth_state(&id, &services, auth.map(|x| x.0)).await;
let auth: Option<RequestAuthorization> = <_>::from_request_without_body(req).await.ok();
let state_arc = get_auth_state(&id, &services, auth.as_ref()).await;
let Some(state_arc) = state_arc else {
return Ok(AuthStateResponse::NotFound);
};
@ -268,10 +270,11 @@ impl Api {
async fn api_approve_auth(
&self,
services: Data<&Services>,
auth: Option<Data<&SessionAuthorization>>,
req: &Request,
id: Path<Uuid>,
) -> poem::Result<AuthStateResponse> {
let Some(state_arc) = get_auth_state(&id, &services, auth.map(|x|x.0)).await else {
let auth: Option<RequestAuthorization> = <_>::from_request_without_body(req).await.ok();
let Some(state_arc) = get_auth_state(&id, &services, auth.as_ref()).await else {
return Ok(AuthStateResponse::NotFound);
};
@ -296,10 +299,11 @@ impl Api {
async fn api_reject_auth(
&self,
services: Data<&Services>,
auth: Option<Data<&SessionAuthorization>>,
req: &Request,
id: Path<Uuid>,
) -> poem::Result<AuthStateResponse> {
let Some(state_arc) = get_auth_state(&id, &services, auth.map(|x|x.0)).await else {
let auth: Option<RequestAuthorization> = <_>::from_request_without_body(req).await.ok();
let Some(state_arc) = get_auth_state(&id, &services, auth.as_ref()).await else {
return Ok(AuthStateResponse::NotFound);
};
state_arc.lock().await.reject();
@ -311,7 +315,7 @@ impl Api {
async fn get_auth_state(
id: &Uuid,
services: &Services,
auth: Option<&SessionAuthorization>,
auth: Option<&RequestAuthorization>,
) -> Option<Arc<Mutex<AuthState>>> {
let store = services.auth_state_store.lock().await;
@ -319,7 +323,7 @@ async fn get_auth_state(
return None;
};
let SessionAuthorization::User(username) = auth else {
let RequestAuthorization::Session(SessionAuthorization::User(username)) = auth else {
return None;
};

View file

@ -6,7 +6,7 @@ use poem_openapi::{ApiResponse, Object, OpenApi};
use serde::Serialize;
use warpgate_core::Services;
use crate::common::{SessionAuthorization, SessionExt};
use crate::common::{RequestAuthorization, SessionAuthorization, SessionExt};
pub struct Api;
@ -41,6 +41,7 @@ impl Api {
req: &Request,
session: &Session,
services: Data<&Services>,
auth: Option<RequestAuthorization>,
) -> poem::Result<InstanceInfoResponse> {
let config = services.config.lock().await;
let external_host = config
@ -51,14 +52,16 @@ impl Api {
.or_else(|| req.original_uri().host());
Ok(InstanceInfoResponse::Ok(Json(Info {
version: env!("CARGO_PKG_VERSION").to_string(),
username: session.get_username(),
username: auth.as_ref().map(|a| a.username().to_owned()),
selected_target: session.get_target_name(),
external_host: external_host.map(str::to_string),
authorized_via_ticket: matches!(
session.get_auth(),
Some(SessionAuthorization::Ticket { .. })
auth,
Some(RequestAuthorization::Session(
SessionAuthorization::Ticket { .. }
))
),
ports: if session.is_authenticated() {
ports: if auth.is_some() {
PortsInfo {
ssh: if config.store.ssh.enable {
Some(config.store.ssh.listen.port())

View file

@ -1,10 +1,30 @@
use poem_openapi::OpenApi;
use poem_openapi::auth::Bearer;
use poem_openapi::{OpenApi, SecurityScheme};
pub mod auth;
pub mod info;
pub mod sso_provider_detail;
pub mod sso_provider_list;
pub mod targets_list;
mod auth;
mod info;
mod sso_provider_detail;
mod sso_provider_list;
mod targets_list;
mod tokens_detail;
mod tokens_list;
#[derive(SecurityScheme)]
#[oai(type = "bearer")]
pub(crate) struct TokenAuth(Bearer);
pub struct Api;
#[OpenApi]
impl Api {
#[oai(path = "/__", method = "get", operation_id = "_ignore_me")]
async fn _hidden(
&self,
_auth: TokenAuth, // only needed once for the security schema to be included in the spec
) -> poem::Result<()> {
Ok(())
}
}
pub fn get() -> impl OpenApi {
(
@ -13,5 +33,8 @@ pub fn get() -> impl OpenApi {
targets_list::Api,
sso_provider_list::Api,
sso_provider_detail::Api,
tokens_list::Api,
tokens_detail::Api,
Api,
)
}

View file

@ -7,7 +7,7 @@ use warpgate_common::TargetOptions;
use warpgate_core::Services;
use warpgate_db_entities::Target;
use crate::common::{endpoint_auth, SessionAuthorization};
use crate::common::{endpoint_auth, RequestAuthorization, SessionAuthorization};
pub struct Api;
@ -35,7 +35,7 @@ impl Api {
async fn api_get_all_targets(
&self,
services: Data<&Services>,
auth: Data<&SessionAuthorization>,
auth: RequestAuthorization,
) -> poem::Result<GetTargetsResponse> {
let targets = {
let mut config_provider = services.config_provider.lock().await;
@ -48,8 +48,11 @@ impl Api {
let name = t.name.clone();
async move {
match auth {
SessionAuthorization::Ticket { target_name, .. } => target_name == name,
SessionAuthorization::User(_) => {
RequestAuthorization::Session(SessionAuthorization::Ticket {
target_name,
..
}) => target_name == name,
_ => {
let mut config_provider = services.config_provider.lock().await;
matches!(

View file

@ -0,0 +1,55 @@
use std::sync::Arc;
use poem::web::Data;
use poem::Request;
use poem_openapi::param::Path;
use poem_openapi::{ApiResponse, OpenApi};
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, ModelTrait, QueryFilter};
use tokio::sync::Mutex;
use uuid::Uuid;
use crate::api::tokens_list::get_user;
pub struct Api;
#[derive(ApiResponse)]
enum DeleteTokenResponse {
#[oai(status = 204)]
Deleted,
#[oai(status = 404)]
NotFound,
}
#[OpenApi]
impl Api {
#[oai(path = "/tokens/:id", method = "delete", operation_id = "delete_token")]
async fn api_delete_token(
&self,
db: Data<&Arc<Mutex<DatabaseConnection>>>,
req: &Request,
id: Path<Uuid>,
) -> poem::Result<DeleteTokenResponse> {
use warpgate_db_entities::Token;
let db = db.lock().await;
let user = get_user(&*db, req).await?;
let token = Token::Entity::find()
.filter(Token::Column::UserId.eq(user.id))
.filter(Token::Column::Id.eq(id.0))
.one(&*db)
.await
.map_err(poem::error::InternalServerError)?;
match token {
Some(token) => {
token
.delete(&*db)
.await
.map_err(poem::error::InternalServerError)?;
Ok(DeleteTokenResponse::Deleted)
}
None => Ok(DeleteTokenResponse::NotFound),
}
}
}

View file

@ -0,0 +1,106 @@
use std::sync::Arc;
use anyhow::Context;
use http::StatusCode;
use poem::web::Data;
use poem::{FromRequest, Request};
use poem_openapi::payload::Json;
use poem_openapi::{ApiResponse, Object, OpenApi};
use sea_orm::ActiveValue::Set;
use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};
use tokio::sync::Mutex;
use uuid::Uuid;
use warpgate_common::helpers::hash::generate_ticket_secret;
use warpgate_db_entities::{Token, User};
use crate::common::RequestAuthorization;
pub struct Api;
#[derive(ApiResponse)]
enum GetTokensResponse {
#[oai(status = 200)]
Ok(Json<Vec<Token::Model>>),
}
#[derive(Object)]
struct TokenAndSecret {
token: Token::Model,
secret: String,
}
#[derive(Object)]
struct CreateTokenRequest {
name: String,
}
#[derive(ApiResponse)]
enum CreateTokenResponse {
#[oai(status = 201)]
Created(Json<TokenAndSecret>),
}
pub(crate) async fn get_user(db: &DatabaseConnection, req: &Request) -> poem::Result<User::Model> {
let auth: Option<RequestAuthorization> = <_>::from_request_without_body(req).await.ok();
if let Some(username) = auth.map(|a| a.username().to_owned()) {
Ok(User::Entity::find()
.filter(User::Column::Username.eq(username))
.one(db)
.await
.map_err(poem::error::InternalServerError)?
.ok_or(anyhow::anyhow!("User not found"))?)
} else {
Err(poem::error::Error::from_status(StatusCode::UNAUTHORIZED))
}
}
#[OpenApi]
impl Api {
#[oai(path = "/tokens", method = "get", operation_id = "get_tokens")]
async fn api_get_all_tokens(
&self,
db: Data<&Arc<Mutex<DatabaseConnection>>>,
req: &Request,
) -> poem::Result<GetTokensResponse> {
use warpgate_db_entities::Token;
let db = db.lock().await;
let user = get_user(&*db, req).await?;
let tokens = Token::Entity::find()
.filter(Token::Column::UserId.eq(user.id))
.all(&*db)
.await
.map_err(poem::error::InternalServerError)?;
Ok(GetTokensResponse::Ok(Json(tokens)))
}
#[oai(path = "/tokens", method = "post", operation_id = "create_token")]
async fn api_create_token(
&self,
db: Data<&Arc<Mutex<DatabaseConnection>>>,
body: Json<CreateTokenRequest>,
req: &Request,
) -> poem::Result<CreateTokenResponse> {
use warpgate_db_entities::Token;
let db = db.lock().await;
let user = get_user(&*db, req).await?;
let secret = generate_ticket_secret();
let values = Token::ActiveModel {
id: Set(Uuid::new_v4()),
name: Set(body.name.clone()),
secret: Set(secret.expose_secret().to_string()),
created: Set(chrono::Utc::now()),
user_id: Set(user.id),
..Default::default()
};
let token = values.insert(&*db).await.context("Error saving token")?;
Ok(CreateTokenResponse::Created(Json(TokenAndSecret {
secret: secret.expose_secret().to_string(),
token,
})))
}
}

View file

@ -10,7 +10,7 @@ use tracing::*;
use warpgate_common::{Target, TargetHTTPOptions, TargetOptions};
use warpgate_core::{Services, WarpgateServerHandle};
use crate::common::{SessionAuthorization, SessionExt};
use crate::common::{RequestAuthorization, SessionAuthorization, SessionExt};
use crate::proxy::{proxy_normal_request, proxy_websocket_request};
#[derive(Deserialize)]
@ -63,7 +63,7 @@ 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?;
let auth: RequestAuthorization = <_>::from_request_without_body(req).await?;
let selected_target_name;
let need_role_auth;
@ -86,12 +86,15 @@ async fn get_target_for_request(
None
};
match *auth {
SessionAuthorization::Ticket { target_name, .. } => {
match auth {
RequestAuthorization::Session(SessionAuthorization::Ticket {
ref target_name, ..
}) => {
selected_target_name = Some(target_name.clone());
need_role_auth = false;
}
SessionAuthorization::User(_) => {
RequestAuthorization::Token { .. }
| RequestAuthorization::Session(SessionAuthorization::User(_)) => {
need_role_auth = true;
selected_target_name =

View file

@ -1,11 +1,14 @@
use std::any::type_name;
use std::sync::Arc;
use std::time::Duration;
use http::StatusCode;
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
use poem::error::GetDataError;
use poem::session::Session;
use poem::web::{Data, Redirect};
use poem::{Endpoint, EndpointExt, FromRequest, IntoResponse, Request, Response};
use poem::{Endpoint, EndpointExt, FromRequest, IntoResponse, Request, RequestBody, Response};
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, ModelTrait, QueryFilter};
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use tracing::*;
@ -13,6 +16,7 @@ use uuid::Uuid;
use warpgate_common::auth::AuthState;
use warpgate_common::{ProtocolName, TargetOptions, WarpgateError};
use warpgate_core::{AuthStateStore, Services};
use warpgate_db_entities::{Token, User};
use crate::session::SessionStore;
@ -28,8 +32,6 @@ pub trait SessionExt {
fn has_selected_target(&self) -> bool;
fn get_target_name(&self) -> Option<String>;
fn set_target_name(&self, target_name: String);
fn is_authenticated(&self) -> bool;
fn get_username(&self) -> Option<String>;
fn get_auth(&self) -> Option<SessionAuthorization>;
fn set_auth(&self, auth: SessionAuthorization);
fn get_auth_state_id(&self) -> Option<AuthStateId>;
@ -49,14 +51,6 @@ impl SessionExt for Session {
self.set(TARGET_SESSION_KEY, target_name);
}
fn is_authenticated(&self) -> bool {
self.get_username().is_some()
}
fn get_username(&self) -> Option<String> {
self.get_auth().map(|x| x.username().to_owned())
}
fn get_auth(&self) -> Option<SessionAuthorization> {
self.get(AUTH_SESSION_KEY)
}
@ -89,18 +83,77 @@ pub enum SessionAuthorization {
impl SessionAuthorization {
pub fn username(&self) -> &String {
match self {
SessionAuthorization::User(username) => username,
SessionAuthorization::Ticket { username, .. } => username,
Self::User(username) => username,
Self::Ticket { username, .. } => username,
}
}
}
async fn is_user_admin(req: &Request, auth: &SessionAuthorization) -> poem::Result<bool> {
#[derive(Clone, Serialize, Deserialize)]
pub enum RequestAuthorization {
Session(SessionAuthorization),
Token { username: String },
}
impl RequestAuthorization {
pub fn username(&self) -> &String {
match self {
Self::Session(auth) => auth.username(),
Self::Token { username, .. } => username,
}
}
}
#[async_trait::async_trait]
impl<'a> FromRequest<'a> for RequestAuthorization {
async fn from_request(req: &'a Request, body: &mut RequestBody) -> poem::Result<Self> {
let session: &Session = <_>::from_request(req, body).await?;
if let Some(auth) = session.get_auth() {
return Ok(RequestAuthorization::Session(auth));
}
let token = req
.headers()
.get("Authorization")
.and_then(|x| x.to_str().ok())
.and_then(|x| x.strip_prefix("Bearer "))
.map(|x| x.to_owned());
if let Some(token) = token {
let db: Data<&Arc<Mutex<DatabaseConnection>>> = <_>::from_request(req, body).await?;
let mut db = db.lock().await;
let token = utf8_percent_encode(&token, NON_ALPHANUMERIC).to_string();
let token = Token::Entity::find()
.filter(Token::Column::Secret.eq(token))
.one(&mut *db)
.await
.map_err(poem::error::InternalServerError)?;
if let Some(token) = token {
let user = token
.find_related(User::Entity)
.one(&mut *db)
.await
.map_err(poem::error::InternalServerError)?;
if let Some(user) = user {
return Ok(RequestAuthorization::Token {
username: user.username,
});
}
}
}
Err(GetDataError(type_name::<RequestAuthorization>()).into())
}
}
async fn is_user_admin(req: &Request, auth: &RequestAuthorization) -> poem::Result<bool> {
let services: Data<&Services> = <_>::from_request_without_body(req).await?;
let SessionAuthorization::User(username) = auth else {
return Ok(false)
};
let username = auth.username();
if let RequestAuthorization::Session(SessionAuthorization::Ticket { .. }) = auth {
return Ok(false);
}
let mut config_provider = services.config_provider.lock().await;
let targets = config_provider.list_targets().await?;
@ -119,7 +172,7 @@ async fn is_user_admin(req: &Request, auth: &SessionAuthorization) -> poem::Resu
pub fn endpoint_admin_auth<E: Endpoint + 'static>(e: E) -> impl Endpoint {
e.around(|ep, req| async move {
let auth: Data<&SessionAuthorization> = <_>::from_request_without_body(&req).await?;
let auth: RequestAuthorization = <_>::from_request_without_body(&req).await?;
if is_user_admin(&req, &auth).await? {
return Ok(ep.call(req).await?.into_response());
}
@ -129,7 +182,7 @@ 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 auth: Data<&SessionAuthorization> = <_>::from_request_without_body(&req).await?;
let auth: RequestAuthorization = <_>::from_request_without_body(&req).await?;
let session: &Session = <_>::from_request_without_body(&req).await?;
if is_user_admin(&req, &auth).await? {
return Ok(ep.call(req).await?.into_response());
@ -139,33 +192,24 @@ 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<Output = E::Output> {
e.around(|ep, req| async move {
_inner_auth(ep, req)
Option::<RequestAuthorization>::from_request_without_body(&req)
.await?
.ok_or_else(|| poem::Error::from_status(StatusCode::UNAUTHORIZED))
.ok_or_else(|| poem::Error::from_status(StatusCode::UNAUTHORIZED))?;
ep.call(req).await
})
}
pub fn page_auth<E: Endpoint + 'static>(e: E) -> impl Endpoint {
e.around(|ep, req| async move {
let err_resp = gateway_redirect(&req).into_response();
Ok(_inner_auth(ep, req)
if Option::<RequestAuthorization>::from_request_without_body(&req)
.await?
.map(IntoResponse::into_response)
.unwrap_or(err_resp))
.is_none()
{
return Ok(gateway_redirect(&req).into_response());
}
Ok(ep.call(req).await?.into_response())
})
}

View file

@ -65,8 +65,6 @@ impl ProtocolServer for HTTPProtocolServer {
env!("CARGO_PKG_VERSION"),
)
.server("/@warpgate/api");
let ui = api_service.swagger_ui();
let spec = api_service.spec_endpoint();
let session_storage =
SharedSessionStorage(Arc::new(Mutex::new(Box::new(MemoryStorage::default()))));
@ -86,13 +84,15 @@ impl ProtocolServer for HTTPProtocolServer {
)
};
let db = self.services.db.clone();
let app = Route::new()
.nest(
"/@warpgate",
Route::new()
.nest("/api/swagger", ui)
.nest("/api/swagger", api_service.swagger_ui())
.nest("/api/openapi.json", api_service.spec_endpoint())
.nest("/api", api_service.with(cache_bust()))
.nest("/api/openapi.json", spec)
.nest_no_strip(
"/assets",
EmbeddedFilesEndpoint::<Assets>::new().with(cache_static()),
@ -156,7 +156,8 @@ impl ProtocolServer for HTTPProtocolServer {
.with(CookieHostMiddleware::new())
.data(self.services.clone())
.data(session_store.clone())
.data(session_storage);
.data(session_storage)
.data(db);
tokio::spawn(async move {
loop {

View file

@ -66,6 +66,7 @@ static DONT_FORWARD_HEADERS: Lazy<HashSet<HeaderName>> = Lazy::new(|| {
#[allow(clippy::mutable_key_type)]
let mut s = HashSet::new();
s.insert(http::header::ACCEPT_ENCODING);
s.insert(http::header::AUTHORIZATION);
s.insert(http::header::SEC_WEBSOCKET_EXTENSIONS);
s.insert(http::header::SEC_WEBSOCKET_ACCEPT);
s.insert(http::header::SEC_WEBSOCKET_KEY);

View file

@ -80,16 +80,16 @@ const routes = {
</div>
</a>
{#if $serverInfo?.username}
<a use:link use:active href="/">Sessions</a>
<a use:link use:active href="/config">Config</a>
<a use:link use:active href="/tickets">Tickets</a>
<a use:link use:active href="/ssh">SSH</a>
<a use:link use:active href="/log">Log</a>
<a use:link use:active class="nav" href="/">Sessions</a>
<a use:link use:active class="nav" href="/config">Config</a>
<a use:link use:active class="nav" href="/tickets">Tickets</a>
<a use:link use:active class="nav" href="/ssh">SSH</a>
<a use:link use:active class="nav" href="/log">Log</a>
{/if}
{#if $serverInfo?.username}
<div class="username ms-auto">
<a href='/@warpgate#/profile' class="username ms-auto">
{$serverInfo?.username}
</div>
</a>
<button class="btn btn-link" on:click={logout} title="Log out">
<Fa icon={faSignOut} fw />
</button>
@ -135,7 +135,7 @@ const routes = {
padding: 10px 0;
margin: 10px 0 20px;
a, .logo {
.nav, .logo {
font-size: 1.5rem;
}

View file

@ -6,7 +6,7 @@
import { api, SessionSnapshot } from 'admin/lib/api'
import moment from 'moment'
import { timer, Observable, switchMap, from, combineLatest, fromEvent, merge } from 'rxjs'
import RelativeDate from './RelativeDate.svelte'
import RelativeDate from '../common/RelativeDate.svelte'
import AsyncButton from 'common/AsyncButton.svelte'
import ItemList, { LoadOptions, PaginatedResponse } from 'common/ItemList.svelte'
import { Input } from 'sveltestrap'

View file

@ -8,7 +8,7 @@ import { onDestroy } from 'svelte'
import { link } from 'svelte-spa-router'
import { Alert } from 'sveltestrap'
import LogViewer from './LogViewer.svelte'
import RelativeDate from './RelativeDate.svelte'
import RelativeDate from '../common/RelativeDate.svelte'
export let params = { id: '' }

View file

@ -2,7 +2,7 @@
import { api, Ticket } from 'admin/lib/api'
import { link } from 'svelte-spa-router'
import { Alert } from 'sveltestrap'
import RelativeDate from './RelativeDate.svelte'
import RelativeDate from '../common/RelativeDate.svelte'
let error: Error|undefined
let tickets: Ticket[]|undefined

View file

@ -1071,6 +1071,21 @@
},
"operationId": "get_logs"
}
},
"/__": {
"get": {
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"TokenAuth": []
}
],
"operationId": "_ignore_me"
}
}
},
"components": {
@ -1284,7 +1299,7 @@
},
"port": {
"type": "integer",
"format": "uint16"
"format": "int32"
},
"key_type": {
"type": "string"
@ -1642,7 +1657,7 @@
},
"uses_left": {
"type": "integer",
"format": "uint32"
"format": "int32"
},
"expiry": {
"type": "string",
@ -1922,6 +1937,12 @@
}
}
}
},
"securitySchemes": {
"TokenAuth": {
"type": "http",
"scheme": "bearer"
}
}
}
}

View file

@ -2,7 +2,7 @@
import { faSignOut } from '@fortawesome/free-solid-svg-icons'
import { Alert } from 'sveltestrap'
import Fa from 'svelte-fa'
import Router, { push, RouteDetail } from 'svelte-spa-router'
import Router, { link, push, RouteDetail } from 'svelte-spa-router'
import { wrap } from 'svelte-spa-router/wrap'
import { get } from 'svelte/store'
import { api } from 'gateway/lib/api'
@ -57,6 +57,14 @@ const routes = {
asyncComponent: () => import('./OutOfBandAuth.svelte'),
conditions: [requireLogin],
}),
'/profile': wrap({
asyncComponent: () => import('./Profile.svelte'),
conditions: [requireLogin],
}),
'/profile/tokens': wrap({
asyncComponent: () => import('./Tokens.svelte'),
conditions: [requireLogin],
}),
}
init()
@ -77,12 +85,12 @@ init()
</a>
{#if $serverInfo?.username}
<div class="ms-auto">
<a use:link href="/profile" class="ms-auto">
{$serverInfo.username}
{#if $serverInfo.authorizedViaTicket}
<span class="ml-2">(ticket auth)</span>
{/if}
</div>
</a>
<button class="btn btn-link" on:click={logout} title="Log out">
<Fa icon={faSignOut} fw />
</button>

View file

@ -0,0 +1,12 @@
<script lang="ts">
import { link } from 'svelte-spa-router'
import { serverInfo } from './lib/store'
</script>
<div class="page-summary-bar">
<h1>{$serverInfo?.username ?? 'Profile'}</h1>
</div>
<p><a use:link href="/profile/tokens">Manage API tokens</a></p>
<p><a href="/@warpgate/api/swagger">Admin API playground</a></p>
<p><a href="/@warpgate/admin/api/swagger">User API playground</a></p>

View file

@ -0,0 +1,128 @@
<script lang="ts">
import { api, Token } from 'gateway/lib/api'
import { Alert, Button, FormGroup, Modal, ModalBody, ModalFooter, ModalHeader } from 'sveltestrap'
import RelativeDate from 'common/RelativeDate.svelte'
import CopyButton from 'common/CopyButton.svelte'
let error: Error|undefined
let tokens: Token[]|undefined
let newSecret: string|undefined
let isCreateModalOpen = false
let newTokenName = ''
async function load () {
tokens = await api.getTokens()
}
function showCreateTokenModal () {
isCreateModalOpen = true
newTokenName = ''
}
async function createNewToken () {
const response = await api.createToken({ createTokenRequest: { name: newTokenName } })
tokens = [...tokens!, response.token]
newSecret = response.secret
isCreateModalOpen = false
}
load().catch(e => {
error = e
})
async function deleteToken (token: Token) {
newSecret = undefined
await api.deleteToken(token)
load()
}
</script>
{#if error}
<Alert color="danger">{error}</Alert>
{/if}
{#if tokens}
<div class="page-summary-bar">
<h1>API tokens</h1>
<button
class="btn btn-outline-secondary ms-auto"
on:click={showCreateTokenModal}>
Create
</button>
</div>
{#if newSecret}
<FormGroup floating label="New token" class="d-flex align-items-center">
<input type="text" class="form-control" readonly value={newSecret} />
<CopyButton text={newSecret} />
</FormGroup>
<Alert color="warning" fade={false}>
The API token is only shown once - you won't be able to see it again.
</Alert>
{/if}
{#if tokens.length }
<div class="list-group list-group-flush">
{#each tokens as token}
<div class="list-group-item">
<strong class="me-auto">
{token.name}
</strong>
<small class="text-muted me-4">
<RelativeDate date={token.created} />
</small>
<a href={''} on:click|preventDefault={() => deleteToken(token)}>Delete</a>
</div>
{/each}
</div>
{:else}
<Alert color="info" fade={false}>
Tokens grant access to the Warpgate API.
</Alert>
{/if}
{/if}
<Modal bind:isOpen={isCreateModalOpen}>
<ModalHeader toggle={() => isCreateModalOpen = false}>
Create API token
</ModalHeader>
<ModalBody>
<form on:submit|preventDefault={createNewToken}>
<FormGroup floating label="Name" class="d-flex align-items-center">
<!-- svelte-ignore a11y-autofocus -->
<input
type="text"
class="form-control"
bind:value={newTokenName}
autofocus
/>
</FormGroup>
</form>
</ModalBody>
<ModalFooter>
<div class="d-flex">
<Button
class="ms-auto"
outline
disabled={!newTokenName}
on:click={createNewToken}
>Save</Button>
<Button
class="ms-2"
outline
color="danger"
on:click={() => isCreateModalOpen = false}
>Cancel</Button>
</div>
</ModalFooter>
</Modal>
<style lang="scss">
.list-group-item {
display: flex;
align-items: center;
}
</style>

View file

@ -68,7 +68,7 @@
}
}
},
"operationId": "otpLogin"
"operationId": "otp_login"
}
},
"/auth/logout": {
@ -98,7 +98,7 @@
"description": ""
}
},
"operationId": "getDefaultAuthState"
"operationId": "get_default_auth_state"
},
"delete": {
"responses": {
@ -116,7 +116,7 @@
"description": ""
}
},
"operationId": "cancelDefaultAuth"
"operationId": "cancel_default_auth"
}
},
"/auth/state/{id}": {
@ -295,6 +295,21 @@
}
},
"operationId": "return_to_sso"
},
"post": {
"responses": {
"200": {
"description": "",
"content": {
"text/html": {
"schema": {
"type": "string"
}
}
}
}
},
"operationId": "return_to_sso_with_form_data"
}
},
"/sso/providers/{name}/start": {
@ -338,6 +353,92 @@
},
"operationId": "start_sso"
}
},
"/tokens": {
"get": {
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Token"
}
}
}
}
}
},
"operationId": "get_tokens"
},
"post": {
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateTokenRequest"
}
}
},
"required": true
},
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TokenAndSecret"
}
}
}
}
},
"operationId": "create_token"
}
},
"/tokens/{id}": {
"delete": {
"parameters": [
{
"name": "id",
"schema": {
"type": "string",
"format": "uuid"
},
"in": "path",
"required": true,
"deprecated": false,
"explode": true
}
],
"responses": {
"204": {
"description": ""
},
"404": {
"description": ""
}
},
"operationId": "delete_token"
}
},
"/__": {
"get": {
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"TokenAuth": []
}
],
"operationId": "_ignore_me"
}
}
},
"components": {
@ -370,6 +471,17 @@
}
}
},
"CreateTokenRequest": {
"type": "object",
"required": [
"name"
],
"properties": {
"name": {
"type": "string"
}
}
},
"Info": {
"type": "object",
"required": [
@ -517,6 +629,57 @@
"type": "string"
}
}
},
"Token": {
"type": "object",
"required": [
"id",
"name",
"user_id",
"created"
],
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string"
},
"user_id": {
"type": "string",
"format": "uuid"
},
"expiry": {
"type": "string",
"format": "date-time"
},
"created": {
"type": "string",
"format": "date-time"
}
}
},
"TokenAndSecret": {
"type": "object",
"required": [
"token",
"secret"
],
"properties": {
"token": {
"$ref": "#/components/schemas/Token"
},
"secret": {
"type": "string"
}
}
}
},
"securitySchemes": {
"TokenAuth": {
"type": "http",
"scheme": "bearer"
}
}
}