API tokens (#1191)

This commit is contained in:
Eugene 2024-12-24 23:33:49 +01:00 committed by GitHub
parent 42301623e8
commit 010534a12f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 859 additions and 95 deletions

View file

@ -7,7 +7,7 @@ use warpgate_core::Services;
pub fn admin_api_app(services: &Services) -> impl IntoEndpoint {
let api_service = OpenApiService::new(
crate::api::get(),
"Warpgate Web Admin",
"Warpgate admin API",
env!("CARGO_PKG_VERSION"),
)
.server("/@warpgate/admin/api");

View file

@ -431,9 +431,30 @@ impl ConfigProvider for DatabaseConfigProvider {
active_model.update(&*db).await.map_err(|e| {
error!("Failed to update last_used for public key: {:?}", e);
WarpgateError::DatabaseError(e.into())
WarpgateError::DatabaseError(e)
})?;
Ok(())
}
async fn validate_api_token(&mut self, token: &str) -> Result<Option<User>, WarpgateError> {
let db = self.db.lock().await;
let Some(ticket) = entities::ApiToken::Entity::find()
.filter(entities::ApiToken::Column::Secret.eq(token))
.one(&*db)
.await?
else {
return Ok(None);
};
let Some(user) = ticket
.find_related(entities::User::Entity)
.one(&*db)
.await?
else {
return Err(WarpgateError::InconsistentState);
};
Ok(Some(user.try_into()?))
}
}

View file

@ -52,6 +52,8 @@ pub trait ConfigProvider {
&self,
credential: Option<AuthCredential>,
) -> Result<(), WarpgateError>;
async fn validate_api_token(&mut self, token: &str) -> Result<Option<User>, WarpgateError>;
}
//TODO: move this somewhere

View file

@ -0,0 +1,42 @@
use chrono::{DateTime, Utc};
use sea_orm::entity::prelude::*;
use sea_orm::sea_query::ForeignKeyAction;
use serde::Serialize;
use uuid::Uuid;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize)]
#[sea_orm(table_name = "api_tokens")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub user_id: Uuid,
pub label: String,
pub secret: String,
pub created: DateTime<Utc>,
pub expiry: DateTime<Utc>,
}
#[derive(Copy, Clone, Debug, EnumIter)]
pub enum Relation {
User,
}
impl RelationTrait for Relation {
fn def(&self) -> RelationDef {
match self {
Self::User => Entity::belongs_to(super::User::Entity)
.from(Column::UserId)
.to(super::User::Column::Id)
.on_delete(ForeignKeyAction::Cascade)
.into(),
}
}
}
impl Related<super::User::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -51,6 +51,12 @@ impl Related<super::SsoCredential::Entity> for Entity {
}
}
impl Related<super::ApiToken::Entity> for Entity {
fn to() -> RelationDef {
Relation::ApiTokens.def()
}
}
#[derive(Copy, Clone, Debug, EnumIter)]
#[allow(clippy::enum_variant_names)]
pub enum Relation {
@ -58,6 +64,7 @@ pub enum Relation {
PasswordCredentials,
PublicKeyCredentials,
SsoCredentials,
ApiTokens,
}
impl RelationTrait for Relation {
@ -79,6 +86,10 @@ impl RelationTrait for Relation {
.from(Column::Id)
.to(super::SsoCredential::Column::UserId)
.into(),
Self::ApiTokens => Entity::has_many(super::ApiToken::Entity)
.from(Column::Id)
.to(super::ApiToken::Column::UserId)
.into(),
}
}
}

View file

@ -1,5 +1,6 @@
#![allow(non_snake_case)]
pub mod ApiToken;
pub mod KnownHost;
pub mod LogEntry;
pub mod OtpCredential;

View file

@ -15,6 +15,7 @@ mod m00010_parameters;
mod m00011_rsa_key_algos;
mod m00012_add_openssh_public_key_label;
mod m00013_add_openssh_public_key_dates;
mod m00014_api_tokens;
pub struct Migrator;
@ -35,6 +36,7 @@ impl MigratorTrait for Migrator {
Box::new(m00011_rsa_key_algos::Migration),
Box::new(m00012_add_openssh_public_key_label::Migration),
Box::new(m00013_add_openssh_public_key_dates::Migration),
Box::new(m00014_api_tokens::Migration),
]
}
}

View file

@ -0,0 +1,76 @@
use sea_orm::Schema;
use sea_orm_migration::prelude::*;
use super::m00008_users::user as User;
pub mod api_tokens {
use chrono::{DateTime, Utc};
use sea_orm::entity::prelude::*;
use sea_orm::sea_query::ForeignKeyAction;
use serde::Serialize;
use uuid::Uuid;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize)]
#[sea_orm(table_name = "api_tokens")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub user_id: Uuid,
pub label: String,
pub secret: String,
pub created: DateTime<Utc>,
pub expiry: DateTime<Utc>,
}
#[derive(Copy, Clone, Debug, EnumIter)]
pub enum Relation {
User,
}
impl RelationTrait for Relation {
fn def(&self) -> RelationDef {
match self {
Self::User => Entity::belongs_to(super::User::Entity)
.from(Column::UserId)
.to(super::User::Column::Id)
.on_delete(ForeignKeyAction::Cascade)
.into(),
}
}
}
impl Related<super::User::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
}
pub struct Migration;
impl MigrationName for Migration {
fn name(&self) -> &str {
"m00014_api_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(api_tokens::Entity))
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(api_tokens::Entity).to_owned())
.await?;
Ok(())
}
}

View file

@ -0,0 +1,168 @@
use chrono::{DateTime, Utc};
use poem::web::Data;
use poem_openapi::param::Path;
use poem_openapi::payload::Json;
use poem_openapi::{ApiResponse, Object, OpenApi};
use sea_orm::{ActiveModelTrait, ColumnTrait, ModelTrait, QueryFilter, Set};
use uuid::Uuid;
use warpgate_common::helpers::hash::generate_ticket_secret;
use warpgate_common::WarpgateError;
use warpgate_core::Services;
use warpgate_db_entities::ApiToken;
use super::common::get_user;
use crate::common::{endpoint_auth, RequestAuthorization};
pub struct Api;
#[derive(ApiResponse)]
enum GetApiTokensResponse {
#[oai(status = 200)]
Ok(Json<Vec<ExistingApiToken>>),
#[oai(status = 401)]
Unauthorized,
}
#[derive(Object)]
struct NewApiToken {
label: String,
expiry: DateTime<Utc>,
}
#[derive(Object)]
struct ExistingApiToken {
id: Uuid,
label: String,
created: DateTime<Utc>,
expiry: DateTime<Utc>,
}
impl From<ApiToken::Model> for ExistingApiToken {
fn from(token: ApiToken::Model) -> Self {
Self {
id: token.id,
label: token.label,
created: token.created,
expiry: token.expiry,
}
}
}
#[derive(Object)]
struct TokenAndSecret {
token: ExistingApiToken,
secret: String,
}
#[derive(ApiResponse)]
enum CreateApiTokenResponse {
#[oai(status = 201)]
Created(Json<TokenAndSecret>),
#[oai(status = 401)]
Unauthorized,
}
#[derive(ApiResponse)]
enum DeleteApiTokenResponse {
#[oai(status = 204)]
Deleted,
#[oai(status = 401)]
Unauthorized,
#[oai(status = 404)]
NotFound,
}
#[OpenApi]
impl Api {
#[oai(
path = "/profile/api-tokens",
method = "get",
operation_id = "get_my_api_tokens",
transform = "endpoint_auth"
)]
async fn api_get_api_tokens(
&self,
auth: Data<&RequestAuthorization>,
services: Data<&Services>,
) -> Result<GetApiTokensResponse, WarpgateError> {
let db = services.db.lock().await;
let Some(user_model) = get_user(*auth, &*db).await? else {
return Ok(GetApiTokensResponse::Unauthorized);
};
let api_tokens = user_model.find_related(ApiToken::Entity).all(&*db).await?;
Ok(GetApiTokensResponse::Ok(Json(
api_tokens.into_iter().map(Into::into).collect(),
)))
}
#[oai(
path = "/profile/api-tokens",
method = "post",
operation_id = "create_api_token",
transform = "endpoint_auth"
)]
async fn api_create_api_token(
&self,
auth: Data<&RequestAuthorization>,
services: Data<&Services>,
body: Json<NewApiToken>,
) -> Result<CreateApiTokenResponse, WarpgateError> {
let db = services.db.lock().await;
let Some(user_model) = get_user(&*auth, &*db).await? else {
return Ok(CreateApiTokenResponse::Unauthorized);
};
let secret = generate_ticket_secret();
let object = ApiToken::ActiveModel {
id: Set(Uuid::new_v4()),
user_id: Set(user_model.id),
created: Set(Utc::now()),
expiry: Set(body.expiry),
label: Set(body.label.clone()),
secret: Set(secret.expose_secret().to_string()),
}
.insert(&*db)
.await
.map_err(WarpgateError::from)?;
Ok(CreateApiTokenResponse::Created(Json(TokenAndSecret {
token: object.into(),
secret: secret.expose_secret().to_string(),
})))
}
#[oai(
path = "/profile/api-tokens/:id",
method = "delete",
operation_id = "delete_my_api_token",
transform = "endpoint_auth"
)]
async fn api_delete_api_token(
&self,
auth: Data<&RequestAuthorization>,
services: Data<&Services>,
id: Path<Uuid>,
) -> Result<DeleteApiTokenResponse, WarpgateError> {
let db = services.db.lock().await;
let Some(user_model) = get_user(&*auth, &*db).await? else {
return Ok(DeleteApiTokenResponse::Unauthorized);
};
let Some(model) = user_model
.find_related(ApiToken::Entity)
.filter(ApiToken::Column::Id.eq(id.0))
.one(&*db)
.await?
else {
return Ok(DeleteApiTokenResponse::NotFound);
};
model.delete(&*db).await?;
Ok(DeleteApiTokenResponse::Deleted)
}
}

View file

@ -1,6 +1,10 @@
use poem::session::Session;
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};
use tracing::info;
use warpgate_common::WarpgateError;
use warpgate_db_entities as entities;
use crate::common::RequestAuthorization;
use crate::session::SessionStore;
pub fn logout(session: &Session, session_middleware: &mut SessionStore) {
@ -8,3 +12,22 @@ pub fn logout(session: &Session, session_middleware: &mut SessionStore) {
session.clear();
info!("Logged out");
}
pub async fn get_user(
auth: &RequestAuthorization,
db: &DatabaseConnection,
) -> Result<Option<entities::User::Model>, WarpgateError> {
let Some(username) = auth.username() else {
return Ok(None);
};
let Some(user_model) = entities::User::Entity::find()
.filter(entities::User::Column::Username.eq(username))
.one(db)
.await?
else {
return Ok(None);
};
Ok(Some(user_model))
}

View file

@ -5,14 +5,13 @@ use poem::{Endpoint, EndpointExt, FromRequest, IntoResponse};
use poem_openapi::param::Path;
use poem_openapi::payload::Json;
use poem_openapi::{ApiResponse, Enum, Object, OpenApi};
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, ModelTrait, QueryFilter, Set,
};
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, QueryFilter, Set};
use uuid::Uuid;
use warpgate_common::{User, UserPasswordCredential, UserRequireCredentialsPolicy, WarpgateError};
use warpgate_core::Services;
use warpgate_db_entities::{self as entities, Parameters, PasswordCredential, PublicKeyCredential};
use super::common::get_user;
use crate::common::{endpoint_auth, RequestAuthorization};
pub struct Api;
@ -168,25 +167,6 @@ pub fn parameters_based_auth<E: Endpoint + 'static>(e: E) -> impl Endpoint {
})
}
async fn get_user(
auth: &RequestAuthorization,
db: &DatabaseConnection,
) -> Result<Option<entities::User::Model>, WarpgateError> {
let Some(username) = auth.username() else {
return Ok(None);
};
let Some(user_model) = entities::User::Entity::find()
.filter(entities::User::Column::Username.eq(username))
.one(db)
.await?
else {
return Ok(None);
};
Ok(Some(user_model))
}
#[OpenApi]
impl Api {
#[oai(

View file

@ -1,6 +1,7 @@
use poem_openapi::auth::ApiKey;
use poem_openapi::{OpenApi, SecurityScheme};
mod api_tokens;
pub mod auth;
mod common;
mod credentials;
@ -33,5 +34,6 @@ pub fn get() -> impl OpenApi {
sso_provider_list::Api,
sso_provider_detail::Api,
credentials::Api,
api_tokens::Api,
)
}

View file

@ -106,7 +106,9 @@ async fn get_target_for_request(
});
username
}
RequestAuthorization::AdminToken => return Ok(None),
RequestAuthorization::UserToken { .. } | RequestAuthorization::AdminToken => {
return Ok(None)
}
};
if let Some(target_name) = selected_target_name {

View file

@ -115,6 +115,7 @@ impl SessionAuthorization {
#[derive(Clone, Serialize, Deserialize)]
pub enum RequestAuthorization {
Session(SessionAuthorization),
UserToken { username: String },
AdminToken,
}
@ -122,6 +123,7 @@ impl RequestAuthorization {
pub fn username(&self) -> Option<&String> {
match self {
Self::Session(auth) => Some(auth.username()),
Self::UserToken { username } => Some(username),
Self::AdminToken => None,
}
}
@ -133,6 +135,7 @@ async fn is_user_admin(req: &Request, auth: &RequestAuthorization) -> poem::Resu
let username = match auth {
RequestAuthorization::Session(SessionAuthorization::User(username)) => username,
RequestAuthorization::Session(SessionAuthorization::Ticket { .. }) => return Ok(false),
RequestAuthorization::UserToken { username } => username,
RequestAuthorization::AdminToken => return Ok(true),
};
@ -184,13 +187,21 @@ pub async fn _inner_auth<E: Endpoint + 'static>(
Some(auth) => RequestAuthorization::Session(auth),
None => match req.headers().get(&X_WARPGATE_TOKEN) {
Some(token_from_header) => {
if Some(
token_from_header
.to_str()
.map_err(poem::error::BadRequest)?,
) == services.admin_token.lock().await.as_deref()
{
let token_from_header = token_from_header
.to_str()
.map_err(poem::error::BadRequest)?;
if Some(token_from_header) == services.admin_token.lock().await.as_deref() {
RequestAuthorization::AdminToken
} else if let Some(user) = services
.config_provider
.lock()
.await
.validate_api_token(token_from_header)
.await?
{
RequestAuthorization::UserToken {
username: user.username,
}
} else {
return Ok(None);
}

View file

@ -63,7 +63,7 @@ impl ProtocolServer for HTTPProtocolServer {
let admin_api_app = admin_api_app(&self.services).into_endpoint();
let api_service = OpenApiService::new(
crate::api::get(),
"Warpgate HTTP proxy",
"Warpgate user API",
env!("CARGO_PKG_VERSION"),
)
.server("/@warpgate/api");

View file

@ -89,7 +89,7 @@
</div>
{/snippet}
{#snippet item({ item: session })}
{#snippet item(session)}
<a
class="list-group-item list-group-item-action"

View file

@ -15,8 +15,7 @@
}
</script>
<div class="page-summary-bar mt-4">
<div class="page-summary-bar">
<h1>roles</h1>
<a
class="btn btn-primary ms-auto"
@ -27,7 +26,7 @@
</div>
<ItemList load={getRoles} showSearch={true}>
{#snippet item({ item: role })}
{#snippet item(role)}
<a
class="list-group-item list-group-item-action"
href="/roles/{role.id}"

View file

@ -4,6 +4,7 @@
import ItemList, { type LoadOptions, type PaginatedResponse } from 'common/ItemList.svelte'
import { link } from 'svelte-spa-router'
import { TargetKind } from 'gateway/lib/api'
import EmptyState from 'common/EmptyState.svelte'
function getTargets (options: LoadOptions): Observable<PaginatedResponse<Target>> {
return from(api.getTargets({
@ -27,7 +28,13 @@
</div>
<ItemList load={getTargets} showSearch={true}>
{#snippet item({ item: target })}
{#snippet empty()}
<EmptyState
title="No targets yet"
hint="Targets are destinations on the internal network that your users will connect to"
/>
{/snippet}
{#snippet item(target)}
<a
class="list-group-item list-group-item-action"
class:disabled={target.options.kind === TargetKind.WebAdmin}

View file

@ -1,40 +1,40 @@
<script lang="ts">
import { api, type Ticket } from 'admin/lib/api'
import { link } from 'svelte-spa-router'
import RelativeDate from '../RelativeDate.svelte'
import Fa from 'svelte-fa'
import { faCalendarXmark, faCalendarCheck, faSquareXmark, faSquareCheck } from '@fortawesome/free-solid-svg-icons'
import { stringifyError } from 'common/errors'
import Alert from 'common/sveltestrap-s5-ports/Alert.svelte'
import { api, type Ticket } from 'admin/lib/api'
import { link } from 'svelte-spa-router'
import RelativeDate from '../RelativeDate.svelte'
import Fa from 'svelte-fa'
import { faCalendarXmark, faCalendarCheck, faSquareXmark, faSquareCheck } from '@fortawesome/free-solid-svg-icons'
import { stringifyError } from 'common/errors'
import Alert from 'common/sveltestrap-s5-ports/Alert.svelte'
import EmptyState from 'common/EmptyState.svelte'
let error: string|undefined = $state()
let tickets: Ticket[]|undefined = $state()
let error: string|undefined = $state()
let tickets: Ticket[]|undefined = $state()
async function load () {
tickets = await api.getTickets()
}
async function load () {
tickets = await api.getTickets()
}
load().catch(async e => {
error = await stringifyError(e)
})
async function deleteTicket (ticket: Ticket) {
await api.deleteTicket(ticket)
load()
}
load().catch(async e => {
error = await stringifyError(e)
})
async function deleteTicket (ticket: Ticket) {
await api.deleteTicket(ticket)
load()
}
</script>
{#if error}
<Alert color="danger">{error}</Alert>
{/if}
{#if tickets }
{#if tickets}
<div class="page-summary-bar">
{#if tickets.length }
<h1>access tickets: <span class="counter">{tickets.length}</span></h1>
{:else}
<h1>No tickets created yet</h1>
<h1>access tickets</h1>
{/if}
<a
class="btn btn-primary ms-auto"
@ -44,7 +44,7 @@ async function deleteTicket (ticket: Ticket) {
</a>
</div>
{#if tickets.length }
{#if tickets.length}
<div class="list-group list-group-flush">
{#each tickets as ticket}
<div class="list-group-item">
@ -79,9 +79,10 @@ async function deleteTicket (ticket: Ticket) {
{/each}
</div>
{:else}
<Alert color="info" fade={false}>
Tickets are secret keys that allow access to one specific target without any additional authentication.
</Alert>
<EmptyState
title="No tickets yet"
hint="Tickets are secret keys that allow access to one specific target without any additional authentication"
/>
{/if}
{/if}

View file

@ -26,7 +26,7 @@
</div>
<ItemList load={getUsers} showSearch={true}>
{#snippet item({ item: user })}
{#snippet item(user)}
<a
class="list-group-item list-group-item-action"
href="/users/{user.id}"

View file

@ -0,0 +1,33 @@
<script lang="ts">
export let title: string
export let hint = ''
</script>
<div class="empty-state">
<h2>{title}</h2>
{#if hint}
<p class="text-muted">{hint}</p>
{/if}
</div>
<style lang="scss">
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 50%;
min-width: 200px;
text-align: center;
margin: 4rem auto;
opacity: .5;
}
h2 {
font-family: 'Poppins';
}
p {
font-size: .9rem;
}
</style>

View file

@ -18,16 +18,18 @@
import { observe } from 'svelte-observable'
import { Input } from '@sveltestrap/sveltestrap'
import DelayedSpinner from './DelayedSpinner.svelte'
import { onDestroy } from 'svelte'
import { onDestroy, type Snippet } from 'svelte'
import EmptyState from './EmptyState.svelte'
interface Props {
page?: number
pageSize?: number|undefined
load: (_: LoadOptions) => Observable<PaginatedResponse<T>>
showSearch?: boolean
header?: import('svelte').Snippet<[any]>
item?: import('svelte').Snippet<[any]>
footer?: import('svelte').Snippet<[any]>
header?: Snippet<[]>
item?: Snippet<[T]>
footer?: Snippet<[T[]]>
empty?: Snippet<[]>
}
let {
@ -38,6 +40,7 @@
header,
item,
footer,
empty,
}: Props = $props()
let filter = $state('')
@ -95,7 +98,7 @@
{#if showSearch}
<Input bind:value={filter} placeholder="Search..." class="flex-grow-1 border-0" />
{/if}
{@render header?.({ items })}
{@render header?.()}
</div>
{#await $items}
<DelayedSpinner />
@ -103,18 +106,20 @@
{#if _items}
<div class="list-group list-group-flush mb-3">
{#each _items as _item}
{@render item?.({ item: _item })}
{@render item?.(_item)}
{/each}
</div>
{@render footer?.({ items: _items })}
{@render footer?.(_items)}
{:else}
<DelayedSpinner />
{/if}
{#if filter && loaded && !_items?.length}
<em>
Nothing found
</em>
{#if loaded && !_items?.length}
{#if filter}
<EmptyState title="Nothing found" />
{:else}
{@render empty?.()}
{/if}
{/if}
{/await}

View file

@ -4,13 +4,13 @@
import DelayedSpinner from './DelayedSpinner.svelte'
import { stringifyError } from './errors'
let { promise, children }: {
let { promise, children, data = $bindable() }: {
promise: Promise<T>
data?: T,
children: Snippet<[T]>
} = $props()
let loaded = $state(false)
let data: T | undefined = $state()
let error: string | undefined = $state()
$effect(() => {

View file

@ -0,0 +1,88 @@
<script lang="ts">
import { api, type ExistingApiToken } from 'gateway/lib/api'
import Loadable from 'common/Loadable.svelte'
import { faKey } from '@fortawesome/free-solid-svg-icons'
import Fa from 'svelte-fa'
import CreateApiTokenModal from './CreateApiTokenModal.svelte'
import Alert from 'common/sveltestrap-s5-ports/Alert.svelte'
import CopyButton from 'common/CopyButton.svelte'
import Badge from 'common/sveltestrap-s5-ports/Badge.svelte'
import EmptyState from 'common/EmptyState.svelte'
let tokens: ExistingApiToken[] = $state([])
let creatingToken = $state(false)
let lastCreatedSecret: string | undefined = $state()
const now = Date.now()
async function deleteToken (token: ExistingApiToken) {
tokens = tokens.filter(c => c.id !== token.id)
await api.deleteMyApiToken(token)
lastCreatedSecret = undefined
}
async function createToken (label: string, expiry: Date) {
const { secret, token } = await api.createApiToken({ newApiToken : { label, expiry } })
lastCreatedSecret = secret
tokens = [...tokens, token]
}
</script>
<div class="page-summary-bar mt-4">
<h1>API tokens</h1>
<a href={''} class="btn btn-primary ms-auto" onclick={e => {
creatingToken = true
e.preventDefault()
}}>Create token</a>
</div>
{#if lastCreatedSecret}
<Alert color="info">
<div>Your token - shown only once:</div>
<div class="d-flex align-items-center mt-2">
<code style="min-width: 0">{lastCreatedSecret}</code>
<CopyButton class="ms-auto" text={lastCreatedSecret} />
</div>
</Alert>
{/if}
<Loadable promise={api.getMyApiTokens()} bind:data={tokens}>
{#if tokens.length === 0}
<EmptyState
title="No tokens yet"
hint="Tokens let you manage Warpgate programmatically via its API"
/>
{/if}
<div class="list-group list-group-flush mb-3">
{#each tokens as token}
<div class="list-group-item d-flex align-items-center">
<Fa fw icon={faKey} />
<span class="label ms-3">{token.label}</span>
{#if token.expiry.getTime() < now}
<Badge color="danger" class="ms-2">Expired</Badge>
{:else}
<Badge color="success" class="ms-2">{token.expiry.toLocaleDateString()}</Badge>
{/if}
<span class="ms-auto"></span>
<a
color="link"
href={''}
class="ms-2"
onclick={e => {
deleteToken(token)
e.preventDefault()
}}
>
Delete
</a>
</div>
{/each}
</div>
</Loadable>
{#if creatingToken}
<CreateApiTokenModal
bind:isOpen={creatingToken}
create={createToken}
/>
{/if}

View file

@ -46,6 +46,14 @@
asyncComponent: () => import('./Profile.svelte') as any,
conditions: [requireLogin],
}),
'/profile/api-tokens': wrap({
asyncComponent: () => import('./ProfileApiTokens.svelte') as any,
conditions: [requireLogin],
}),
'/profile/credentials': wrap({
asyncComponent: () => import('./ProfileCredentials.svelte') as any,
conditions: [requireLogin],
}),
'/login': wrap({
asyncComponent: () => import('./Login.svelte') as any,
}),

View file

@ -0,0 +1,77 @@
<script lang="ts">
import {
Button,
Form,
FormGroup,
Input,
Modal,
ModalBody,
ModalFooter,
} from '@sveltestrap/sveltestrap'
import ModalHeader from 'common/sveltestrap-s5-ports/ModalHeader.svelte'
interface Props {
isOpen: boolean
create: (label: string, expiry: Date) => void
}
let {
isOpen = $bindable(true),
create,
}: Props = $props()
let label = $state('')
let expiry = $state(new Date(Date.now() + 1000 * 60 * 60 * 24 * 7).toISOString())
let field: HTMLInputElement|undefined = $state()
let validated = $state(false)
function _save () {
create(label, new Date(expiry))
_cancel()
}
function _cancel () {
isOpen = false
label = ''
}
</script>
<Modal toggle={_cancel} isOpen={isOpen} on:open={() => field?.focus()}>
<Form {validated} on:submit={e => {
_save()
e.preventDefault()
}}>
<ModalHeader toggle={_cancel}>
New API token
</ModalHeader>
<ModalBody>
<FormGroup floating label="Descriptive label">
<Input
bind:inner={field}
required
bind:value={label} />
</FormGroup>
<FormGroup floating label="Expiry">
<Input
type="datetime-local"
bind:value={expiry} />
</FormGroup>
</ModalBody>
<ModalFooter>
<div class="d-flex">
<Button
class="ms-auto"
on:click={() => validated = true}
>Create</Button>
<Button
class="ms-2"
color="danger"
on:click={_cancel}
>Cancel</Button>
</div>
</ModalFooter>
</Form>
</Modal>

View file

@ -66,15 +66,15 @@
<div class="list-group list-group-flush mb-3">
<div class="list-group-item credential">
{#if creds.password === PasswordState.Unset}
<span class="label">Your account has no password set</span>
<span class="label ms-3">Your account has no password set</span>
{/if}
{#if creds.password === PasswordState.Set}
<Fa fw icon={faKeyboard} />
<span class="label">Password set</span>
<span class="label ms-3">Password set</span>
{/if}
{#if creds.password === PasswordState.MultipleSet}
<Fa fw icon={faKeyboard} />
<span class="label">Multiple passwords set</span>
<span class="label ms-3">Multiple passwords set</span>
{/if}
<span class="ms-auto"></span>
@ -116,7 +116,7 @@
{#each creds.otp as credential}
<div class="list-group-item credential">
<Fa fw icon={faMobilePhone} />
<span class="label">OTP device</span>
<span class="label ms-3">OTP device</span>
<span class="ms-auto"></span>
<a
class="ms-2"
@ -150,7 +150,7 @@
{#each creds.publicKeys as credential}
<div class="list-group-item credential">
<Fa fw icon={faKey} />
<div class="main">
<div class="main ms-3">
<div class="label">{credential.label}</div>
<small class="d-block text-muted">{credential.abbreviated}</small>
</div>
@ -185,7 +185,7 @@
{#each creds.sso as credential}
<div class="list-group-item credential">
<Fa fw icon={faIdBadge} />
<span class="label">
<span class="label ms-3">
{credential.email}
{#if credential.provider} ({credential.provider}){/if}
</span>
@ -222,9 +222,5 @@
.credential {
display: flex;
align-items: center;
.label:not(:first-child), .main {
margin-left: .75rem;
}
}
</style>

View file

@ -1,19 +1,24 @@
<script lang="ts">
import { serverInfo } from 'gateway/lib/store'
import Alert from 'common/sveltestrap-s5-ports/Alert.svelte'
import CredentialManager from './CredentialManager.svelte'
import NavListItem from 'common/NavListItem.svelte'
</script>
<div class="page-summary-bar">
<h1>{$serverInfo!.username}</h1>
</div>
<NavListItem
title="API tokens"
description="Manage your API tokens"
href="/profile/api-tokens"
/>
{#if $serverInfo}
{#if $serverInfo.ownCredentialManagementAllowed}
<CredentialManager />
{:else}
<Alert color="info">
Credential management is disabled by your administrator
</Alert>
<NavListItem
title="Credentials"
description="Manage your passwords and keys"
href="/profile/credentials"
/>
{/if}
{/if}

View file

@ -0,0 +1,48 @@
<script lang="ts">
import { faFileContract, faFlaskVial } from '@fortawesome/free-solid-svg-icons'
import Fa from 'svelte-fa'
import ApiTokenManager from './ApiTokenManager.svelte'
</script>
<!-- <div class="page-summary-bar">
<h1>API tokens</h1>
</div> -->
<ApiTokenManager />
<div class="row">
<div class="col">
<h4>User API</h4>
<a class="link" target="_blank" href="/@warpgate/api/swagger">
<Fa icon={faFlaskVial} fw />
<span>Playground</span>
</a>
<a class="link" target="_blank" href="/@warpgate/api/openapi.json">
<Fa icon={faFileContract} fw />
<span>Schema</span>
</a>
</div>
<div class="col">
<h4>Admin API</h4>
<a class="link" target="_blank" href="/@warpgate/admin/api/swagger">
<Fa icon={faFlaskVial} fw />
<span>Playground</span>
</a>
<a class="link" target="_blank" href="/@warpgate/admin/api/openapi.json">
<Fa icon={faFileContract} fw />
<span>Schema</span>
</a>
</div>
</div>
<style lang="scss">
.link {
display: flex;
align-items: center;
span {
margin-left: 0.25rem;
}
}
</style>

View file

@ -0,0 +1,19 @@
<script lang="ts">
import { serverInfo } from 'gateway/lib/store'
import Alert from 'common/sveltestrap-s5-ports/Alert.svelte'
import CredentialManager from './CredentialManager.svelte'
</script>
<div class="page-summary-bar">
<h1>Credentials</h1>
</div>
{#if $serverInfo}
{#if $serverInfo.ownCredentialManagementAllowed}
<CredentialManager />
{:else}
<Alert color="info">
Credential management is disabled by your administrator
</Alert>
{/if}
{/if}

View file

@ -45,7 +45,7 @@ function loadURL (url: string) {
</script>
<ItemList load={loadTargets} showSearch={true}>
{#snippet item({ item: target })}
{#snippet item(target)}
<a
class="list-group-item list-group-item-action target-item"
href={

View file

@ -571,6 +571,86 @@
},
"operationId": "delete_my_otp"
}
},
"/profile/api-tokens": {
"get": {
"responses": {
"200": {
"description": "",
"content": {
"application/json; charset=utf-8": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ExistingApiToken"
}
}
}
}
},
"401": {
"description": ""
}
},
"operationId": "get_my_api_tokens"
},
"post": {
"requestBody": {
"content": {
"application/json; charset=utf-8": {
"schema": {
"$ref": "#/components/schemas/NewApiToken"
}
}
},
"required": true
},
"responses": {
"201": {
"description": "",
"content": {
"application/json; charset=utf-8": {
"schema": {
"$ref": "#/components/schemas/TokenAndSecret"
}
}
}
},
"401": {
"description": ""
}
},
"operationId": "create_api_token"
}
},
"/profile/api-tokens/{id}": {
"delete": {
"parameters": [
{
"name": "id",
"schema": {
"type": "string",
"format": "uuid"
},
"in": "path",
"required": true,
"deprecated": false,
"explode": true
}
],
"responses": {
"204": {
"description": ""
},
"401": {
"description": ""
},
"404": {
"description": ""
}
},
"operationId": "delete_my_api_token"
}
}
},
"components": {
@ -672,6 +752,32 @@
}
}
},
"ExistingApiToken": {
"type": "object",
"required": [
"id",
"label",
"created",
"expiry"
],
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"label": {
"type": "string"
},
"created": {
"type": "string",
"format": "date-time"
},
"expiry": {
"type": "string",
"format": "date-time"
}
}
},
"ExistingOtpCredential": {
"type": "object",
"required": [
@ -793,6 +899,22 @@
}
}
},
"NewApiToken": {
"type": "object",
"required": [
"label",
"expiry"
],
"properties": {
"label": {
"type": "string"
},
"expiry": {
"type": "string",
"format": "date-time"
}
}
},
"NewOtpCredential": {
"type": "object",
"required": [
@ -941,6 +1063,21 @@
}
}
},
"TokenAndSecret": {
"type": "object",
"required": [
"token",
"secret"
],
"properties": {
"token": {
"$ref": "#/components/schemas/ExistingApiToken"
},
"secret": {
"type": "string"
}
}
},
"UserRequireCredentialsPolicy": {
"type": "object",
"properties": {