1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

migrate secrets to bootstrap 5 and deprecate jquery functions

This commit is contained in:
checktheroads
2021-04-17 17:18:13 -07:00
parent 726ab7fc05
commit eb951fdaf1
10 changed files with 294 additions and 86 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -11,12 +11,15 @@ type APIAnswer<T> = {
results: T[];
};
type APIError = {
type ErrorBase = {
error: string;
};
type APIError = {
exception: string;
netbox_version: string;
python_version: string;
};
} & ErrorBase;
type APIObjectBase = {
id: number;
@ -39,6 +42,23 @@ type APIReference = {
_depth: number;
};
type APISecret = {
assigned_object: APIObjectBase;
assigned_object_id: number;
assigned_object_type: string;
created: string;
custom_fields: Record<string, unknown>;
display: string;
hash: string;
id: number;
last_updated: string;
name: string;
plaintext: Nullable<string>;
role: APIObjectBase;
tags: number[];
url: string;
};
interface ObjectWithGroup extends APIObjectBase {
group: Nullable<APIReference>;
}

View File

@ -7,7 +7,7 @@ import { initSpeedSelector, initForms } from './forms';
import { initRackElevation } from './buttons';
import { initClipboard } from './clipboard';
import { initSearchBar } from './search';
// import { initGenerateKeyPair } from './secrets';
import { initGenerateKeyPair, initLockUnlock, initGetSessionKey } from './secrets';
import { getElements } from './util';
const INITIALIZERS = [
@ -21,7 +21,9 @@ const INITIALIZERS = [
initColorSelect,
initRackElevation,
initClipboard,
// initGenerateKeyPair,
initGenerateKeyPair,
initLockUnlock,
initGetSessionKey,
] as (() => void)[];
/**
@ -35,7 +37,6 @@ function initBootstrap(): void {
new Tooltip(tooltip, { container: 'body', boundary: 'window' });
}
for (const modal of getElements('[data-bs-toggle="modal"]')) {
// for (const modal of getElements('div.modal')) {
new Modal(modal);
}
initMessageToasts();

View File

@ -1,47 +1,50 @@
import { apiGetBase, getElements, isApiError } from './util';
import { Modal } from 'bootstrap';
import { apiGetBase, apiPostForm, getElements, isApiError, hasError } from './util';
import { createToast } from './toast';
/**
*
* $('#generate_keypair').click(function() {
$('#new_keypair_modal').modal('show');
$.ajax({
url: netbox_api_path + 'secrets/generate-rsa-key-pair/',
type: 'GET',
dataType: 'json',
success: function (response, status) {
var public_key = response.public_key;
var private_key = response.private_key;
$('#new_pubkey').val(public_key);
$('#new_privkey').val(private_key);
},
error: function (xhr, ajaxOptions, thrownError) {
alert("There was an error generating a new key pair.");
}
});
});
* Initialize Generate Private Key Pair Elements.
*/
export function initGenerateKeyPair() {
const element = document.getElementById('new_keypair_modal') as HTMLDivElement;
const accept = document.getElementById('use_new_pubkey') as HTMLButtonElement;
// If the elements are not loaded, stop.
if (element === null || accept === null) {
return;
}
const publicElem = element.querySelector<HTMLTextAreaElement>('textarea#new_pubkey');
const privateElem = element.querySelector<HTMLTextAreaElement>('textarea#new_privkey');
/**
* Handle Generate Private Key Pair Modal opening.
*/
function handleOpen() {
// When the modal opens, set the `readonly` attribute on the textarea elements.
for (const elem of [publicElem, privateElem]) {
if (elem !== null) {
elem.setAttribute('readonly', '');
}
}
// Fetch the key pair from the API.
apiGetBase<APIKeyPair>('/api/secrets/generate-rsa-key-pair').then(data => {
if (!isApiError(data)) {
if (!hasError(data)) {
// If key pair generation was successful, set the textarea elements' value to the generated
// values.
const { private_key: priv, public_key: pub } = data;
if (publicElem !== null && privateElem !== null) {
publicElem.value = pub;
privateElem.value = priv;
}
} else {
// Otherwise, show an error.
const toast = createToast('danger', 'Error', data.error);
toast.show();
}
});
}
/**
* Set the public key form field's value to the generated public key.
*/
function handleAccept() {
const publicKeyField = document.getElementById('id_public_key') as HTMLTextAreaElement;
if (publicElem !== null) {
@ -53,10 +56,147 @@ export function initGenerateKeyPair() {
accept.addEventListener('click', handleAccept);
}
export function initLockUnlock() {
for (const element of getElements<HTMLButtonElement>('button.unlock-secret')) {
function handleClick() {
const { secretId } = element.dataset;
}
/**
* Toggle copy/lock/unlock button visibility based on the action occurring.
* @param id Secret ID.
* @param action Lock or Unlock, so we know which buttons to display.
*/
function toggleSecretButtons(id: string, action: 'lock' | 'unlock') {
const unlockButton = document.querySelector(`button.unlock-secret[secret-id='${id}']`);
const lockButton = document.querySelector(`button.lock-secret[secret-id='${id}']`);
const copyButton = document.querySelector(`button.copy-secret[secret-id='${id}']`);
// If we're unlocking, hide the unlock button. Otherwise, show it.
if (unlockButton !== null) {
if (action === 'unlock') unlockButton.classList.add('d-none');
if (action === 'lock') unlockButton.classList.remove('d-none');
}
// If we're unlocking, show the lock button. Otherwise, hide it.
if (lockButton !== null) {
if (action === 'unlock') lockButton.classList.remove('d-none');
if (action === 'lock') lockButton.classList.add('d-none');
}
// If we're unlocking, show the copy button. Otherwise, hide it.
if (copyButton !== null) {
if (action === 'unlock') copyButton.classList.remove('d-none');
if (action === 'lock') copyButton.classList.add('d-none');
}
}
/**
* Initialize Lock & Unlock button event listeners & callbacks.
*/
export function initLockUnlock() {
const privateKeyModalElem = document.getElementById('privkey_modal');
if (privateKeyModalElem === null) {
return;
}
const privateKeyModal = new Modal(privateKeyModalElem);
/**
* Unlock a secret, or prompt the user for their private key, if a session key is not available.
*
* @param id Secret ID
*/
function unlock(id: string | null) {
const target = document.getElementById(`secret_${id}`);
if (typeof id === 'string' && id !== '') {
apiGetBase<APISecret>(`/api/secrets/secrets/${id}`).then(data => {
if (!hasError(data)) {
const { plaintext } = data;
// `plaintext` is the plain text value of the secret. If it is null, it has not been
// decrypted, likely due to a mission session key.
if (target !== null && plaintext !== null) {
// If `plaintext` is not null, we have the decrypted value. Set the target element's
// inner text to the decrypted value and toggle copy/lock button visibility.
target.innerText = plaintext;
toggleSecretButtons(id, 'unlock');
} else {
// Otherwise, we do _not_ have the decrypted value and need to prompt the user for
// their private RSA key, in order to get a session key. The session key is then sent
// as a cookie in future requests.
privateKeyModal.show();
}
} else {
if (data.error.toLowerCase().includes('invalid session key')) {
// If, for some reason, a request was made but resulted in an API error that complains
// of a missing session key, prompt the user for their session key.
privateKeyModal.show();
} else {
// If we received an API error but it doesn't contain 'invalid session key', show the
// user an error message.
const toast = createToast('danger', 'Error', data.error);
toast.show();
}
}
});
}
}
/**
* Lock a secret and toggle visibility of the unlock button.
* @param id Secret ID
*/
function lock(id: string | null) {
if (typeof id === 'string' && id !== '') {
const target = document.getElementById(`secret_${id}`);
if (target !== null) {
// Obscure the inner text of the secret element.
target.innerText = '********';
}
// Toggle visibility of the copy/lock/unlock buttons.
toggleSecretButtons(id, 'lock');
}
}
for (const element of getElements<HTMLButtonElement>('button.unlock-secret')) {
element.addEventListener('click', () => unlock(element.getAttribute('secret-id')));
}
for (const element of getElements<HTMLButtonElement>('button.lock-secret')) {
element.addEventListener('click', () => lock(element.getAttribute('secret-id')));
}
}
/**
* Request a session key from the API.
* @param privateKey RSA Private Key (valid JSON string)
*/
function requestSessionKey(privateKey: string) {
apiPostForm('/api/secrets/get-session-key/', { private_key: privateKey }).then(res => {
if (!hasError(res)) {
// If the response received was not an error, show the user a success message.
const toast = createToast('success', 'Session Key Received', 'You may now unlock secrets.');
toast.show();
} else {
// Otherwise, show the user an error message.
let message = res.error;
if (isApiError(res)) {
// If the error received was a standard API error containing a Python exception message,
// append it to the error.
message += `\n${res.exception}`;
}
const toast = createToast('danger', 'Failed to Retrieve Session Key', message);
toast.show();
}
});
}
/**
* Initialize Request Session Key Elements.
*/
export function initGetSessionKey() {
for (const element of getElements<HTMLButtonElement>('#request_session_key')) {
/**
* Send the user's input private key to the API to get a session key, which will be stored as
* a cookie for future requests.
*/
function handleClick() {
for (const pk of getElements<HTMLTextAreaElement>('#user_privkey')) {
requestSessionKey(pk.value);
// Clear the private key form field value.
pk.value = '';
}
}
element.addEventListener('click', handleClick);
}
}

View File

@ -1,6 +1,9 @@
import Cookie from 'cookie';
export function isApiError(data: Record<string, unknown>): data is APIError {
return 'error' in data && 'exception' in data;
}
export function hasError(data: Record<string, unknown>): data is ErrorBase {
return 'error' in data;
}
@ -34,13 +37,55 @@ export function getCsrfToken(): string {
export async function apiGetBase<T extends Record<string, unknown>>(
url: string,
): Promise<T | APIError> {
): Promise<T | ErrorBase | APIError> {
const token = getCsrfToken();
const res = await fetch(url, {
method: 'GET',
headers: { 'X-CSRFToken': token },
credentials: 'same-origin',
});
const contentType = res.headers.get('Content-Type');
if (typeof contentType === 'string' && contentType.includes('text')) {
const error = await res.text();
return { error } as ErrorBase;
}
const json = (await res.json()) as T | APIError;
if (!res.ok && Array.isArray(json)) {
const error = json.join('\n');
return { error } as ErrorBase;
}
return json;
}
export async function apiPostForm<
T extends Record<string, unknown>,
R extends Record<string, unknown>
>(url: string, data: T): Promise<R | ErrorBase | APIError> {
const token = getCsrfToken();
const body = new URLSearchParams();
for (const [k, v] of Object.entries(data)) {
body.append(k, String(v));
}
const res = await fetch(url, {
method: 'POST',
body,
headers: { 'X-CSRFToken': token },
});
const contentType = res.headers.get('Content-Type');
if (typeof contentType === 'string' && contentType.includes('text')) {
let error = await res.text();
if (contentType.includes('text/html')) {
error = res.statusText;
}
return { error } as ErrorBase;
}
const json = (await res.json()) as R | APIError;
if (!res.ok && 'detail' in json) {
return { error: json.detail as string } as ErrorBase;
}
return json;
}
@ -50,7 +95,7 @@ export async function apiGetBase<T extends Record<string, unknown>>(
*/
export async function getApiData<T extends APIObjectBase>(
url: string,
): Promise<APIAnswer<T> | APIError> {
): Promise<APIAnswer<T> | ErrorBase | APIError> {
return await apiGetBase<APIAnswer<T>>(url);
}

View File

@ -2,26 +2,26 @@
<div class="modal-dialog modal-md" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<h4 class="modal-title" id="privkey_modal_title">
<h5 class="modal-title" id="privkey_modal_title">
<span class="mdi mdi-lock" aria-hidden="true"></span>
Enter your private RSA key
</h4>
Enter Private RSA Key
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>
<p class="small text-muted">
You do not have an active session key. To request one, please provide your private RSA key below.
Once retrieved, your session key will be saved for future requests.
</p>
<div class="form-group">
<textarea class="form-control" id="user_privkey" style="height: 300px;"></textarea>
</div>
<div class="form-group text-right noprint">
<button id="request_session_key" class="btn btn-primary" data-dismiss="modal">
Request session key
</button>
<textarea class="form-control font-monospace" id="user_privkey" style="height: 300px;"></textarea>
</div>
</div>
<div class="modal-footer float-end">
<button id="request_session_key" class="btn btn-primary" data-bs-dismiss="modal">
Request Session Key
</button>
</div>
</div>
</div>
</div>

View File

@ -53,15 +53,15 @@
</form>
<div class="row">
<div class="col-md-2">Secret</div>
<div class="col-md-6" id="secret_{{ object.pk }}">********</div>
<div class="col-md-6"><code id="secret_{{ object.pk }}">********</code></div>
<div class="col-md-4 text-right noprint">
<button class="btn btn-sm btn-success unlock-secret" secret-id="{{ object.pk }}">
<i class="mdi mdi-lock"></i> Unlock
</button>
<button class="btn btn-sm btn-default copy-secret collapse" secret-id="{{ object.pk }}" data-clipboard-target="#secret_{{ object.pk }}">
<button class="btn btn-sm btn-outline-dark copy-secret d-none" secret-id="{{ object.pk }}" data-clipboard-target="#secret_{{ object.pk }}">
<i class="mdi mdi-content-copy"></i> Copy
</button>
<button class="btn btn-sm btn-danger lock-secret collapse" secret-id="{{ object.pk }}">
<button class="btn btn-sm btn-danger lock-secret d-none" secret-id="{{ object.pk }}">
<i class="mdi mdi-lock-open"></i> Lock
</button>
</div>

View File

@ -5,7 +5,3 @@
{{ block.super }}
{% include 'secrets/inc/private_key_modal.html' %}
{% endblock %}
{% block javascript %}
<script src="{% static 'js/secrets.js' %}?v{{ settings.VERSION }}"></script>
{% endblock %}

View File

@ -3,33 +3,35 @@
{% load plugins %}
{% block breadcrumbs %}
<li><a href="{% url 'secrets:secretrole_list' %}">Secret Roles</a></li>
<li>{{ object }}</li>
<li class="breadcrumb-item"><a href="{% url 'secrets:secretrole_list' %}">Secret Roles</a></li>
<li class="breadcrumb-item">{{ object }}</li>
{% endblock %}
{% block content %}
<div class="row">
<div class="row mb-3">
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Secret Role</strong>
<div class="card">
<h5 class="card-header">
Secret Role
</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Name</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">Description</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">Secrets</th>
<td>
<a href="{% url 'secrets:secret_list' %}?role_id={{ object.pk }}">{{ secrets_table.rows|length }}</a>
</td>
</tr>
</table>
</div>
<table class="table table-hover panel-body attr-table">
<tr>
<td>Name</td>
<td>{{ object.name }}</td>
</tr>
<tr>
<td>Description</td>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<td>Secrets</td>
<td>
<a href="{% url 'secrets:secret_list' %}?role_id={{ object.pk }}">{{ secrets_table.rows|length }}</a>
</td>
</tr>
</table>
</div>
{% plugin_left_page object %}
</div>
@ -40,15 +42,17 @@
</div>
<div class="row">
<div class="col-md-12">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Secrets</strong>
<div class="card">
<h5 class="card-header">
Secrets
</h5>
<div class="card-body">
{% include 'inc/table.html' with table=secrets_table %}
</div>
{% include 'inc/table.html' with table=secrets_table %}
{% if perms.secrets.add_secret %}
<div class="panel-footer text-right noprint">
<a href="{% url 'secrets:secret_add' %}?role={{ object.pk }}" class="btn btn-xs btn-primary">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add secret
<div class="card-footer text-end noprint">
<a href="{% url 'secrets:secret_add' %}?role={{ object.pk }}" class="btn btn-sm btn-primary">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Secret
</a>
</div>
{% endif %}