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 287 additions and 81 deletions

Binary file not shown.

Binary file not shown.

View File

@ -11,12 +11,15 @@ type APIAnswer<T> = {
results: T[]; results: T[];
}; };
type APIError = { type ErrorBase = {
error: string; error: string;
};
type APIError = {
exception: string; exception: string;
netbox_version: string; netbox_version: string;
python_version: string; python_version: string;
}; } & ErrorBase;
type APIObjectBase = { type APIObjectBase = {
id: number; id: number;
@ -39,6 +42,23 @@ type APIReference = {
_depth: number; _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 { interface ObjectWithGroup extends APIObjectBase {
group: Nullable<APIReference>; group: Nullable<APIReference>;
} }

View File

@ -7,7 +7,7 @@ import { initSpeedSelector, initForms } from './forms';
import { initRackElevation } from './buttons'; import { initRackElevation } from './buttons';
import { initClipboard } from './clipboard'; import { initClipboard } from './clipboard';
import { initSearchBar } from './search'; import { initSearchBar } from './search';
// import { initGenerateKeyPair } from './secrets'; import { initGenerateKeyPair, initLockUnlock, initGetSessionKey } from './secrets';
import { getElements } from './util'; import { getElements } from './util';
const INITIALIZERS = [ const INITIALIZERS = [
@ -21,7 +21,9 @@ const INITIALIZERS = [
initColorSelect, initColorSelect,
initRackElevation, initRackElevation,
initClipboard, initClipboard,
// initGenerateKeyPair, initGenerateKeyPair,
initLockUnlock,
initGetSessionKey,
] as (() => void)[]; ] as (() => void)[];
/** /**
@ -35,7 +37,6 @@ function initBootstrap(): void {
new Tooltip(tooltip, { container: 'body', boundary: 'window' }); new Tooltip(tooltip, { container: 'body', boundary: 'window' });
} }
for (const modal of getElements('[data-bs-toggle="modal"]')) { for (const modal of getElements('[data-bs-toggle="modal"]')) {
// for (const modal of getElements('div.modal')) {
new Modal(modal); new Modal(modal);
} }
initMessageToasts(); 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';
/** /**
* * Initialize Generate Private Key Pair Elements.
* $('#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.");
}
});
});
*/ */
export function initGenerateKeyPair() { export function initGenerateKeyPair() {
const element = document.getElementById('new_keypair_modal') as HTMLDivElement; const element = document.getElementById('new_keypair_modal') as HTMLDivElement;
const accept = document.getElementById('use_new_pubkey') as HTMLButtonElement; 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 publicElem = element.querySelector<HTMLTextAreaElement>('textarea#new_pubkey');
const privateElem = element.querySelector<HTMLTextAreaElement>('textarea#new_privkey'); const privateElem = element.querySelector<HTMLTextAreaElement>('textarea#new_privkey');
/**
* Handle Generate Private Key Pair Modal opening.
*/
function handleOpen() { function handleOpen() {
// When the modal opens, set the `readonly` attribute on the textarea elements.
for (const elem of [publicElem, privateElem]) { for (const elem of [publicElem, privateElem]) {
if (elem !== null) { if (elem !== null) {
elem.setAttribute('readonly', ''); elem.setAttribute('readonly', '');
} }
} }
// Fetch the key pair from the API.
apiGetBase<APIKeyPair>('/api/secrets/generate-rsa-key-pair').then(data => { 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; const { private_key: priv, public_key: pub } = data;
if (publicElem !== null && privateElem !== null) { if (publicElem !== null && privateElem !== null) {
publicElem.value = pub; publicElem.value = pub;
privateElem.value = priv; 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() { function handleAccept() {
const publicKeyField = document.getElementById('id_public_key') as HTMLTextAreaElement; const publicKeyField = document.getElementById('id_public_key') as HTMLTextAreaElement;
if (publicElem !== null) { if (publicElem !== null) {
@ -53,10 +56,147 @@ export function initGenerateKeyPair() {
accept.addEventListener('click', handleAccept); accept.addEventListener('click', handleAccept);
} }
/**
* 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() { 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')) { 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() { function handleClick() {
const { secretId } = element.dataset; 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'; import Cookie from 'cookie';
export function isApiError(data: Record<string, unknown>): data is APIError { 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; return 'error' in data;
} }
@ -34,13 +37,55 @@ export function getCsrfToken(): string {
export async function apiGetBase<T extends Record<string, unknown>>( export async function apiGetBase<T extends Record<string, unknown>>(
url: string, url: string,
): Promise<T | APIError> { ): Promise<T | ErrorBase | APIError> {
const token = getCsrfToken(); const token = getCsrfToken();
const res = await fetch(url, { const res = await fetch(url, {
method: 'GET', method: 'GET',
headers: { 'X-CSRFToken': token }, 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; 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; return json;
} }
@ -50,7 +95,7 @@ export async function apiGetBase<T extends Record<string, unknown>>(
*/ */
export async function getApiData<T extends APIObjectBase>( export async function getApiData<T extends APIObjectBase>(
url: string, url: string,
): Promise<APIAnswer<T> | APIError> { ): Promise<APIAnswer<T> | ErrorBase | APIError> {
return await apiGetBase<APIAnswer<T>>(url); return await apiGetBase<APIAnswer<T>>(url);
} }

View File

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

View File

@ -53,15 +53,15 @@
</form> </form>
<div class="row"> <div class="row">
<div class="col-md-2">Secret</div> <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"> <div class="col-md-4 text-right noprint">
<button class="btn btn-sm btn-success unlock-secret" secret-id="{{ object.pk }}"> <button class="btn btn-sm btn-success unlock-secret" secret-id="{{ object.pk }}">
<i class="mdi mdi-lock"></i> Unlock <i class="mdi mdi-lock"></i> Unlock
</button> </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 <i class="mdi mdi-content-copy"></i> Copy
</button> </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 <i class="mdi mdi-lock-open"></i> Lock
</button> </button>
</div> </div>

View File

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

View File

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