Navigation Guards
Auftrag
Martina Hostettler hat zwei Wünsche aufgeschrieben.
Als Freiwillige oder Freiwilliger am Tresen möchte ich mich anmelden, damit nur das Team den Bereich für Ausleihen und Rückgaben sieht.
Als Mitglied möchte ich gewarnt werden, bevor ich ein halb ausgefülltes Formular verlasse, damit meine Eingaben nicht verloren gehen.
Die Akzeptanzkriterien lauten:
- Wer nicht mit einem Konto des Teams angemeldet ist und eine Seite des Teams öffnet, landet auf der Anmeldung.
- Nach der Anmeldung führt die App zu der Seite, die man öffnen wollte.
- Wer eine Seite mit einem halb ausgefüllten Formular verlassen will, wird gefragt und kann bleiben.
Lernziele
Nach diesem Kapitel kannst du
- den Ablauf einer Navigation mit ihren Guards beschreiben,
- einen globalen Guard mit
beforeEachund Feldern inmetaschreiben, - nach der Anmeldung zum ursprünglichen Ziel zurückführen und dabei nur Ziele in der App annehmen,
- einen Guard für eine einzelne Route mit
beforeEnterschreiben, - mit
onBeforeRouteLeavevor dem Verlassen einer Seite fragen, - erklären, warum ein Guard im Browser keine Daten schützt.
Der Ablauf einer Navigation
Abschnitt betitelt „Der Ablauf einer Navigation“Zwischen dem Klick auf einen Link und der neuen Seite fragt der Router eine Reihe von Funktionen, ob die Navigation weitergehen darf. Diese Funktionen heissen Navigation Guards. Die Dokumentation beschreibt die genaue Reihenfolge, die Abbildung zeigt die Schritte, die der Kurs braucht.
Ein Guard bekommt das Ziel to und die bisherige Route from. Gibt er nichts zurück, geht es weiter. false bricht die Navigation ab, und eine Route leitet auf eine neue Navigation um. Ältere Beispiele verwenden ein drittes Argument next. Die Dokumentation unterstützt es noch, rät aber davon ab, weil es eine häufige Quelle von Fehlern ist.
Globale Guards mit beforeEach
Abschnitt betitelt „Globale Guards mit beforeEach“router.beforeEach läuft vor jeder Navigation. Statt einer Liste von Pfaden prüft der Guard ein Feld in meta, das die Route selbst trägt. to.meta enthält laut Dokumentation die Felder aller passenden Routen, von der Elternroute bis zur Kindroute. Eine Markierung an /team gilt darum auch für /team/returns.
App.vue
<script setup>import { RouterLink, RouterView, useRoute } from 'vue-router'
import { member } from './auth.js'
const route = useRoute()
function signOut() { member.value = null}</script>
<template> <nav> <RouterLink to="/">Katalog</RouterLink> <RouterLink to="/team">Team</RouterLink> <RouterLink to="/login">Anmelden</RouterLink> <button v-if="member" type="button" @click="signOut">Abmelden</button> </nav> <p>Adresse: {{ route.fullPath }}</p> <RouterView /></template>
<style scoped>nav { display: flex; flex-wrap: wrap; gap: 1rem;}</style>auth.js
import { ref } from 'vue'
// Who is signed in, a stand-in until the association's server checks sign-insexport const member = ref(null)CatalogView.vue
<template> <h1>Katalog</h1> <p>60 Dinge zum Ausleihen.</p></template>LoginView.vue
<script setup>import { useRoute, useRouter } from 'vue-router'
import { member } from './auth.js'
const route = useRoute()const router = useRouter()
// The target comes from the address, so it is input from outside. Only paths inside the app count.function target() { const redirect = route.query.redirect return typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//') ? redirect : '/'}
function signIn() { member.value = { name: 'Martina Hostettler', team: true } router.replace(target())}</script>
<template> <h1>Anmelden</h1> <p v-if="route.query.redirect">Danach geht es weiter zu {{ route.query.redirect }}</p> <button type="button" @click="signIn">Als Martina Hostettler anmelden</button></template>main.js
import { createApp } from 'vue'
import App from './App.vue'import router from './router.js'
createApp(App).use(router).mount('#app')router.js
import { createMemoryHistory, createRouter } from 'vue-router'
import { member } from './auth.js'import CatalogView from './CatalogView.vue'import LoginView from './LoginView.vue'import TeamView from './TeamView.vue'
const router = createRouter({ history: createMemoryHistory(), routes: [ { path: '/', component: CatalogView }, { path: '/login', name: 'login', component: LoginView }, { path: '/team', component: TeamView, meta: { requiresTeam: true } }, ],})
// Runs before every navigation. Nothing lets it pass, a route location redirects.router.beforeEach((to) => { if (to.meta.requiresTeam && !member.value?.team) { return { name: 'login', query: { redirect: to.fullPath } } }})
export default routerTeamView.vue
<template> <h1>Team am Tresen</h1> <p>Nur für das Team.</p></template>Ein Klick auf «Team» führt ohne Anmeldung zu /login?redirect=/team. Der Guard gibt das Ziel als redirect in der Query mit, so weiss die Anmeldung, wohin es danach geht.
Die Anmeldung als Platzhalter
Abschnitt betitelt „Die Anmeldung als Platzhalter“Eine Anmeldung muss ein Server prüfen, und der kommt in Teil 7. Bis dahin steht in der Fallbeispiel-App ein Platzhalter in src/auth.js. Er kennt zwei Mitglieder, Martina Hostettler mit der Nummer M-117 und einem Konto des Teams und das Mitglied M-204 ohne. Angemeldet wird nur mit der Mitgliedernummer, Passwörter stehen keine im Code. Den Zustand hält das Modul selbst, und nach dem Neuladen ist die Anmeldung weg.
Ein Guard im Browser schützt keine Daten. Er sorgt dafür, dass Mitglieder nicht aus Versehen auf Seiten landen, die nicht für sie sind. Wer den Code im Browser ändert, kommt an ihm vorbei. Laut OWASP Top 10:2025 wirkt eine Zugriffskontrolle nur in Code auf einem Server, den ein Angreifer nicht verändern kann. Die Daten des Teams muss darum der Server schützen.
Zurück zum Ziel
Abschnitt betitelt „Zurück zum Ziel“Nach der Anmeldung liest die Seite das Ziel aus route.query.redirect und führt mit router.replace dorthin. replace passt, weil die Anmeldung keinen eigenen Schritt in der History braucht. Der Wert stammt aber aus der Adresse, und eine Adresse kann jeder bauen und verschicken. Das Cheat Sheet von OWASP zu Umleitungen rät, für solche Rückkehrziele nur Adressen der eigenen Website anzunehmen. Die Anmeldung prüft darum, dass das Ziel mit einem einzelnen / beginnt, und führt sonst zum Katalog.
Guards für eine Route
Abschnitt betitelt „Guards für eine Route“Ein Guard, der nur zu einer Route gehört, steht in ihr als beforeEnter. Laut Dokumentation läuft er nur beim Betreten der Route, nicht wenn sich Parameter, Query oder Hash ändern.
App.vue
<script setup>import { RouterLink, RouterView, useRoute } from 'vue-router'
import { member } from './auth.js'
const route = useRoute()
function signOut() { member.value = null}</script>
<template> <nav> <RouterLink to="/">Katalog</RouterLink> <RouterLink to="/team">Team</RouterLink> <RouterLink to="/login">Anmelden</RouterLink> <button v-if="member" type="button" @click="signOut">Abmelden</button> </nav> <p>Adresse: {{ route.fullPath }}</p> <RouterView /></template>
<style scoped>nav { display: flex; flex-wrap: wrap; gap: 1rem;}</style>auth.js
import { ref } from 'vue'
// Who is signed in, a stand-in until the association's server checks sign-insexport const member = ref(null)CatalogView.vue
<template> <h1>Katalog</h1> <p>60 Dinge zum Ausleihen.</p></template>LoginView.vue
<script setup>import { useRouter } from 'vue-router'
import { member } from './auth.js'
const router = useRouter()
function signIn() { member.value = { name: 'Martina Hostettler', team: true } router.push('/team')}</script>
<template> <h1>Anmelden</h1> <button type="button" @click="signIn">Als Martina Hostettler anmelden</button></template>main.js
import { createApp } from 'vue'
import App from './App.vue'import router from './router.js'
createApp(App).use(router).mount('#app')router.js
import { createMemoryHistory, createRouter } from 'vue-router'
import { member } from './auth.js'import CatalogView from './CatalogView.vue'import LoginView from './LoginView.vue'import TeamView from './TeamView.vue'
export default createRouter({ history: createMemoryHistory(), routes: [ { path: '/', component: CatalogView }, { path: '/login', component: LoginView, // Runs only when someone enters this route, whoever is signed in goes straight on beforeEnter: () => (member.value ? '/team' : true), }, { path: '/team', component: TeamView }, ],})TeamView.vue
<template> <h1>Team am Tresen</h1> <p>Nur für das Team.</p></template>Wer angemeldet ist, kommt mit «Anmelden» gleich in den Bereich des Teams. Für einen Guard, der nur eine Route betrifft, ist beforeEnter übersichtlicher als eine Bedingung in beforeEach. Prüft ein Guard viele Routen, passt beforeEach mit meta besser.
Warnen vor dem Verlassen
Abschnitt betitelt „Warnen vor dem Verlassen“onBeforeRouteLeave läuft, bevor jemand die Seite verlässt, auf der die Komponente steht. Laut Dokumentation funktioniert das in jeder Komponente, die innerhalb einer RouterView erscheint, auch tief verschachtelt, in der Fallbeispiel-App also im Formular für Reservationen.
App.vue
<script setup>import { RouterLink, RouterView, useRoute } from 'vue-router'
const route = useRoute()</script>
<template> <nav> <RouterLink to="/">Katalog</RouterLink> <RouterLink to="/items/6">Beamer reservieren</RouterLink> </nav> <p>Adresse: {{ route.fullPath }}</p> <RouterView /></template>
<style scoped>nav { display: flex; flex-wrap: wrap; gap: 1rem;}</style>CatalogView.vue
<template> <h1>Katalog</h1> <p>60 Dinge zum Ausleihen.</p></template>ItemView.vue
<script setup>import { computed, ref } from 'vue'import { onBeforeRouteLeave } from 'vue-router'
const note = ref('')const dirty = computed(() => note.value.trim() !== '')
// Asks before someone leaves the page with a half-filled form, false keeps the pageonBeforeRouteLeave(() => { if (dirty.value && !window.confirm('Deine Eingaben gehen verloren. Seite trotzdem verlassen?')) { return false }})</script>
<template> <h1>Beamer</h1> <label for="note">Notiz zur Reservation</label> <textarea id="note" v-model="note"></textarea></template>main.js
import { createApp } from 'vue'
import App from './App.vue'import router from './router.js'
createApp(App).use(router).mount('#app')router.js
import { createMemoryHistory, createRouter } from 'vue-router'
import CatalogView from './CatalogView.vue'import ItemView from './ItemView.vue'
export default createRouter({ history: createMemoryHistory(), routes: [ { path: '/', component: CatalogView }, { path: '/items/:id', component: ItemView }, ],})Steht etwas im Feld, fragt window.confirm, ob die Seite wirklich verlassen werden soll, und bei «Abbrechen» bleibt der Guard mit false stehen. Neuladen und Schliessen des Tabs sind keine Wechsel der Route. Für sie gibt es das Event beforeunload des Browsers, das der Kurs nicht weiter verwendet.
Im Code
Abschnitt betitelt „Im Code“Arbeite mit den Beispielen dieses Kapitels. Lies den Code und schreib auf, was du erwartest, bevor du etwas änderst.
- Klick im ersten Beispiel ohne Anmeldung auf «Team». Welche Adresse zeigt der Absatz, und was steht auf der Anmeldung?
- Lass den Guard statt der Route
falsezurückgeben. Was passiert jetzt beim Klick auf «Team»? - Melde dich im Beispiel mit
beforeEnteran, geh zurück zum Katalog und klick auf «Anmelden». - Schreib im Beispiel mit dem Formular eine Notiz und klick auf «Katalog». Antworte einmal mit «Abbrechen» und einmal mit «OK».
Aufgaben
Abschnitt betitelt „Aufgaben“Die Aufgaben bauen die Anmeldung und die Warnung aus dem Auftrag. Du findest das Projekt im Kursrepo im Ordner exercises/06-routing/guards/de. Der Platzhalter für die Anmeldung steht schon in src/auth.js. Kopiere den Ordner, installiere die Pakete und starte die Tests.
npm installnpm testAufgabe Einstieg
Schütze den Bereich des Teams mit einem globalen Guard in src/router/index.js. Wer nicht mit einem Konto des Teams angemeldet ist und eine Route mit meta.requiresTeam öffnen will, landet auf der Route login, und die Query merkt sich das Ziel als redirect.
Lösung
// Runs before every navigation. Nothing lets it pass, a route location redirects.router.beforeEach((to) => { if (to.meta.requiresTeam && !currentMember.value?.team) { // The login leads back to the target afterwards return { name: 'login', query: { redirect: to.fullPath } } }})currentMember kommt aus src/auth.js. Das Fragezeichen in currentMember.value?.team deckt den Fall ab, dass niemand angemeldet ist und der Wert null ist.
Aufgabe Vertiefung
Nach der Anmeldung führt LoginView zur Adresse aus redirect, ohne redirect zum Katalog. Nur Pfade innerhalb der App zählen, also Werte, die mit einem einzelnen / beginnen.
Lösung
// The target comes from the address, so it is input from outside. Only paths inside the app count.function target() { const redirect = route.query.redirect return typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//') ? redirect : '/'}
function submit() { if (!signIn(number.value)) { error.value = 'Diese Mitgliedernummer kennen wir nicht.' return } router.replace(target())}typeof redirect === 'string' schliesst eine fehlende Angabe aus und auch eine Query mit zwei Werten, die als Array ankommt. startsWith('//') erkennt eine Adresse ohne Protokoll, die auf einen anderen Host zeigt.
Aufgabe Knobelei
Warne vor dem Verlassen der Seite eines Dings, wenn im Formular etwas steht. Frag mit window.confirm und bleib auf der Seite, wenn die Antwort «Abbrechen» ist.
Lösung
// Something typed into the form would be lost when leaving the pageconst dirty = computed(() => Object.values(request).some((value) => value.trim() !== ''))
// Runs before the page is left, false keeps itonBeforeRouteLeave(() => { if (dirty.value && !window.confirm('Deine Eingaben gehen verloren. Seite trotzdem verlassen?')) { return false }})dirty prüft alle Felder auf einmal. Nach dem Senden leert Object.assign das Formular, und die Warnung bleibt aus. Die Tests ersetzen window.confirm mit vi.stubGlobal und antworten selbst, im Browser erscheint der Dialog.
Typische Fehler
Abschnitt betitelt „Typische Fehler“| Fehler | Was hilft |
|---|---|
Die Konsole meldet Detected a possibly infinite redirection in a navigation guard. | Der Guard leitet auch die Anmeldung selbst um. Nur bei Routen mit meta.requiresTeam umleiten und sonst nichts zurückgeben. |
next wird zweimal aufgerufen oder gar nicht, und die Navigation hängt. | Statt next einen Wert zurückgeben, false, eine Route oder nichts. |
Nach der Anmeldung landet man auf / statt auf der gewünschten Seite. | Das Ziel als redirect in der Query mitgeben und nach der Anmeldung lesen. |
| Die Warnung erscheint nicht beim Neuladen oder beim Schliessen des Tabs. | Das ist kein Wechsel der Route. Dafür gibt es das Event beforeunload des Browsers. |
| Ein KI-Assistent schützt die Daten des Teams nur mit einem Guard. | Ein Guard lenkt Mitglieder, er schützt nichts. Den Zugriff prüft der Server, siehe Teil 7. |
Selbstcheck
Abschnitt betitelt „Selbstcheck“Selbstcheck
- Was kann ein Guard zurückgeben, und was bewirkt jeder Wert?
- Warum prüft der Guard ein Feld in
metastatt einer Liste von Pfaden? - Warum prüft die Anmeldung das Ziel aus der Adresse?
- Warum schützt ein Guard im Browser keine Daten?
Antworten
- Nichts oder
truelässt die Navigation weiterlaufen,falsebricht sie ab, und eine Route leitet auf eine neue Navigation um. - Die Markierung steht an der Route selbst, gilt für ihre Kindrouten mit, und eine neue Seite des Teams braucht keine Änderung im Guard.
- Jeder kann eine Adresse mit einem Ziel bauen und verschicken. Nur Pfade der App sind als Ziel sicher.
- Der Code läuft im Browser der Person, die ihn umgehen will. Den Zugriff kann nur Code auf dem Server sicher prüfen.
Auftrag
Nur das Team kommt in seinen Bereich, und kein Formular geht mehr unbemerkt verloren. Der Freiwillige schaut sich zum Schluss an, was beim ersten Laden über das Netz kommt. «Jedes Mitglied lädt den Code für den Bereich des Teams mit, obwohl es ihn nie zu sehen bekommt. Und eine falsche Adresse zeigt eine leere Seite.» Beides löst das nächste Kapitel.
Texte und Abbildungen stehen unter CC BY-SA 4.0, der Code unter der MIT-Lizenz und die Daten des Kurses unter CC0 1.0. Die Schrift der Abbildungen ist Kalam unter der SIL Open Font License 1.1. Die Quellen liegen auf GitHub.