|
|
|
@@ -0,0 +1,464 @@
|
|
|
|
|
import 'server-only'
|
|
|
|
|
import {
|
|
|
|
|
generateAuthenticationOptions,
|
|
|
|
|
generateRegistrationOptions,
|
|
|
|
|
verifyAuthenticationResponse,
|
|
|
|
|
verifyRegistrationResponse,
|
|
|
|
|
} from '@simplewebauthn/server'
|
|
|
|
|
import type {
|
|
|
|
|
AuthenticationResponseJSON,
|
|
|
|
|
AuthenticatorTransportFuture,
|
|
|
|
|
PublicKeyCredentialCreationOptionsJSON,
|
|
|
|
|
PublicKeyCredentialRequestOptionsJSON,
|
|
|
|
|
RegistrationResponseJSON,
|
|
|
|
|
} from '@simplewebauthn/server'
|
|
|
|
|
import type { Passkey, PasskeyAnfrageArt, User } from '@prisma/client'
|
|
|
|
|
import { db } from '@/lib/db'
|
|
|
|
|
import { env } from '@/lib/env'
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Passkeys (WebAuthn/FIDO2) als zweiter Anmeldeweg.
|
|
|
|
|
*
|
|
|
|
|
* ---------------------------------------------------------------------------
|
|
|
|
|
* Warum ein Passkey hier den zweiten Faktor ERSETZEN darf
|
|
|
|
|
* ---------------------------------------------------------------------------
|
|
|
|
|
*
|
|
|
|
|
* Die Zusage dieser Anwendung lautet: zwei Faktoren, nicht abschaltbar. Ein
|
|
|
|
|
* Passkey sieht auf den ersten Blick nach einem einzigen Klick aus - er ist
|
|
|
|
|
* aber zwei Faktoren, sofern der Authenticator die Nutzerverifikation
|
|
|
|
|
* durchfuehrt:
|
|
|
|
|
*
|
|
|
|
|
* 1. Besitz - der private Schluessel liegt im Sicherheitsbaustein des
|
|
|
|
|
* Geraets und laesst sich nicht auslesen.
|
|
|
|
|
* 2. Wissen - Geraete-PIN
|
|
|
|
|
* oder Sein - Fingerabdruck, Gesicht.
|
|
|
|
|
*
|
|
|
|
|
* Diese Bedingung wird nicht gehofft, sondern erzwungen: bei der Anmeldung
|
|
|
|
|
* ebenso wie beim Einrichten steht userVerification auf "required", und die
|
|
|
|
|
* Pruefung laeuft mit requireUserVerification. Ein Authenticator, der nur
|
|
|
|
|
* Anwesenheit meldet (blosses Antippen), wird abgewiesen.
|
|
|
|
|
*
|
|
|
|
|
* Zusaetzlich ist ein Passkey dem Zahlenkomma aus Passwort + TOTP in einem
|
|
|
|
|
* Punkt ueberlegen: Er ist an die Domain gebunden. Eine nachgebaute
|
|
|
|
|
* Anmeldeseite bekommt keine Signatur - Phishing, der haeufigste Weg an
|
|
|
|
|
* einen TOTP-Code zu kommen, laeuft ins Leere.
|
|
|
|
|
*
|
|
|
|
|
* ---------------------------------------------------------------------------
|
|
|
|
|
* Warum TOTP trotzdem zuerst eingerichtet sein muss
|
|
|
|
|
* ---------------------------------------------------------------------------
|
|
|
|
|
*
|
|
|
|
|
* Ein Passkey haengt am Geraet. Geht das Handy verloren und ist es der
|
|
|
|
|
* einzige Zugangsweg, ist die Akte zu - und in ihr stehen Unterlagen, die
|
|
|
|
|
* eine betreute Person braucht. Deshalb laesst sich ein Passkey erst
|
|
|
|
|
* anlegen, wenn der zweite Faktor samt Wiederherstellungscodes steht. Der
|
|
|
|
|
* Passkey ist die bequeme Tuer, nicht die einzige.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Wie lange eine begonnene Passkey-Aktion abgeschlossen werden kann.
|
|
|
|
|
*
|
|
|
|
|
* Fuenf Minuten sind reichlich fuer "Handy suchen, Finger auflegen" und
|
|
|
|
|
* kurz genug, dass eine abgefangene Challenge nichts mehr wert ist.
|
|
|
|
|
*/
|
|
|
|
|
export const ANFRAGE_GUELTIG_SEKUNDEN = 300
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Obergrenze je Benutzer. Nicht aus technischen Gruenden - die Liste soll
|
|
|
|
|
* uebersichtlich bleiben, damit ein fremder Eintrag auffaellt.
|
|
|
|
|
*/
|
|
|
|
|
export const MAX_PASSKEYS_JE_BENUTZER = 10
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Obergrenze fuer gleichzeitig offene Anmeldeversuche.
|
|
|
|
|
*
|
|
|
|
|
* Eine Anmeldung darf jeder beginnen, auch ohne angemeldet zu sein - anders
|
|
|
|
|
* ginge es nicht. Damit daraus kein Weg wird, die Tabelle vollzuschreiben,
|
|
|
|
|
* gilt eine Decke. Fuer eine Handvoll Benutzer ist sie unerreichbar; fuer
|
|
|
|
|
* ein Skript ist sie schnell erreicht, und dann ist Schluss.
|
|
|
|
|
*/
|
|
|
|
|
export const MAX_OFFENE_ANMELDUNGEN = 200
|
|
|
|
|
|
|
|
|
|
export type PasskeyGrund =
|
|
|
|
|
| 'nicht-verfuegbar'
|
|
|
|
|
| 'anfrage-abgelaufen'
|
|
|
|
|
| 'unbekannt'
|
|
|
|
|
| 'nicht-verifiziert'
|
|
|
|
|
| 'geklont'
|
|
|
|
|
| 'bereits-vorhanden'
|
|
|
|
|
| 'zu-viele'
|
|
|
|
|
| 'zweiter-faktor-fehlt'
|
|
|
|
|
| 'zu-viele-anfragen'
|
|
|
|
|
| 'ungueltig'
|
|
|
|
|
|
|
|
|
|
/** Traegt einen Grund, damit die Oberflaeche einen brauchbaren Satz daraus macht. */
|
|
|
|
|
export class PasskeyFehler extends Error {
|
|
|
|
|
constructor(
|
|
|
|
|
readonly grund: PasskeyGrund,
|
|
|
|
|
nachricht?: string,
|
|
|
|
|
) {
|
|
|
|
|
super(nachricht ?? grund)
|
|
|
|
|
this.name = 'PasskeyFehler'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const PASSKEY_MELDUNG: Record<PasskeyGrund, string> = {
|
|
|
|
|
'nicht-verfuegbar':
|
|
|
|
|
'Passkeys brauchen eine verschlüsselte Verbindung. Diese Installation läuft nicht über https — ' +
|
|
|
|
|
'melde dich mit Passwort und Code an.',
|
|
|
|
|
'anfrage-abgelaufen':
|
|
|
|
|
'Das hat zu lange gedauert. Bitte klicke noch einmal auf „Mit Passkey anmelden“.',
|
|
|
|
|
unbekannt:
|
|
|
|
|
'Dieser Passkey ist hier nicht hinterlegt. Vielleicht gehört er zu einer anderen Adresse ' +
|
|
|
|
|
'oder wurde inzwischen entfernt.',
|
|
|
|
|
'nicht-verifiziert':
|
|
|
|
|
'Dein Gerät hat den Passkey ohne Fingerabdruck, Gesichtserkennung oder PIN freigegeben. ' +
|
|
|
|
|
'Das wäre nur ein Faktor — und hier liegen Gesundheitsdaten. Bitte richte die Gerätesperre ein.',
|
|
|
|
|
geklont:
|
|
|
|
|
'Der Zähler dieses Passkeys ist nicht weitergelaufen. Das kann auf eine Kopie hindeuten. ' +
|
|
|
|
|
'Der Passkey wurde nicht akzeptiert — bitte entferne ihn und richte einen neuen ein.',
|
|
|
|
|
'bereits-vorhanden': 'Dieser Passkey ist bereits hinterlegt.',
|
|
|
|
|
'zu-viele': `Es sind höchstens ${MAX_PASSKEYS_JE_BENUTZER} Passkeys möglich. Entferne zuerst einen alten.`,
|
|
|
|
|
'zweiter-faktor-fehlt':
|
|
|
|
|
'Richte zuerst die Authenticator-App ein. Ein Passkey hängt an einem Gerät — ohne einen ' +
|
|
|
|
|
'zweiten Weg wäre der Zugang bei Verlust zu.',
|
|
|
|
|
'zu-viele-anfragen':
|
|
|
|
|
'Gerade laufen ungewöhnlich viele Anmeldeversuche. Bitte versuche es in ein paar Minuten ' +
|
|
|
|
|
'noch einmal oder melde dich mit Passwort und Code an.',
|
|
|
|
|
ungueltig: 'Der Passkey konnte nicht geprüft werden. Bitte versuche es noch einmal.',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Kopiert Bytes in ein Uint8Array mit eigenem, nicht geteiltem Puffer.
|
|
|
|
|
*
|
|
|
|
|
* Prisma liefert einen Buffer, TextEncoder ein Uint8Array<ArrayBufferLike> -
|
|
|
|
|
* die Bibliothek verlangt ausdruecklich Uint8Array<ArrayBuffer>. Der
|
|
|
|
|
* Unterschied ist nicht kosmetisch: ArrayBufferLike schliesst
|
|
|
|
|
* SharedArrayBuffer ein, und auf geteiltem Speicher darf keine
|
|
|
|
|
* Signaturpruefung arbeiten, weil sich der Inhalt waehrenddessen aendern
|
|
|
|
|
* koennte.
|
|
|
|
|
*/
|
|
|
|
|
function alsUint8(daten: Uint8Array): Uint8Array<ArrayBuffer> {
|
|
|
|
|
const kopie = new Uint8Array(new ArrayBuffer(daten.byteLength))
|
|
|
|
|
kopie.set(daten)
|
|
|
|
|
return kopie
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Holt die Challenge aus der Antwort des Browsers.
|
|
|
|
|
*
|
|
|
|
|
* Sie steckt in den clientData, die der Authenticator mitsigniert hat. Wir
|
|
|
|
|
* lesen sie hier nur, um die passende Zeile zu finden - geprueft wird sie
|
|
|
|
|
* anschliessend von der Bibliothek gegen genau diesen Wert.
|
|
|
|
|
*
|
|
|
|
|
* Exportiert, damit sie sich ohne Datenbank pruefen laesst.
|
|
|
|
|
*/
|
|
|
|
|
export function challengeAusAntwort(clientDataJSON: string): string {
|
|
|
|
|
try {
|
|
|
|
|
const roh = Buffer.from(clientDataJSON, 'base64url').toString('utf8')
|
|
|
|
|
const gelesen: unknown = JSON.parse(roh)
|
|
|
|
|
const challenge =
|
|
|
|
|
typeof gelesen === 'object' && gelesen !== null && 'challenge' in gelesen
|
|
|
|
|
? (gelesen as { challenge: unknown }).challenge
|
|
|
|
|
: null
|
|
|
|
|
if (typeof challenge !== 'string' || challenge.length === 0) {
|
|
|
|
|
throw new PasskeyFehler('ungueltig')
|
|
|
|
|
}
|
|
|
|
|
return challenge
|
|
|
|
|
} catch (fehler) {
|
|
|
|
|
if (fehler instanceof PasskeyFehler) throw fehler
|
|
|
|
|
throw new PasskeyFehler('ungueltig')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Legt eine Challenge ab. Nur der Server kennt sie, bis der Browser antwortet. */
|
|
|
|
|
async function anfrageMerken(
|
|
|
|
|
challenge: string,
|
|
|
|
|
art: PasskeyAnfrageArt,
|
|
|
|
|
userId: string | null,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
await db.passkeyAnfrage.create({
|
|
|
|
|
data: {
|
|
|
|
|
challenge,
|
|
|
|
|
art,
|
|
|
|
|
userId,
|
|
|
|
|
ablaufAm: new Date(Date.now() + ANFRAGE_GUELTIG_SEKUNDEN * 1000),
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Verbraucht eine Challenge - und zwar genau einmal.
|
|
|
|
|
*
|
|
|
|
|
* Das DELETE ist der eigentliche Schutz gegen das erneute Einspielen einer
|
|
|
|
|
* mitgeschnittenen Antwort: Die Datenbank entscheidet, wer die Zeile
|
|
|
|
|
* bekommt. Wurde keine Zeile geloescht, war sie schon verbraucht oder
|
|
|
|
|
* abgelaufen - beides fuehrt zum Abbruch.
|
|
|
|
|
*/
|
|
|
|
|
async function anfrageVerbrauchen(
|
|
|
|
|
challenge: string,
|
|
|
|
|
art: PasskeyAnfrageArt,
|
|
|
|
|
userId?: string,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const { count } = await db.passkeyAnfrage.deleteMany({
|
|
|
|
|
where: {
|
|
|
|
|
challenge,
|
|
|
|
|
art,
|
|
|
|
|
ablaufAm: { gt: new Date() },
|
|
|
|
|
...(userId ? { userId } : {}),
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
if (count !== 1) throw new PasskeyFehler('anfrage-abgelaufen')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Wirft, wenn die Installation gar nicht ueber https erreichbar ist. */
|
|
|
|
|
function verfuegbarkeitPruefen(): void {
|
|
|
|
|
if (!env.passkey.sicherAufrufbar) throw new PasskeyFehler('nicht-verfuegbar')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Ob Passkeys unter der aktuellen Adresse ueberhaupt angeboten werden koennen. */
|
|
|
|
|
export function passkeysMoeglich(): boolean {
|
|
|
|
|
return env.passkey.sicherAufrufbar
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Einrichten
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
export async function registrierungVorbereiten(
|
|
|
|
|
benutzer: User,
|
|
|
|
|
): Promise<PublicKeyCredentialCreationOptionsJSON> {
|
|
|
|
|
verfuegbarkeitPruefen()
|
|
|
|
|
|
|
|
|
|
// Ohne Authenticator-App kein Passkey - siehe Kopfkommentar.
|
|
|
|
|
if (!benutzer.totpAktiviertAm) throw new PasskeyFehler('zweiter-faktor-fehlt')
|
|
|
|
|
|
|
|
|
|
const vorhandene = await db.passkey.findMany({
|
|
|
|
|
where: { userId: benutzer.id },
|
|
|
|
|
select: { credentialId: true, transports: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (vorhandene.length >= MAX_PASSKEYS_JE_BENUTZER) throw new PasskeyFehler('zu-viele')
|
|
|
|
|
|
|
|
|
|
const optionen = await generateRegistrationOptions({
|
|
|
|
|
rpName: env.passkey.rpName,
|
|
|
|
|
rpID: env.passkey.rpId,
|
|
|
|
|
// Stabil aus der Benutzer-ID. Damit erkennt ein Geraet beim zweiten
|
|
|
|
|
// Einrichten, dass es derselbe Zugang ist, und ersetzt den Eintrag,
|
|
|
|
|
// statt einen zweiten anzulegen.
|
|
|
|
|
userID: alsUint8(new TextEncoder().encode(benutzer.id)),
|
|
|
|
|
userName: benutzer.email,
|
|
|
|
|
userDisplayName: benutzer.name,
|
|
|
|
|
// Keine Attestierung anfordern: Wir wollen nicht wissen, welches
|
|
|
|
|
// Fabrikat der Benutzer benutzt. Das ist ein Datum, das uns nichts
|
|
|
|
|
// angeht, und es macht den Ablauf auf manchen Geraeten umstaendlicher.
|
|
|
|
|
attestationType: 'none',
|
|
|
|
|
// Schon hinterlegte Schluessel ausschliessen, sonst legt dasselbe
|
|
|
|
|
// Geraet stillschweigend einen zweiten an.
|
|
|
|
|
excludeCredentials: vorhandene.map((eintrag) => ({
|
|
|
|
|
id: eintrag.credentialId,
|
|
|
|
|
transports: eintrag.transports as AuthenticatorTransportFuture[],
|
|
|
|
|
})),
|
|
|
|
|
authenticatorSelection: {
|
|
|
|
|
// "required": der Schluessel wird auf dem Geraet auffindbar
|
|
|
|
|
// gespeichert. Nur dann geht die Anmeldung ohne vorherige Eingabe
|
|
|
|
|
// der E-Mail-Adresse.
|
|
|
|
|
residentKey: 'required',
|
|
|
|
|
requireResidentKey: true,
|
|
|
|
|
// Der Punkt, an dem aus einem Faktor zwei werden.
|
|
|
|
|
userVerification: 'required',
|
|
|
|
|
},
|
|
|
|
|
timeout: ANFRAGE_GUELTIG_SEKUNDEN * 1000,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await anfrageMerken(optionen.challenge, 'REGISTRIERUNG', benutzer.id)
|
|
|
|
|
return optionen
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function registrierungPruefen(
|
|
|
|
|
benutzer: User,
|
|
|
|
|
name: string,
|
|
|
|
|
antwort: RegistrationResponseJSON,
|
|
|
|
|
): Promise<Passkey> {
|
|
|
|
|
verfuegbarkeitPruefen()
|
|
|
|
|
if (!benutzer.totpAktiviertAm) throw new PasskeyFehler('zweiter-faktor-fehlt')
|
|
|
|
|
|
|
|
|
|
const challenge = challengeAusAntwort(antwort.response.clientDataJSON)
|
|
|
|
|
await anfrageVerbrauchen(challenge, 'REGISTRIERUNG', benutzer.id)
|
|
|
|
|
|
|
|
|
|
let ergebnis
|
|
|
|
|
try {
|
|
|
|
|
ergebnis = await verifyRegistrationResponse({
|
|
|
|
|
response: antwort,
|
|
|
|
|
expectedChallenge: challenge,
|
|
|
|
|
expectedOrigin: env.passkey.herkunft,
|
|
|
|
|
expectedRPID: env.passkey.rpId,
|
|
|
|
|
requireUserVerification: true,
|
|
|
|
|
})
|
|
|
|
|
} catch {
|
|
|
|
|
throw new PasskeyFehler('ungueltig')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!ergebnis.verified) throw new PasskeyFehler('ungueltig')
|
|
|
|
|
|
|
|
|
|
const { credential, credentialDeviceType, credentialBackedUp, userVerified } =
|
|
|
|
|
ergebnis.registrationInfo
|
|
|
|
|
|
|
|
|
|
// Doppelte Absicherung: die Bibliothek prueft es bereits, aber diese
|
|
|
|
|
// Zusage ist zu wichtig, um sie an einer Voreinstellung haengen zu lassen.
|
|
|
|
|
if (!userVerified) throw new PasskeyFehler('nicht-verifiziert')
|
|
|
|
|
|
|
|
|
|
const schonDa = await db.passkey.findUnique({
|
|
|
|
|
where: { credentialId: credential.id },
|
|
|
|
|
select: { id: true },
|
|
|
|
|
})
|
|
|
|
|
if (schonDa) throw new PasskeyFehler('bereits-vorhanden')
|
|
|
|
|
|
|
|
|
|
return db.passkey.create({
|
|
|
|
|
data: {
|
|
|
|
|
userId: benutzer.id,
|
|
|
|
|
credentialId: credential.id,
|
|
|
|
|
publicKey: Buffer.from(credential.publicKey),
|
|
|
|
|
counter: BigInt(credential.counter),
|
|
|
|
|
transports: credential.transports ?? [],
|
|
|
|
|
name,
|
|
|
|
|
geraeteArt: credentialDeviceType,
|
|
|
|
|
gesichert: credentialBackedUp,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Anmelden
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
export async function anmeldungVorbereiten(): Promise<PublicKeyCredentialRequestOptionsJSON> {
|
|
|
|
|
verfuegbarkeitPruefen()
|
|
|
|
|
|
|
|
|
|
// Erst aufraeumen, dann die Decke pruefen: Abgelaufenes soll nicht als
|
|
|
|
|
// laufender Versuch zaehlen.
|
|
|
|
|
await abgelaufeneAnfragenEntfernen()
|
|
|
|
|
const offen = await db.passkeyAnfrage.count({ where: { art: 'ANMELDUNG' } })
|
|
|
|
|
if (offen >= MAX_OFFENE_ANMELDUNGEN) throw new PasskeyFehler('zu-viele-anfragen')
|
|
|
|
|
|
|
|
|
|
const optionen = await generateAuthenticationOptions({
|
|
|
|
|
rpID: env.passkey.rpId,
|
|
|
|
|
// Bewusst ohne allowCredentials: Der Server soll vor der Anmeldung
|
|
|
|
|
// nicht verraten, welche Schluessel es zu einer Adresse gibt - und der
|
|
|
|
|
// Benutzer soll seine E-Mail gar nicht erst eintippen muessen. Das
|
|
|
|
|
// Geraet weiss selbst, welchen Schluessel es fuer diese Domain hat.
|
|
|
|
|
userVerification: 'required',
|
|
|
|
|
timeout: ANFRAGE_GUELTIG_SEKUNDEN * 1000,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await anfrageMerken(optionen.challenge, 'ANMELDUNG', null)
|
|
|
|
|
return optionen
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type PasskeyAnmeldung = {
|
|
|
|
|
benutzer: User
|
|
|
|
|
passkey: Passkey
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Prueft eine Anmeldeantwort vollstaendig und meldet, wem sie gehoert.
|
|
|
|
|
*
|
|
|
|
|
* Alles, was hier durchgeht, ist kryptografisch belegt: die Signatur passt
|
|
|
|
|
* zum hinterlegten oeffentlichen Schluessel, die Challenge war frisch und
|
|
|
|
|
* genau einmal gueltig, die Domain stimmt, und das Geraet hat den Benutzer
|
|
|
|
|
* verifiziert.
|
|
|
|
|
*
|
|
|
|
|
* Rate-Limiting und Audit-Log passieren NICHT hier, sondern beim Aufrufer -
|
|
|
|
|
* dort ist bekannt, ob es eine Anmeldung oder eine erneute Bestaetigung ist.
|
|
|
|
|
*/
|
|
|
|
|
export async function anmeldungPruefen(
|
|
|
|
|
antwort: AuthenticationResponseJSON,
|
|
|
|
|
): Promise<PasskeyAnmeldung> {
|
|
|
|
|
verfuegbarkeitPruefen()
|
|
|
|
|
|
|
|
|
|
const challenge = challengeAusAntwort(antwort.response.clientDataJSON)
|
|
|
|
|
await anfrageVerbrauchen(challenge, 'ANMELDUNG')
|
|
|
|
|
|
|
|
|
|
const passkey = await db.passkey.findUnique({
|
|
|
|
|
where: { credentialId: antwort.id },
|
|
|
|
|
include: { user: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!passkey) throw new PasskeyFehler('unbekannt')
|
|
|
|
|
|
|
|
|
|
let ergebnis
|
|
|
|
|
try {
|
|
|
|
|
ergebnis = await verifyAuthenticationResponse({
|
|
|
|
|
response: antwort,
|
|
|
|
|
expectedChallenge: challenge,
|
|
|
|
|
expectedOrigin: env.passkey.herkunft,
|
|
|
|
|
expectedRPID: env.passkey.rpId,
|
|
|
|
|
requireUserVerification: true,
|
|
|
|
|
credential: {
|
|
|
|
|
id: passkey.credentialId,
|
|
|
|
|
publicKey: alsUint8(passkey.publicKey),
|
|
|
|
|
counter: Number(passkey.counter),
|
|
|
|
|
transports: passkey.transports as AuthenticatorTransportFuture[],
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
} catch (fehler) {
|
|
|
|
|
// Die Bibliothek meldet einen nicht weitergelaufenen Zaehler als
|
|
|
|
|
// gewoehnlichen Fehler. Der verdient eine eigene Meldung: er ist der
|
|
|
|
|
// einzige Hinweis auf einen kopierten Schluessel, den wir bekommen.
|
|
|
|
|
const text = fehler instanceof Error ? fehler.message : ''
|
|
|
|
|
if (text.includes('counter value')) throw new PasskeyFehler('geklont')
|
|
|
|
|
throw new PasskeyFehler('ungueltig')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!ergebnis.verified) throw new PasskeyFehler('ungueltig')
|
|
|
|
|
if (!ergebnis.authenticationInfo.userVerified) throw new PasskeyFehler('nicht-verifiziert')
|
|
|
|
|
|
|
|
|
|
await db.passkey.update({
|
|
|
|
|
where: { id: passkey.id },
|
|
|
|
|
data: {
|
|
|
|
|
counter: BigInt(ergebnis.authenticationInfo.newCounter),
|
|
|
|
|
zuletztBenutztAm: new Date(),
|
|
|
|
|
// Beides kann sich nachtraeglich aendern, etwa wenn der Benutzer die
|
|
|
|
|
// Schluesselbund-Synchronisierung einschaltet.
|
|
|
|
|
geraeteArt: ergebnis.authenticationInfo.credentialDeviceType,
|
|
|
|
|
gesichert: ergebnis.authenticationInfo.credentialBackedUp,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return { benutzer: passkey.user, passkey }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Verwalten und Aufraeumen
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
export type PasskeyUebersicht = {
|
|
|
|
|
id: string
|
|
|
|
|
name: string
|
|
|
|
|
geraeteArt: string | null
|
|
|
|
|
gesichert: boolean
|
|
|
|
|
erstelltAm: Date
|
|
|
|
|
zuletztBenutztAm: Date | null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function passkeysDesBenutzers(userId: string): Promise<PasskeyUebersicht[]> {
|
|
|
|
|
return db.passkey.findMany({
|
|
|
|
|
where: { userId },
|
|
|
|
|
orderBy: [{ zuletztBenutztAm: { sort: 'desc', nulls: 'last' } }, { erstelltAm: 'desc' }],
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
name: true,
|
|
|
|
|
geraeteArt: true,
|
|
|
|
|
gesichert: true,
|
|
|
|
|
erstelltAm: true,
|
|
|
|
|
zuletztBenutztAm: true,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Raeumt liegengebliebene Challenges weg; wird vom Wartungslauf aufgerufen. */
|
|
|
|
|
export async function abgelaufeneAnfragenEntfernen(): Promise<number> {
|
|
|
|
|
const { count } = await db.passkeyAnfrage.deleteMany({
|
|
|
|
|
where: { ablaufAm: { lt: new Date() } },
|
|
|
|
|
})
|
|
|
|
|
return count
|
|
|
|
|
}
|