Use pseudo-Bearer token auth instead of Basic auth

This commit is contained in:
Saphire 2024-07-18 00:06:51 +06:00
parent b0190b2ccf
commit 866129dd24
Signed by: Saphire
GPG Key ID: B26EB7A1F07044C4
30 changed files with 836 additions and 300 deletions

1
env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -1,23 +1,30 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
<meta name="robots" content="noindex, nofollow">
<meta name="googlebot" content="noindex, nofollow">
<meta name="theme-color" content="aliceblue" />
<link rel="icon" href="/favicon.ico">
<script type="module" src="/src/main.ts"></script>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Directory</title>
</head>
<body class="bg-back text-front">
<div id="app"></div>
<noscript>
<strong>We're sorry but JavaScript is required. Please enable it to continue.</strong>
</noscript>
</body>
<meta
name="viewport"
content="width=device-width,initial-scale=1,shrink-to-fit=no"
/>
<meta name="robots" content="noindex, nofollow" />
<meta name="googlebot" content="noindex, nofollow" />
<meta name="theme-color" content="aliceblue" />
<link rel="icon" href="/src/assets/favicon.ico" />
<title>Directory Web Access</title>
</head>
<body class="bg-back text-front">
<div id="app"></div>
<noscript>
<strong>
We're sorry but JavaScript is required. Please enable it to
continue.
</strong>
</noscript>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

BIN
package-lock.json generated

Binary file not shown.

View File

@ -15,9 +15,9 @@
"tw-config": "tailwind-config-viewer -o"
},
"dependencies": {
"@vueuse/components": "^10.2.1",
"@vueuse/core": "^10.2.1",
"@heroicons/vue": "^2.0.10",
"font-awesome": "^4.7.0",
"pinia": "^2.1.7",
"vue": "^3.4.29",
"vue-router": "^4.3.3"
},
@ -35,8 +35,7 @@
"prettier": "^3.2.6",
"typescript": "~5.4.0",
"vite": "^5.3.1",
"vue-tsc": "^2.0.21",
"vite-plugin-compression": "^0.5.1",
"vue-tsc": "^2.0.26",
"vitest": "^0.34.2",
"autoprefixer": "^10.4.19",
"tailwindcss": "^3.4.4",

View File

@ -4,13 +4,15 @@
from typing import *
from constants import PASSWORDS, PHOTO, WITH_OPERATIONAL_ATTRS
from decorators import no_cache, api, authenticated, connected
from decorators import anonymous_api, no_cache, api, authenticated, connected, search_user_by_attr
from ldaputils import _at, _dict, _dn_order, _ename, _entry, _oc, _schema, _syntax, _tree
import ldaputils
from token_utils import issue_jwt_token
from utils import empty, result, unique
from app_quart import app
@app.route('/')
@app.route('/login')
async def index() -> quart.Response:
'Serve the main page'
return await static_file('index.html')
@ -30,6 +32,42 @@ async def whoami() -> str:
return request.ldap.whoami_s().replace('dn:', '')
@app.route('/api/login', methods=['POST'])
@no_cache
@connected
@anonymous_api
async def login() -> str:
'Issue a JWT token containing encrypted user password & login'
requestData = await request.get_json();
get_dn = app.config['GET_BIND_DN']
dn = get_dn(requestData) if get_dn else None
try:
if dn is None and requestData["userIdentifier"] is None:
raise ldaplib.INVALID_CREDENTIALS()
# Try authenticating
get_passwd = app.config['GET_BIND_PASSWORD']
await empty(quart.request.ldap.simple_bind(
dn or await search_user_by_attr(),
get_passwd(requestData)
))
jwtToken = issue_jwt_token(requestData);
return quart.Response(
quart.json.dumps({ "token": jwtToken }),
status = 200
)
except ldaplib.INVALID_CREDENTIALS:
return quart.Response(
quart.json.dumps({ "error": "Invalid credentials" }),
status = 401
)
@app.route('/api/tree/<path:basedn>')
@no_cache
@api
@ -177,28 +215,28 @@ async def rename() -> None:
async def passwd(dn: str) -> Optional[bool]:
'Edit directory entries'
if request.is_json:
args = await request.get_json()
if 'check' in args:
try:
con = ldaplib.initialize(app.config['LDAP_URL'])
con.simple_bind_s(dn, args['check'])
con.unbind_s()
return True
except ldaplib.INVALID_CREDENTIALS:
return False
if not request.is_json:
return None;
elif 'new1' in args:
if args['new1']:
await empty(request.ldap.passwd(dn, args.get('old') or None, args['new1']))
_dn, attrs = await unique(
request.ldap.search(dn, ldaplib.SCOPE_BASE))
return attrs['userPassword'][0].decode()
else:
await empty(request.ldap.modify(dn, [(1, 'userPassword', None)]))
return ''
args = await request.get_json()
if 'check' in args:
try:
con = ldaplib.initialize(app.config['LDAP_URL'])
con.simple_bind_s(dn, args['check'])
con.unbind_s()
return True
except ldaplib.INVALID_CREDENTIALS:
return False
return None # mypy
elif 'new1' in args:
if args['new1']:
await empty(request.ldap.passwd(dn, args.get('old') or None, args['new1']))
_dn, attrs = await unique(
request.ldap.search(dn, ldaplib.SCOPE_BASE))
return attrs['userPassword'][0].decode()
else:
await empty(request.ldap.modify(dn, [(1, 'userPassword', None)]))
return ''
@app.route('/api/search/<path:query>')

View File

@ -5,6 +5,7 @@
import quart
from constants import UNAUTHORIZED
from token_utils import decode_jwt_token
from utils import empty, unique
from app_quart import app
@ -72,25 +73,26 @@ def authenticated(view: Callable):
@functools.wraps(view)
async def wrapped_view(**values):
auth_material = decode_jwt_token(quart.request.authorization.token)
get_dn = app.config['GET_BIND_DN']
dn = get_dn(quart.request.authorization) if get_dn else None
dn = get_dn(auth_material) if get_dn and (auth_material is not None) else None
try:
if dn is None and quart.request.authorization is None:
if dn is None and auth_material is None:
raise ldap.INVALID_CREDENTIALS()
# Try authenticating
get_passwd = app.config['GET_BIND_PASSWORD']
await empty(quart.request.ldap.simple_bind(
dn or await search_user_by_attr(app),
get_passwd(quart.request.authorization)))
get_passwd(auth_material)))
# On success, call the view function
return await view(**values)
except ldap.INVALID_CREDENTIALS:
return quart.Response(
'Please log in', 401, UNAUTHORIZED)
return quart.Response('{"error": "Unauthenticated. Please log in"}', 401, UNAUTHORIZED)
return wrapped_view
@ -104,4 +106,17 @@ async def wrapped_view(**values) -> quart.Response:
if type(data) is not quart.Response:
data = quart.jsonify(data)
return data
return wrapped_view
def anonymous_api(view: Callable) -> quart.Response:
''' View decorator for JSON endpoints.
Does not require authentication.
'''
@functools.wraps(view)
async def wrapped_view(**values) -> quart.Response:
data = await view(**values)
if type(data) is not quart.Response:
data = quart.jsonify(data)
return data
return wrapped_view

View File

@ -1,3 +1,4 @@
python-ldap
python-dotenv
Quart
cryptography

View File

@ -1,5 +1,6 @@
from dotenv import load_dotenv
import os
import base64
load_dotenv()
@ -7,6 +8,19 @@
PREFERRED_URL_SCHEME = 'https'
SECRET_KEY = os.urandom(16)
def GET_TOKEN_KEY():
env_key_b64 = os.environ.get("SECRET_TOKEN_KEY")
if env_key_b64 is None:
return os.urandom(32)
decoded = base64.b64decode(env_key_b64)
if len(decoded) != 32:
raise ValueError("Invalid secret token length, expected 32 bytes got " + str(len(decoded)))
return decoded
SECRET_TOKEN_KEY = GET_TOKEN_KEY()
#
# LDAP settings
#
@ -36,12 +50,12 @@ def GET_BIND_DN(authorization):
# This can be used to authenticate with directories
# that do not allow anonymous users to search.
elif os.environ.get('BIND_PATTERN') and authorization is not None:
return os.environ['BIND_PATTERN'] % authorization.username
return os.environ['BIND_PATTERN'] % authorization["userIdentifier"]
def GET_BIND_DN_FILTER(authorization):
'Produce a LDAP search filter for the login DN'
return SEARCH_PATTERNS[0] % authorization.username
return SEARCH_PATTERNS[0] % authorization["userIdentifier"]
def GET_BIND_PASSWORD(authorization):
@ -54,9 +68,9 @@ def GET_BIND_PASSWORD(authorization):
if pw_file is not None:
with open(pw_file) as file:
return file.read().rstrip('\n')
if authorization is not None:
return authorization.password
return authorization["password"]
#

27
server/token_utils.py Normal file
View File

@ -0,0 +1,27 @@
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
import json
import os
import settings
import struct
import time
import base64
def issue_jwt_token(auth_material):
cipher_object = ChaCha20Poly1305(settings.SECRET_TOKEN_KEY)
nonce = struct.pack("QI", int(time.time()), struct.unpack("I", os.urandom(4))[0])
data = json.dumps(auth_material).encode("utf-8")
associated_data = nonce
return base64.b64encode(nonce + cipher_object.encrypt(nonce, data, associated_data)).decode("ascii")
def decode_jwt_token(aead_token_b64):
if aead_token_b64 is None:
return None
aead_token = base64.b64decode(aead_token_b64)
cipher_object = ChaCha20Poly1305(settings.SECRET_TOKEN_KEY)
token = cipher_object.decrypt(aead_token[:12], aead_token[12:], aead_token[:12])
return json.loads(token.decode("utf-8"))

View File

@ -1,206 +1,19 @@
<template>
<div id="app" v-if="!user">
<login-view />
</div>
<div id="app" v-else>
<nav-bar
v-model:treeOpen="treeOpen"
:dn="activeDn"
:base-dn="baseDn"
:user="user"
@show-modal="modal = $event"
@select-dn="activeDn = $event"
@show-oc="oc = $event"
/>
<ldif-import-dialog v-model:modal="modal" @ok="activeDn = '-'" />
<div class="flex container">
<div class="space-y-4">
<!-- left column -->
<tree-view
v-model:activeDn="activeDn"
v-show="treeOpen"
@base-dn="baseDn = $event"
/>
<object-class-card
v-model="oc"
@show-attr="attr = $event"
@show-oc="oc = $event"
/>
<attribute-card v-model="attr" @show-attr="attr = $event" />
</div>
<div class="flex-auto mt-4">
<!-- main editing area -->
<transition name="fade"
><!-- Notifications -->
<div
v-if="error"
:class="error.cssClass"
class="rounded mx-4 mb-4 p-3 border border-front/70 text-front/70 dark:text-back/70"
>
{{ error.msg }}
<span
class="float-right control"
@click="error = undefined"
></span
>
</div>
</transition>
<entry-editor
v-model:activeDn="activeDn"
:user="user"
@show-attr="attr = $event"
@show-oc="oc = $event"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, ref, watch } from "vue";
import AttributeCard from "./components/schema/AttributeCard.vue";
import EntryEditor from "./components/editor/EntryEditor.vue";
import { LdapSchema } from "./components/schema/schema";
import LdifImportDialog from "@/views/dialogs/LdifImportDialog.vue";
import NavBar from "./components/NavBar.vue";
import ObjectClassCard from "./components/schema/ObjectClassCard.vue";
import type { Provided } from "@/data/provided";
import TreeView from "./components/TreeView.vue";
import LoginView from "@/views/LoginView.vue";
interface Error {
counter: number;
cssClass: string;
msg: string;
}
// Authentication
const user = ref<string>(); // logged in user
const baseDn = ref<string>();
const authHeaders = ref<Record<string, string>>({});
// Components
const treeOpen = ref(true); // Is the tree visible?
const activeDn = ref<string>(); // currently active DN in the editor
const modal = ref<string>(); // modal popup
// Alerts
const error = ref<Error>(); // status alert
// LDAP schema
const schema = ref<LdapSchema>();
const oc = ref<string>(); // objectClass info in side panel
const attr = ref<string>(); // attribute info in side panel
// Helpers for components
const provided: Provided = {
get schema() {
return schema.value;
},
showInfo,
showException,
showWarning,
authHeaders,
};
provide("app", provided);
onMounted(async () => {
// Runs on page load
// Get the DN of the current user
const whoamiResponse = await fetch("api/whoami");
if (whoamiResponse.ok) {
user.value = await whoamiResponse.json();
}
// Load the schema
const schemaResponse = await fetch("api/schema");
if (schemaResponse.ok) {
schema.value = new LdapSchema(await schemaResponse.json());
}
});
watch(attr, (a) => {
if (a) oc.value = undefined;
});
watch(oc, (o) => {
if (o) attr.value = undefined;
});
// Display an info popup
function showInfo(msg: string) {
error.value = { counter: 5, cssClass: "bg-emerald-300", msg: "" + msg };
setTimeout(() => {
error.value = undefined;
}, 5000);
}
// Flash a warning popup
function showWarning(msg: string) {
error.value = { counter: 10, cssClass: "bg-amber-200", msg: "⚠️ " + msg };
setTimeout(() => {
error.value = undefined;
}, 10000);
}
// Report an error
function showError(msg: string) {
error.value = { counter: 60, cssClass: "bg-red-300", msg: "⛔ " + msg };
setTimeout(() => {
error.value = undefined;
}, 60000);
}
function showException(msg: string) {
const span = document.createElement("span");
span.innerHTML = msg.replace("\n", " ");
const titles = span.getElementsByTagName("title");
for (let i = 0; i < titles.length; ++i) {
span.removeChild(titles[i]);
}
let text = "";
const headlines = span.getElementsByTagName("h1");
for (let i = 0; i < headlines.length; ++i) {
text = text + headlines[i].textContent + ": ";
span.removeChild(headlines[i]);
}
showError(text + " " + span.textContent);
}
import { RouterView } from "vue-router";
</script>
<template>
<RouterView v-slot="{ Component }">
<Transition name="fade" mode="out-in">
<component :is="Component" />
</Transition>
</RouterView>
</template>
<style>
.control {
@apply opacity-70 hover:opacity-90 cursor-pointer select-none leading-none pt-1 pr-1;
}
button,
.btn,
[type="button"] {
@apply px-3 py-2 rounded text-back dark:text-front font-medium outline-none;
}
button.btn {
@apply border-solid border-back border-2 focus:border-primary dark:focus:border-front;
}
select {
background: url("assets/gray_bg.svg") no-repeat right;
appearance: none;
}
.glyph {
font-family: sans-serif, FontAwesome;
font-style: normal;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease;
transition: opacity 0.2s ease;
}
.fade-enter-from,
@ -208,4 +21,3 @@ select {
opacity: 0;
}
</style>
./data/Provided

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

64
src/assets/main.css Normal file
View File

@ -0,0 +1,64 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
color-scheme: light;
--color-front: 32 32 32;
--color-back: 255 255 255;
--color-primary: 23 162 184;
--color-secondary: 108 117 125;
--color-danger: 229 57 53;
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--color-front: 144 144 144;
--color-back: 24 24 24;
}
}
body {
min-height: 100vh;
background: var(--bg-color);
/* line-height: 1.6; */
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
Oxygen,
Ubuntu,
Cantarell,
"Fira Sans",
"Droid Sans",
"Helvetica Neue",
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
}
#app {
display: flex;
font-weight: normal;
margin: 0 auto;
flex: 1 0 auto;
place-items: center;
}
.footer-shown > #app {
margin-bottom: 2em;
}
body {
display: flex;
flex-direction: column;
place-items: center center;
}
}

View File

@ -0,0 +1,76 @@
<script setup lang="ts">
import { XCircleIcon } from "@heroicons/vue/24/outline";
import { nextTick, onMounted, ref, watch } from "vue";
const props = defineProps<{
modelValue: string | null;
}>();
const emit = defineEmits<{
(e: "update:modelValue", value: string | null): void;
}>();
const finishedChanging = ref<number | null>(null);
const innerElement = ref<HTMLDivElement | null>(null);
const style = ref<string | undefined>("height: 0px");
const cachedValue = ref<string | null>(props.modelValue ?? null);
watch(
() => props.modelValue,
async (newValue, oldValue) => {
style.value = `height: ${
oldValue ? innerElement.value?.clientHeight ?? 0 : 0
}`;
if (finishedChanging.value) clearTimeout(finishedChanging.value);
if (newValue) cachedValue.value = newValue;
await nextTick();
style.value = `height: ${
newValue ? innerElement.value?.clientHeight ?? 0 : 0
}px`;
finishedChanging.value = setTimeout(() => {
finishedChanging.value = null;
cachedValue.value = newValue;
}, 200);
}
);
onMounted(async () => {
if (finishedChanging.value) clearTimeout(finishedChanging.value);
style.value = `height: ${
props.modelValue ? innerElement.value?.clientHeight ?? 0 : 0
}px`;
finishedChanging.value = setTimeout(
() => (finishedChanging.value = null),
205
);
});
</script>
<template>
<div
v-if="modelValue || cachedValue"
class="collapsible mx-1 bg-red-900 text-gray-50 rounded relative overflow-hidden"
:style="style"
>
<div ref="innerElement" class="px-3 py-2 whitespace-pre-wrap">
<button
title="Close Collapsible"
type="button"
@click="emit('update:modelValue', null)"
class="float-right ml-2"
>
<XCircleIcon class="h-6 w-6 text-gray-50" /></button
>{{ cachedValue }}
</div>
</div>
</template>
<style>
.collapsible {
transition: height 0.2s ease-out;
}
</style>

View File

@ -51,7 +51,9 @@ watch(
async (q) => {
if (!q) return;
const response = await fetch("api/search/" + q);
const response = await fetch("api/search/" + q, {
headers: app?.authHeaders.value,
});
if (!response.ok) return;
results.value = (await response.json()) as Result[];

View File

@ -0,0 +1,43 @@
<script setup lang="ts">
import { ref } from "vue";
import type { RouteLocationRaw } from "vue-router";
const props = defineProps<{
disabled?: boolean;
text?: string;
to?: RouteLocationRaw;
customClass?: string;
type?: string;
}>();
const button = ref(null);
const emit = defineEmits<{
(e: "click", value: MouseEvent): void;
}>();
const eventHandler = (event: MouseEvent) => {
emit("click", event);
};
</script>
<template>
<component
ref="button"
:is="to ? 'RouterLink' : 'button'"
@click="eventHandler"
:to="to ? to : undefined"
:disabled="disabled || false"
class="px-1 py-2 border transition-colors transition-200 ease-in"
:class="[
to ? 'text-center' : '',
customClass ??
'bg-slate-800 border-slate-700 disabled:bg-zinc-800 disabled:border-zinc-700 hover:bg-slate-700 hover:border-slate-600',
]"
:type="type"
>
<slot>
{{ text || "" }}
</slot>
</component>
</template>

View File

@ -41,9 +41,10 @@
<script setup lang="ts">
import { DN } from "./schema/schema";
import { onMounted, ref, watch } from "vue";
import { inject, onMounted, ref, watch } from "vue";
import NodeLabel from "./NodeLabel.vue";
import type { TreeNode } from "@/data/treeNode";
import type { Provided } from "@/data/provided";
class Node implements TreeNode {
dn: string;
@ -106,6 +107,7 @@ const props = defineProps({
});
const tree = ref<Node>();
const emit = defineEmits(["base-dn", "update:activeDn"]);
const app = inject<Provided>("app");
onMounted(async () => {
await reload("base");
@ -157,7 +159,9 @@ async function clicked(dn: string) {
// Reload the subtree at entry with given DN
async function reload(dn: string) {
const response = await fetch("api/tree/" + dn);
const response = await fetch("api/tree/" + dn, {
headers: app?.authHeaders.value,
});
if (!response.ok) return;
const data = (await response.json()) as Node[];

View File

@ -273,7 +273,9 @@ onMounted(async () => {
)
return;
const response = await fetch("api/range/" + props.attr.name);
const response = await fetch("api/range/" + props.attr.name, {
headers: app?.authHeaders.value,
});
if (!response.ok) return;
const range = (await response.json()) as {
@ -379,6 +381,7 @@ async function deleteBlob(index: number) {
"api/blob/" + props.attr.name + "/" + index + "/" + props.meta.dn,
{
method: "DELETE",
headers: app?.authHeaders.value,
}
);
if (response.ok) emit("reload-form", props.meta.dn, [props.attr.name]);

View File

@ -332,7 +332,9 @@ async function load(
return;
}
const response = await fetch("api/entry/" + dn);
const response = await fetch("api/entry/" + dn, {
headers: app?.authHeaders.value,
});
if (!response.ok) return;
entry.value = (await response.json()) as Entry;
@ -344,7 +346,6 @@ async function load(
}
function hasChanged(key: string) {
console.log(entry.value?.changed);
return (entry.value?.changed && entry.value.changed.includes(key)) || false;
}
@ -360,6 +361,7 @@ async function save() {
method: entry.value!.meta.isNew ? "PUT" : "POST",
body: JSON.stringify(entry.value!.attrs),
headers: {
...app?.authHeaders.value,
"Content-Type": "application/json",
},
});
@ -384,6 +386,7 @@ async function renameEntry(rdn: string) {
rdn: rdn,
}),
headers: {
...app?.authHeaders.value,
"Content-Type": "application/json",
},
});
@ -394,7 +397,10 @@ async function renameEntry(rdn: string) {
}
async function deleteEntry(dn: string) {
const response = await fetch("api/entry/" + dn, { method: "DELETE" });
const response = await fetch("api/entry/" + dn, {
method: "DELETE",
headers: app?.authHeaders.value,
});
if (response.ok && (await response.json()) !== undefined) {
app?.showInfo("Deleted: " + dn);
emit("update:activeDn", "-" + dn);
@ -406,6 +412,7 @@ async function changePassword(oldPass: string, newPass: string) {
method: "POST",
body: JSON.stringify({ old: oldPass, new1: newPass }),
headers: {
...app?.authHeaders.value,
"Content-Type": "application/json",
},
});
@ -421,7 +428,7 @@ async function changePassword(oldPass: string, newPass: string) {
// Download LDIF
async function ldif() {
const response = await fetch("api/ldif/" + entry.value!.meta.dn, {
headers: app?.authHeaders.value ?? {},
headers: app?.authHeaders.value,
});
if (!response.ok) return;

View File

@ -21,13 +21,12 @@
<script setup lang="ts">
import { onMounted, ref, watch } from "vue";
import { useEventListener, useMouseInElement } from "@vueuse/core";
const props = defineProps({ open: Boolean });
const emit = defineEmits(["opened", "closed", "update:open"]);
const items = ref<HTMLElement | null>(null);
const selected = ref<number>();
const { isOutside } = useMouseInElement(items);
const isOutside = ref(false) //useMouseInElement(items);
function close() {
selected.value = undefined;
@ -70,8 +69,8 @@ function scroll(e: KeyboardEvent) {
}
onMounted(() => {
useEventListener(document, "keydown", scroll);
useEventListener(document, "click", close);
//useEventListener(document, "keydown", scroll);
//useEventListener(document, "click", close);
});
watch(selected, (pos) => {

View File

@ -1,8 +1,15 @@
"use strict";
import { createApp } from "vue";
import App from "./App.vue";
import "./tailwind.css";
import "@/assets/main.css";
import "font-awesome/css/font-awesome.min.css";
createApp(App).mount("#app");
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "@/App.vue";
import router from "@/router";
const app = createApp(App);
app.use(createPinia())
app.use(router)
app.mount("#app");

57
src/router/index.ts Normal file
View File

@ -0,0 +1,57 @@
import { createRouter, createWebHistory } from "vue-router";
import MainView from "@/views/MainView.vue";
import useAuthStore from "@/stores/auth";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: "/",
name: "main",
component: MainView,
meta: {
showFooter: true,
},
},
{
path: "/login",
name: "login",
component: () => import("@/views/LoginView.vue"),
meta: {
showFooter: true,
guestOnly: true
},
},
],
});
router.beforeEach(async (to, from) => {
const auth = useAuthStore();
try {
await auth.ready;
} catch (error) {
if (to.name == "login") return;
auth.savePath(to.fullPath);
console.log("Saved path: ", auth.savedPath);
console.error(error);
return "/login";
}
if (auth.token) {
if (to.meta?.guestOnly) {
return "/";
}
} else {
if (!to.meta?.guest && to.name != "login") {
return "/login";
}
}
});
export default router;

66
src/stores/auth.ts Normal file
View File

@ -0,0 +1,66 @@
import { send } from "@/utils";
import { defineStore } from "pinia";
import { ref } from "vue";
export const useAuthStore = defineStore("auth", () => {
const token = ref<string | null>(
window.localStorage.getItem("_vue_ldap_ui_token")
);
const savedPath = ref<string | null>(null);
const ready = ref<Promise<boolean>>(readyGenerator());
async function readyGenerator(): Promise<boolean> {
try {
await whoami();
return true;
} catch (err) {
if (err instanceof Error && err.message == "No token")
return Promise.reject(err);
if (err instanceof Response && err.status == 401) {
token.value = null;
window.localStorage.removeItem("_vue_ldap_ui_token");
} else {
console.error(err);
}
return Promise.reject(err);
}
}
async function whoami() {
if (!token.value) return Promise.reject(new Error("No token"));
return await send<{}>("/api/whoami", null, "GET", {
Authorization: "Bearer " + token.value,
});
}
async function login(userIdentifier: string, password: string) {
const { token: loginToken } = await send<{ token: string }>(
"/api/login",
{ userIdentifier, password },
"POST"
);
token.value = loginToken;
ready.value = readyGenerator();
await ready.value;
window.localStorage.setItem("_vue_ldap_ui_token", token.value);
return;
}
async function savePath(value: string) {
savedPath.value = value;
if (savedPath.value)
window.localStorage.setItem("_vue_ldap_ui_savedPath", savedPath.value);
else window.localStorage.removeItem("_vue_ldap_ui_savedPath");
}
return { token, ready, login, savedPath, savePath };
});
export default useAuthStore;

View File

@ -1,21 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--color-front: 32 32 32;
--color-back: 255 255 255;
--color-primary: 23 162 184;
--color-secondary: 108 117 125;
--color-danger: 229 57 53;
}
@media (prefers-color-scheme: dark) {
:root {
--color-front: 255 255 255;
--color-back: 16 16 16;
}
}
}

View File

@ -1,7 +1,110 @@
<script setup lang="ts">
console.log("Rawr");
import { useRouter } from "vue-router";
import useAuthStore from "@/stores/auth";
import { computed } from "@vue/reactivity";
import { ref } from "vue";
import Collapsible from "@/components/Collapsible.vue";
import TailwindButton from "@/components/TailwindButton.vue";
const router = useRouter();
const auth = useAuthStore();
const userIdentifier = ref("");
const password = ref("");
const loggingIn = ref(false);
const error = ref("");
const disableButton = computed(
() => loggingIn.value || !userIdentifier.value || !password.value
);
async function login() {
loggingIn.value = true;
try {
const result = await auth.login(userIdentifier.value, password.value);
console.log("Navigating to root!");
const routerResult = await router.push("/");
if (routerResult instanceof Error) throw routerResult;
} catch (response: any) {
if (response instanceof Error) {
console.error(response);
loggingIn.value = false;
error.value = response.message;
return;
}
if (!(response instanceof Response)) return;
const buffer = await response.arrayBuffer();
await new Promise<void>((resolve) => setTimeout(() => resolve(), 400));
loggingIn.value = false;
if (buffer.byteLength > 0) {
const json = JSON.parse(new TextDecoder().decode(buffer));
error.value = json.error ?? response.statusText;
} else {
error.value = `Error ${response.status}: ${response.statusText}`;
}
}
}
</script>
<template>
<div>Rawr!</div>
<main>
<form
id="login-form"
@submit.prevent="login"
class="flex flex-col w-80"
>
<h2 class="mb-2 text-center text-2xl">LDAP Directory Login</h2>
<hr class="mt-1 mb-2 border-zinc-800" />
<label class="flex flex-col mb-2">
<span class="block mb-1 px-1" for="userIdentifier">
Username
</span>
<input
required
v-model="userIdentifier"
id="userIdentifier"
class="bg-slate-700 px-2 mx-2 py-1 rounded border border-slate-600 caret-slate-400"
form="login-form"
/>
</label>
<label class="flex flex-col mb-2">
<span class="block mb-1 px-1" for="password"> Password </span>
<input
required
v-model="password"
id="password"
class="bg-slate-700 px-2 mx-2 py-1 rounded border border-slate-600 caret-slate-400"
type="password"
autocomplete="current-password"
form="login-form"
/>
</label>
<Collapsible class="" v-model="error" />
<hr class="mt-2 mb-3 border-zinc-800" />
<TailwindButton
type="submit"
:disabled="disableButton"
class="mx-3 mb-3"
>
<template v-if="!loggingIn">Login</template>
<div v-else>
<span
class="relative animate-pulse inline-flex rounded-full h-2 mx-1 w-2 bg-sky-500"
></span>
<span
class="relative animate-pulse pulse-2 inline-flex rounded-full h-2 mx-1 w-2 bg-sky-500"
></span>
<span
class="relative animate-pulse pulse-3 inline-flex rounded-full h-2 mx-1 w-2 bg-sky-500"
></span>
</div>
</TailwindButton>
</form>
</main>
</template>

205
src/views/MainView.vue Normal file
View File

@ -0,0 +1,205 @@
<script setup lang="ts">
import { onMounted, provide, ref, watch } from "vue";
import { LdapSchema } from "@/components/schema/schema";
import type { Provided } from "@/data/provided";
import AttributeCard from "@/components/schema/AttributeCard.vue";
import EntryEditor from "@/components/editor/EntryEditor.vue";
import LdifImportDialog from "@/views/dialogs/LdifImportDialog.vue";
import NavBar from "@/components/NavBar.vue";
import ObjectClassCard from "@/components/schema/ObjectClassCard.vue";
import TreeView from "@/components/TreeView.vue";
import useAuthStore from "@/stores/auth";
interface Error {
counter: number;
cssClass: string;
msg: string;
}
// Alerts
const error = ref<Error>(); // status alert
// Display an info popup
function showInfo(msg: string) {
error.value = { counter: 5, cssClass: "bg-emerald-300", msg: "" + msg };
setTimeout(() => {
error.value = undefined;
}, 5000);
}
// Flash a warning popup
function showWarning(msg: string) {
error.value = { counter: 10, cssClass: "bg-amber-200", msg: "⚠️ " + msg };
setTimeout(() => {
error.value = undefined;
}, 10000);
}
// Report an error
function showError(msg: string) {
error.value = { counter: 60, cssClass: "bg-red-300", msg: "⛔ " + msg };
setTimeout(() => {
error.value = undefined;
}, 60000);
}
function showException(msg: string) {
const span = document.createElement("span");
span.innerHTML = msg.replace("\n", " ");
const titles = span.getElementsByTagName("title");
for (let i = 0; i < titles.length; ++i) {
span.removeChild(titles[i]);
}
let text = "";
const headlines = span.getElementsByTagName("h1");
for (let i = 0; i < headlines.length; ++i) {
text = text + headlines[i].textContent + ": ";
span.removeChild(headlines[i]);
}
showError(text + " " + span.textContent);
}
// Authentication
const auth = useAuthStore();
const user = ref<string>(); // logged in user
const baseDn = ref<string>();
const authHeaders = ref<Record<string, string>>({
Authorization: "Bearer " + auth.token,
});
// Components
const treeOpen = ref(true); // Is the tree visible?
const activeDn = ref<string>(); // currently active DN in the editor
const modal = ref<string>(); // modal popup
// LDAP schema
const schema = ref<LdapSchema>();
const oc = ref<string>(); // objectClass info in side panel
const attr = ref<string>(); // attribute info in side panel
// Helpers for components
const provided: Provided = {
get schema() {
return schema.value;
},
showInfo,
showException,
showWarning,
authHeaders,
};
provide("app", provided);
onMounted(async () => {
// Runs on page load
// Get the DN of the current user
const whoamiResponse = await fetch("api/whoami", {
headers: provided.authHeaders.value,
});
if (whoamiResponse.ok) {
user.value = await whoamiResponse.json();
}
// Load the schema
const schemaResponse = await fetch("api/schema", {
headers: provided.authHeaders.value,
});
if (schemaResponse.ok) {
schema.value = new LdapSchema(await schemaResponse.json());
}
});
watch(attr, (a) => {
if (a) oc.value = undefined;
});
watch(oc, (o) => {
if (o) attr.value = undefined;
});
</script>
<template>
<div class="w-screen h-screen">
<NavBar
v-model:treeOpen="treeOpen"
:dn="activeDn"
:base-dn="baseDn"
:user="user"
@show-modal="modal = $event"
@select-dn="activeDn = $event"
@show-oc="oc = $event"
/>
<LdifImportDialog v-model:modal="modal" @ok="activeDn = '-'" />
<div class="flex container">
<div class="space-y-4">
<!-- left column -->
<TreeView
v-model:activeDn="activeDn"
v-show="treeOpen"
@base-dn="baseDn = $event"
/>
<ObjectClassCard
v-model="oc"
@show-attr="attr = $event"
@show-oc="oc = $event"
/>
<AttributeCard v-model="attr" @show-attr="attr = $event" />
</div>
<div class="flex-auto mt-4">
<!-- main editing area -->
<Transition name="fade"
><!-- Notifications -->
<div
v-if="error"
:class="error.cssClass"
class="rounded mx-4 mb-4 p-3 border border-front/70 text-front/70 dark:text-back/70"
>
{{ error.msg }}
<span
class="float-right control"
@click="error = undefined"
></span
>
</div>
</Transition>
<EntryEditor
v-model:activeDn="activeDn"
:user="user"
@show-attr="attr = $event"
@show-oc="oc = $event"
/>
</div>
</div>
</div>
</template>
<style scoped>
.control {
@apply opacity-70 hover:opacity-90 cursor-pointer select-none leading-none pt-1 pr-1;
}
button,
.btn,
[type="button"] {
@apply px-3 py-2 rounded text-back dark:text-front font-medium outline-none;
}
button.btn {
@apply border-solid border-back border-2 focus:border-primary dark:focus:border-front;
}
select {
background: url("assets/gray_bg.svg") no-repeat right;
appearance: none;
}
.glyph {
font-family: sans-serif, FontAwesome;
font-style: normal;
}
</style>

View File

@ -18,8 +18,9 @@
</template>
<script setup lang="ts">
import { ref } from "vue";
import { inject, ref } from "vue";
import Modal from "@/components/ui/Modal.vue";
import type { Provided } from "@/data/provided";
const props = defineProps({
dn: { type: String, required: true },
@ -33,6 +34,7 @@ const props = defineProps({
});
const upload = ref<HTMLInputElement | null>(null);
const emit = defineEmits(["ok", "update:modal"]);
const app = inject<Provided>("app");
// add an image
async function onOk(evt: Event) {
@ -44,6 +46,7 @@ async function onOk(evt: Event) {
const response = await fetch("api/blob/" + props.attr + "/0/" + props.dn, {
method: "PUT",
body: fd,
headers: app?.authHeaders.value,
});
if (response.ok) {

View File

@ -31,10 +31,11 @@
</template>
<script setup lang="ts">
import { ref } from "vue";
import { inject, ref } from "vue";
import Modal from "@/components/ui/Modal.vue";
import NodeLabel from "@/components/NodeLabel.vue";
import type { TreeNode } from "@/data/treeNode";
import type { Provided } from "@/data/provided";
const props = defineProps({
dn: { type: String, required: true },
@ -43,10 +44,13 @@ const props = defineProps({
});
const subtree = ref<TreeNode[]>([]);
const emit = defineEmits(["ok", "update:modal"]);
const app = inject<Provided>("app");
// List subordinate elements to be deleted
async function init() {
const response = await fetch("api/subtree/" + props.dn);
const response = await fetch("api/subtree/" + props.dn, {
headers: app?.authHeaders.value,
});
subtree.value = (await response.json()) as TreeNode[];
}

View File

@ -53,6 +53,7 @@ async function onOk() {
const response = await fetch("api/ldif", {
method: "POST",
body: ldifData.value,
headers: app?.authHeaders.value,
});
if (response.ok) emit("ok");
}

View File

@ -104,7 +104,7 @@ async function check() {
method: "POST",
body: JSON.stringify({ check: oldPassword.value }),
headers: {
...(app?.authHeaders.value ?? {}),
...app?.authHeaders.value,
"Content-Type": "application/json",
},
});

View File

@ -1,11 +1,10 @@
import { defineConfig } from "vite";
import { fileURLToPath, URL } from "node:url";
import viteCompression from "vite-plugin-compression";
import vue from "@vitejs/plugin-vue";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue(), viteCompression()],
plugins: [vue()],
build: {
manifest: true,