🛂 Add key-based permission system

This commit is contained in:
2023-06-20 19:53:34 +02:00
parent 917783d114
commit 5d9317ac01
11 changed files with 432 additions and 17 deletions

View File

@ -101,6 +101,9 @@ async function checkLogin(req, res, next) {
res.sendStatus(401);
return;
}
req.locals = {
session: session.token,
};
renewSession(session);
next();
}

View File

@ -1,6 +1,60 @@
import Prisma from "@prisma/client";
const prisma = new Prisma.PrismaClient();
import {
applyKey,
hasPermission,
listPermissions,
revokeKey,
} from "./permission.js";
// Get info API endpoint (/api/info)
// Returns information about the requesting session
export async function getInfo(req, res) {
const session = await prisma.session.findUnique({
where: {
token: req.locals.session,
},
include: {
appliedKeys: {
select: {
key: true,
permissions: true,
validUntil: true,
},
},
},
});
res.send({
authenticated: true,
appliedKeys: session.appliedKeys,
permissions: await listPermissions(session.token),
});
}
// Put and Delete key API endpoints (/api/key)
// Applies or revokes a key from the requesting user's session
export async function putKey(req, res) {
if (await applyKey(req.locals.session, req.query.key)) {
res.status(200).send();
} else {
res.status(400).send({
success: false,
error: "invalid_key",
message: "This key does not exist",
});
}
}
export async function deleteKey(req, res) {
if (await revokeKey(req.locals.session, req.query.key)) {
res.status(200).send();
} else {
res.status(400).send();
}
}
// Get timetable API endpoint (/api/timetable)
// Returns timetable data for requested class if available
export async function getTimetable(req, res) {

95
server/api/permission.js Normal file
View File

@ -0,0 +1,95 @@
import Prisma from "@prisma/client";
import { log } from "../logs.js";
const prisma = new Prisma.PrismaClient();
export async function listPermissions(sessionToken) {
const session = await prisma.session.findUnique({
where: {
token: sessionToken,
},
include: {
appliedKeys: "true",
},
});
if (!session) return [];
const perms = [];
for (const key of session.appliedKeys) {
if (key.validUntil && new Date() > key.validUntil) continue;
for (const perm of key.permissions) {
perms.push(perm);
}
}
return perms;
}
export async function hasPermission(sessionToken, permission, forValue) {
let hasPermission = false;
for (const perm of await listPermissions(sessionToken)) {
if (perm == permission) hasPermission = true;
else if (perm == permission + ":" + forValue) hasPermission = true;
}
return hasPermission;
}
export async function applyKey(sessionToken, key) {
if (!key) return false;
const foundKey = await prisma.key.findUnique({
where: {
key,
},
});
if (!foundKey) return false;
await prisma.session.update({
where: {
token: sessionToken,
},
data: {
appliedKeys: {
connect: {
key: foundKey.key,
},
},
},
});
return true;
}
export async function revokeKey(sessionToken, key) {
if (!key) return false;
await prisma.session.update({
where: {
token: sessionToken,
},
data: {
appliedKeys: {
disconnect: {
key: key,
},
},
},
});
return true;
}
// Clean up expired keys every hour
setInterval(async () => {
const keys = await prisma.key.findMany();
for (const key of keys) {
if (key.validUntil && key.validUntil < new Date()) {
log(
"API / Permissions",
`Removed expired key: ${key.key}; Permissions: ${key.permissions.join(
", "
)}`
);
await prisma.key.delete({
where: {
key: key.key,
},
});
}
}
}, 1000 * 60 * 60);

View File

@ -8,6 +8,9 @@ import {
getSubstitutions,
getHistory,
getClasses,
getInfo,
putKey,
deleteKey,
} from "./api/index.js";
import auth from "./api/auth.js";
import { Parser } from "./parser/index.js";
@ -30,6 +33,7 @@ const port = process.env.PORT || 3000;
app.use(cors());
app.use(cookieParser());
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
// Initialize the Parser and set it to update the
// substitution plan at the specified update interval
@ -55,6 +59,9 @@ app.get("/api/check", (_req, res) => {
});
// Register API endpoints
app.get("/api/info", getInfo);
app.put("/api/key", putKey);
app.delete("/api/key", deleteKey);
app.get("/api/timetable", getTimetable);
app.get("/api/substitutions", getSubstitutions);
app.get("/api/history", getHistory);

View File

@ -73,7 +73,18 @@ model Time {
}
model Session {
token String @id @unique @default(uuid())
createdAt DateTime @default(now())
validUntil DateTime
token String @id @unique @default(uuid())
createdAt DateTime @default(now())
validUntil DateTime
appliedKeys Key[]
}
model Key {
key String @id @unique @default(uuid())
createdAt DateTime? @default(now())
validUntil DateTime?
permissions String[]
notes String?
Session Session? @relation(fields: [sessionToken], references: [token])
sessionToken String?
}

View File

@ -0,0 +1,72 @@
<script setup>
import { TrashIcon, AlertCircleIcon } from "lucide-vue-next";
import { ref, watch } from "vue";
const deleteConfirm = ref(false);
watch(deleteConfirm, (value) => {
if (value) {
setTimeout(() => (deleteConfirm.value = false), 2000);
}
});
defineProps(["keyData"]);
defineEmits(["delete"]);
</script>
<template>
<div class="card">
<div class="info">
<p class="key">{{ keyData.key }}</p>
<div class="permissions">
<div
class="permission"
v-for="permission in keyData.permissions"
:key="permission"
>
{{ permission }}
</div>
</div>
</div>
<div class="button">
<TrashIcon v-if="!deleteConfirm" @click="deleteConfirm = true" />
<AlertCircleIcon color="red" v-else @click="$emit('delete')" />
</div>
</div>
</template>
<style scoped>
.card {
background-color: var(--element-color);
border-radius: 10px;
display: grid;
grid-template-columns: minmax(90%, auto) 50px;
align-items: center;
}
.key {
font-family: monospace;
font-size: 15px;
padding: 15px 10px 0px 10px;
}
.permissions {
display: flex;
flex-direction: row;
gap: 10px;
padding: 5px 10px 0px 10px;
user-select: none;
max-width: 90%;
overflow-x: scroll;
}
.permission {
padding: 10px;
background-color: var(--element-color-hover);
border-radius: 5px;
}
.button {
margin-right: 20px;
cursor: pointer;
}
</style>

View File

@ -12,6 +12,7 @@ import TimetableSettings from "@/views/settings/TimetableSettings.vue";
import TimetableEditor from "@/views/settings/TimetableEditor.vue";
import TimetableGroupSettings from "@/views/settings/TimetableGroupSettings.vue";
import AppearanceSettings from "@/views/settings/AppearanceSettings.vue";
import KeySettings from "@/views/settings/KeySettings.vue";
import AboutPage from "@/views/settings/AboutPage.vue";
const router = createRouter({
@ -70,6 +71,11 @@ const router = createRouter({
name: "title.settings.appearance",
component: AppearanceSettings,
},
{
path: "keys",
name: "title.settings.keys",
component: KeySettings,
},
{
path: "about",
name: "title.settings.about",

View File

@ -72,6 +72,7 @@ export const timetable = computed(() => {
localTimetable || remoteTimetable || { trusted: true, source: "", data: [] }
);
});
export const sessionInfo = ref({});
export const timetables = ref([]);
export const times = ref([]);
export const substitutions = ref({});
@ -81,7 +82,7 @@ export const classList = ref([]);
/* API functions */
// Set the `VITE_API_ENDPOINT` env variable when
// building the frontend to use an external api server
const baseUrl = import.meta.env.VITE_API_ENDPOINT || "/api";
export const baseUrl = import.meta.env.VITE_API_ENDPOINT || "/api";
export async function fetchData(days, partial) {
if (!timetable.value.data || Object.keys(classList.value).length == 0) {
@ -96,23 +97,12 @@ export async function fetchData(days, partial) {
// Check if the API server is reachable
// and the user is authenticated
try {
const checkResponse = await fetch(`${baseUrl}/check`);
if (checkResponse.status == 401) {
shouldLogin.value = true;
return;
} else if (checkResponse.status != 200) {
loadingFailed.value = true;
loadingProgress.value = 1;
console.log("Other error while fetching data: " + checkResponse.status);
return;
}
} catch {
if (!(await fetchSessionInfo())) {
loadingFailed.value = true;
loadingProgress.value = 1;
console.log("Error while fetching data: No internet connection!");
return;
}
loadingProgress.value = step++ / steps;
if (!partial) {
@ -137,6 +127,25 @@ watch(selectedDate, () =>
fetchData(getNextAndPrevDay(selectedDate.value), true)
);
export async function fetchSessionInfo() {
try {
const checkResponse = await fetch(`${baseUrl}/info`);
if (checkResponse.status == 401) {
shouldLogin.value = true;
return false;
} else if (checkResponse.status != 200) {
console.log("Other error while fetching data: " + checkResponse.status);
return false;
} else {
sessionInfo.value = await checkResponse.json();
}
} catch {
console.log("Error while fetching data: No internet connection!");
return false;
}
return true;
}
export async function fetchClassList() {
const classListResponse = await fetch(`${baseUrl}/classes`);
const classListData = await classListResponse.json();

View File

@ -13,6 +13,7 @@ export const strings = {
timetable: "Manage Timetables",
groups: "Timetable Groups",
appearance: "Appearance",
keys: "Manage Keys",
about: "About",
},
},
@ -25,6 +26,7 @@ export const strings = {
language: "Language",
about: "About",
theme: "Theme",
keys: "Manage Keys",
},
text: {
filtering:
@ -38,12 +40,15 @@ export const strings = {
"This Tool queries and parses the latest substitution-plan data every minute. The correctness of the data can in no way be guaranteed, so please check the data against the official plan if something seems wrong! Due to the format of the plan files, it it sometimes not easily possible to extract the correct data, so the plan displayed here may not be correct.",
theme:
"Select a Theme to change the colors of the app. The 'Auto' option selects a theme based on your system preferences.",
keys: "Keys are used to give you special permissions, for example editing a timetable. You can enter keys that you received here.",
},
other: "Other",
back: "Back",
none: "None",
version: "Version",
source: "Source",
key: "Key",
invalidKey: "This key does not exist!",
theme: {
auto: "Auto",
dark: "Dark",
@ -130,6 +135,7 @@ export const strings = {
timetable: "Stundenpläne Verwalten",
groups: "Stundenplan-Gruppen",
appearance: "Aussehen",
keys: "Schlüssel Verwalten",
about: "Über",
},
},
@ -142,6 +148,7 @@ export const strings = {
language: "Sprache",
about: "Über diese Anwendung",
theme: "Farbschema",
keys: "Schlüssel Verwalten",
},
text: {
filtering:
@ -155,12 +162,15 @@ export const strings = {
"Diese Anwendung fragt jede Minute die neusten Vertretungsplan-Daten an und verarbeitet sie, um sie dir schöner und besser lesbar anzuzeigen. Die Richtigkeit dieser Daten kann nicht garantiert werden, da es manchmal kompliziert ist, die richtigen Daten zu extrahieren. Wenn etwas nicht richtig aussieht, überprüfe es bitte auf dem offiziellen Plan!",
theme:
"Wähle ein Farbschema aus, um die Farben dieser Anwendung anzupassen. Die Option 'Automatisch' wählt ein Farbschema basierend auf den Einstellungen deines Systems aus.",
keys: "Schlüssel können dir erweiterte rechte geben, zum Beispiel zum editieren eines Stundenplans. Wenn du einen Schlüssel bekommen hast, kannst du ihn hier eingeben.",
},
other: "Andere",
back: "Zurück",
none: "Keine",
version: "Version",
source: "Quelle",
key: "Schlüssel",
invalidKey: "Dieser Schlüssel existiert nicht!",
theme: {
auto: "Automatisch",
dark: "Dunkel",

View File

@ -6,6 +6,7 @@ import {
CalendarIcon,
CopyCheckIcon,
PaletteIcon,
KeyRoundIcon,
InfoIcon,
ChevronLeft,
} from "lucide-vue-next";
@ -35,6 +36,11 @@ import {
:icon="PaletteIcon"
route="settings/appearance"
/>
<PageCard
:name="$t('title.settings.keys')"
:icon="KeyRoundIcon"
route="settings/keys"
/>
<PageCard
:name="$t('title.settings.about')"
:icon="InfoIcon"

View File

@ -0,0 +1,142 @@
<script setup>
import {
baseUrl,
sessionInfo,
fetchSessionInfo,
loading,
loadingProgress,
} from "@/store";
import KeyCard from "@/components/settings/key-card.vue";
import { ref } from "vue";
import { AlertTriangleIcon, SendIcon } from "lucide-vue-next";
const key = ref();
const invalidKey = ref(false);
async function applyKey() {
loadingProgress.value = 0.1;
loading.value = true;
const result = await fetch(
baseUrl + "/key?key=" + encodeURIComponent(key.value),
{
method: "put",
}
);
loadingProgress.value = 0.5;
if (result.status == 400) {
invalidKey.value = true;
} else if (result.status != 200) {
alert("Error while applying key!");
console.warn(result.body);
} else {
await fetchSessionInfo();
}
loadingProgress.value = 1;
loading.value = false;
}
async function revokeKey(key) {
loadingProgress.value = 0.1;
loading.value = true;
const result = await fetch(baseUrl + "/key?key=" + encodeURIComponent(key), {
method: "delete",
});
loadingProgress.value = 0.5;
if (result.status != 200) {
alert("Error while revoking key!");
console.warn(result.body);
} else {
await fetchSessionInfo();
}
loadingProgress.value = 1;
loading.value = false;
}
</script>
<template>
<h2>{{ $t("settings.heading.keys") }}</h2>
<p>{{ $t("settings.text.keys") }}</p>
<div class="input">
<input
type="text"
:placeholder="$t('settings.key')"
v-model="key"
@keyup.enter="applyKey"
@keydown="invalidKey = false"
/>
<div class="button" @click="applyKey">
<SendIcon />
</div>
</div>
<div class="invalidKey" v-if="invalidKey">
<AlertTriangleIcon />
<span>{{ $t("settings.invalidKey") }}</span>
</div>
<div class="list">
<KeyCard
v-for="key in sessionInfo.appliedKeys"
:key="key"
:keyData="key"
@delete="() => revokeKey(key.key)"
/>
</div>
</template>
<style scoped>
h2 {
margin: 0px;
}
p {
margin: 5px 0px;
}
.invalidKey {
color: red;
display: flex;
align-items: center;
gap: 5px;
padding: 0px 0px 15px 5px;
}
.input {
padding: 10px 0px;
display: grid;
grid-template-columns: 1fr 50px;
grid-auto-rows: 1fr;
align-items: center;
gap: 10px;
}
.input > * {
background-color: var(--element-color);
border-radius: 7px;
padding: 10px;
transition: 0.2s background-color;
}
.input > *:hover {
background-color: var(--element-color-hover);
}
.input input {
border: none;
color: var(--text-color);
font-size: 15px;
font-family: monospace;
height: 24px;
}
.button {
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
}
.list {
display: flex;
flex-direction: column;
gap: 10px;
}
</style>