Zum Inhalt springen

Authentifizierung

Authentifizierung beantwortet die Frage, wer eine Anfrage schickt. Die Kurs-API aus Teil 7 simuliert eine Anmeldung mit der Mitgliedernummer, ohne Passwort. Sie zeigt trotzdem den Ablauf, den auch eine echte Anmeldung hat. Der Server prüft die Angaben, startet eine Sitzung und gibt dem Browser ein Cookie mit. Bei jeder weiteren Anfrage schickt der Browser das Cookie mit, und der Server weiss, wer sie stellt.

Laut Projektauftrag bringt eine Authentifizierung zwei Bonuspunkte. Sie lohnt sich, sobald bestimmte Daten nur angemeldeten Personen gehören, etwa eigene Einträge oder ein Bereich für ein Team. Die Anmeldung gehört ins Backend. Eine SPA allein kann niemanden sicher anmelden, denn alles, was im Browser läuft, kann die Person dort lesen und ändern. Passwörter speicherst du nie im Klartext, sondern nur als Hash mit einem Verfahren wie Argon2id, wie es OWASP empfiehlt. Für ein Projekt ist ein Anbieter für Identitäten oft der sicherere Weg.

Die Kurs-API startet die Sitzung mit POST /session und setzt ein Cookie. HttpOnly hält das Cookie von JavaScript fern, also auch von Code, den jemand über eine Lücke in die Seite schmuggelt. SameSite=Strict verhindert, dass der Browser das Cookie bei Anfragen von anderen Websites mitschickt.

app.js
const COOKIE = 'session'
// HttpOnly keeps the cookie away from JavaScript, SameSite=Strict away from other sites
const COOKIE_ATTRIBUTES = 'HttpOnly; SameSite=Strict; Path=/'
app.js
[
'POST',
/^\/session$/,
async ({ request, response, lang, text }) => {
const { number } = await readJson(request, text)
const member = state.member(lang, String(number ?? '').trim().toUpperCase())
if (member === undefined) throw new HttpError(401, { error: text.unknownMember })
const id = randomUUID()
sessions.set(id, member.number)
// Without Expires or Max-Age the cookie ends when the browser closes
response.setHeader('Set-Cookie', `${COOKIE}=${id}; ${COOKIE_ATTRIBUTES}`)
return [200, member]
},
],
['GET', /^\/session$/, ({ request, lang, text }) => [200, currentMember(request, lang, text)]],
[
'DELETE',
/^\/session$/,
({ request, response }) => {
sessions.delete(sessionId(request))
response.setHeader('Set-Cookie', `${COOKIE}=; ${COOKIE_ATTRIBUTES}; Max-Age=0`)
return [204]
},
],

Die SPA kennt die Sitzung nicht, sie hält nur fest, wer angemeldet ist. Nach dem Neuladen fragt der Store der Fallbeispiel-App die API einmal mit GET /session, wer zum Cookie gehört.

authStore.js
export const useAuthStore = defineStore('auth', () => {
const api = useApi()
const member = ref(null)
const isTeam = computed(() => member.value?.team === true)
// The question to the API after a reload, asked once
let restoring = null
// The session of the last login outlives a reload, the store does not.
// So the app asks the API once who is signed in. Every caller gets the same answer.
function restore() {
restoring ??= api.restoreSession().then(({ ok, data }) => {
if (ok) member.value = data
})
return restoring
}
// The API checks the member number and starts the session.
// Returns the message of the API for a failed login and an empty text when it worked.
async function signIn(number) {
const { ok, data } = await api.signIn(number)
if (!ok) return data.error
member.value = data
return ''
}
async function signOut() {
await api.signOut()
member.value = null
}
return { member, isTeam, restore, signIn, signOut }
})

Der Guard des Routers schickt Personen ohne Team-Konto zur Anmeldung und danach zurück. Er sorgt nur dafür, dass die Oberfläche passt.

index.js
// The team area needs a team account. Everybody else signs in first and comes back afterwards.
router.beforeEach(async (to) => {
if (!to.meta.requiresTeam) return
// The store is called inside the guard, when Pinia is installed. After a reload the guard
// waits until the API has said who is signed in.
const auth = useAuthStore()
await auth.restore()
if (!auth.isTeam) return { name: 'login', query: { redirect: to.fullPath } }
})

Schützen muss die Daten der Server. Das Authorization Cheat Sheet von OWASP verlangt, jede Anfrage auf dem Server zu prüfen und sich nie auf Prüfungen im Browser zu verlassen. Die Kurs-API sucht deshalb bei jeder Anfrage die Sitzung zum Cookie. Ohne Sitzung antwortet sie mit 401, und eine Rückgabe darf nur das Team erfassen, sonst kommt 403.

app.js
function currentMember(request, lang, text) {
const number = sessions.get(sessionId(request))
if (number === undefined) throw new HttpError(401, { error: text.notSignedIn })
return state.member(lang, number)
}
app.js
[
'POST',
/^\/items\/(\d+)\/return$/,
({ request, lang, text, params }) => {
if (!currentMember(request, lang, text).team) throw new HttpError(403, { error: text.teamOnly })
const item = existingItem(lang, params[0], text)
if (item.availableFrom === null) throw new HttpError(409, { error: text.notOnLoan })
state.markReturned(item.id)
return [200, state.item(lang, item.id)]
},

Echte Anmeldungen laufen oft über einen Anbieter für Identitäten mit OAuth 2.0 und OpenID Connect, einer Schicht über OAuth, die festhält, wer sich angemeldet hat. Für SPAs beschreibt RFC 10017 vom August 2026 drei Muster, geordnet nach ihrer Sicherheit. Am sichersten ist ein Backend for Frontend, ein eigener Server, der die Tokens hält, der SPA nur eine Sitzung im Cookie gibt und die Aufrufe der API weiterreicht. Holt die SPA die Tokens selbst, muss sie PKCE nach RFC 7636 verwenden. Ein Token oder eine Session-ID gehört laut OWASP nie in localStorage oder sessionStorage, denn dort kann jedes Skript der Seite sie lesen.

Liegen SPA und API auf verschiedenen Sites, etwa die SPA auf github.io und die API bei einem anderen Anbieter, schickt der Browser ein Cookie mit SameSite=Strict oder Lax nicht mit. Das Cookie braucht dann SameSite=None und Secure, und die API einen Schutz gegen Cross-Site Request Forgery, wie ihn OWASP beschreibt. Einfacher ist es, SPA und API unter derselben Domain zu betreiben.

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.