diff --git a/.env b/.env index 29afc948..12f7be79 100644 --- a/.env +++ b/.env @@ -4,6 +4,8 @@ # Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB. # See the documentation for all the connection string options: https://pris.ly/d/connection-strings +SECRET="jg57cya4mrNkGlnb2R85X1gI8LdY7iNZ9MF0Jbn0K5zQBshOxv" + POSTGRES_DB=main POSTGRES_HOST=localhost POSTGRES_PORT=5432 diff --git a/src/astro-typesafe-api-caller.ts b/src/astro-typesafe-api-caller.ts index 0432563b..1d1730fb 100644 --- a/src/astro-typesafe-api-caller.ts +++ b/src/astro-typesafe-api-caller.ts @@ -12,31 +12,32 @@ export const createCaller = createCallerFactory({ "admin/nicht-ausstellen": await import("../src/pages/api/admin/nicht-ausstellen.ts"), "admin/registriernummer": await import("../src/pages/api/admin/registriernummer.ts"), "admin/stornieren": await import("../src/pages/api/admin/stornieren.ts"), - "ausweise": await import("../src/pages/api/ausweise/index.ts"), - "bedarfsausweis-gewerbe/[id]": await import("../src/pages/api/bedarfsausweis-gewerbe/[id].ts"), - "bedarfsausweis-gewerbe": await import("../src/pages/api/bedarfsausweis-gewerbe/index.ts"), "auth/access-token": await import("../src/pages/api/auth/access-token.ts"), "auth/passwort-vergessen": await import("../src/pages/api/auth/passwort-vergessen.ts"), "auth/refresh-token": await import("../src/pages/api/auth/refresh-token.ts"), + "auth/verify": await import("../src/pages/api/auth/verify.ts"), + "ausweise": await import("../src/pages/api/ausweise/index.ts"), + "bedarfsausweis-gewerbe/[id]": await import("../src/pages/api/bedarfsausweis-gewerbe/[id].ts"), + "bedarfsausweis-gewerbe": await import("../src/pages/api/bedarfsausweis-gewerbe/index.ts"), + "aufnahme": await import("../src/pages/api/aufnahme/index.ts"), "bedarfsausweis-wohnen/[id]": await import("../src/pages/api/bedarfsausweis-wohnen/[id].ts"), "bedarfsausweis-wohnen": await import("../src/pages/api/bedarfsausweis-wohnen/index.ts"), - "aufnahme": await import("../src/pages/api/aufnahme/index.ts"), "bilder/[id]": await import("../src/pages/api/bilder/[id].ts"), "geg-nachweis-gewerbe/[id]": await import("../src/pages/api/geg-nachweis-gewerbe/[id].ts"), "geg-nachweis-gewerbe": await import("../src/pages/api/geg-nachweis-gewerbe/index.ts"), "geg-nachweis-wohnen/[id]": await import("../src/pages/api/geg-nachweis-wohnen/[id].ts"), "geg-nachweis-wohnen": await import("../src/pages/api/geg-nachweis-wohnen/index.ts"), "objekt": await import("../src/pages/api/objekt/index.ts"), - "ticket": await import("../src/pages/api/ticket/index.ts"), - "user": await import("../src/pages/api/user/index.ts"), - "user/self": await import("../src/pages/api/user/self.ts"), "rechnung/[id]": await import("../src/pages/api/rechnung/[id].ts"), "rechnung/anfordern": await import("../src/pages/api/rechnung/anfordern.ts"), "rechnung": await import("../src/pages/api/rechnung/index.ts"), - "verbrauchsausweis-wohnen/[id]": await import("../src/pages/api/verbrauchsausweis-wohnen/[id].ts"), - "verbrauchsausweis-wohnen": await import("../src/pages/api/verbrauchsausweis-wohnen/index.ts"), + "ticket": await import("../src/pages/api/ticket/index.ts"), "verbrauchsausweis-gewerbe/[id]": await import("../src/pages/api/verbrauchsausweis-gewerbe/[id].ts"), "verbrauchsausweis-gewerbe": await import("../src/pages/api/verbrauchsausweis-gewerbe/index.ts"), + "user": await import("../src/pages/api/user/index.ts"), + "user/self": await import("../src/pages/api/user/self.ts"), + "verbrauchsausweis-wohnen/[id]": await import("../src/pages/api/verbrauchsausweis-wohnen/[id].ts"), + "verbrauchsausweis-wohnen": await import("../src/pages/api/verbrauchsausweis-wohnen/index.ts"), "webhooks/mollie": await import("../src/pages/api/webhooks/mollie.ts"), "aufnahme/[id]/bilder": await import("../src/pages/api/aufnahme/[id]/bilder.ts"), "aufnahme/[id]": await import("../src/pages/api/aufnahme/[id]/index.ts"), diff --git a/src/lib/pin.ts b/src/lib/pin.ts new file mode 100644 index 00000000..66ef666a --- /dev/null +++ b/src/lib/pin.ts @@ -0,0 +1,59 @@ +import crypto from "crypto"; +import { SECRET } from "./server/constants.js"; + +// Convert integer to 8-byte Buffer (big-endian) +function intToBuffer(i: number): Buffer { + const buf = Buffer.alloc(8); + buf.writeBigUInt64BE(BigInt(i)); + return buf; +} + +// Derive a stable key per email +function deriveKey(email: string): Buffer { + return crypto.createHmac("sha256", Buffer.from(SECRET, "utf8")) + .update(email) + .digest(); +} + +/** + * Erstellt eine 4-stellige PIN. + * @param email - Die Email-Adresse, für die die PIN generiert wird + * @param forTime - Der Zeitpunkt, für den die PIN generiert wird (Standard: aktueller Zeitpunkt) + * @param validSeconds - Die Anzahl der Sekunden, für die die PIN gültig ist (Standard: 1800 Sekunden = 30 Minuten) + * @returns Die generierte PIN + */ +export function generatePinCode(email: string, forTime: number = Math.floor(Date.now() / 1000), validSeconds: number = 1800): string { + const timestep = Math.floor(forTime / validSeconds); + console.log(timestep, forTime, validSeconds, Date.now()); + + const key = deriveKey(email); + const msg = intToBuffer(timestep); + + const mac = crypto.createHmac("sha256", key).update(msg).digest(); + + // Dynamic truncation (RFC4226-style) + const offset = mac[mac.length - 1] & 0x0f; + const codeInt = (mac.readUInt32BE(offset) & 0x7fffffff) % 10000; + return codeInt.toString().padStart(4, "0"); +} + +/** + * Verifiziert eine PIN für eine Email-Adresse. + * @param email - Die Email-Adresse, für die die PIN verifiziert wird + * @param code - Die zu verifizierende PIN + * @param validSeconds - Die Anzahl der Sekunden, für die die PIN gültig ist (Standard: 1800 Sekunden = 30 Minuten) + * @param allowedWindows - Die Anzahl der erlaubten Zeitfenster für Zeitabweichungen (Standard: 1, erlaubt ±1 Fenster) + * @returns true, wenn die PIN gültig ist, sonst false + */ +export function verifyCode(email: string, code: string, validSeconds: number = 1800, allowedWindows: number = 1): boolean { + const now = Math.floor(Date.now() / 1000); + + for (let w = -allowedWindows; w <= allowedWindows; w++) { + const time = now + w * validSeconds; + if (generatePinCode(email, time, validSeconds) === code) { + return true; + } + } + + return false; +} \ No newline at end of file diff --git a/src/lib/server/constants.ts b/src/lib/server/constants.ts index 343b3c96..f0562041 100644 --- a/src/lib/server/constants.ts +++ b/src/lib/server/constants.ts @@ -2,6 +2,7 @@ import os from "os" import fs from "fs" export const PERSISTENT_DIR = `${os.homedir()}/persistent/online-energieausweis` +export const SECRET = process.env.SECRET as string if (!fs.existsSync(PERSISTENT_DIR)) { fs.mkdirSync(PERSISTENT_DIR, {recursive: true}) diff --git a/src/lib/server/mail/registrierung.ts b/src/lib/server/mail/registrierung.ts index 5eb476ae..f92da0b5 100644 --- a/src/lib/server/mail/registrierung.ts +++ b/src/lib/server/mail/registrierung.ts @@ -5,6 +5,8 @@ import { } from "#lib/client/prisma.js"; import { encodeToken } from "#lib/auth/token.js"; import { TokenType } from "#lib/auth/types.js"; +import { generatePinCode } from "#lib/pin.js"; +import { logger } from "#lib/logger.js"; export async function sendRegisterMail( user: Benutzer @@ -15,6 +17,13 @@ export async function sendRegisterMail( id: user.id }) + // Bei der Registrierung soll ein Code an die Email des Benutzers geschickt werden. + // Der Benutzer muss diesen Code dann eingeben, um seine Email zu verifizieren. + // Bis dahin kann er sich nicht einloggen. + // Diese Pin soll nach 30 Minuten ablaufen, wir können sie ganz einfach erstellen indem wir einen zeitbasierten Hash der Email generieren. + const pin = generatePinCode(user.email); + logger.info(`Generated PIN for ${user.email}: ${pin}`); + await transport.sendMail({ from: `"IBCornelsen" `, to: user.email, @@ -26,6 +35,13 @@ export async function sendRegisterMail( Um Ihre Registrierung abzuschließen, klicken Sie bitte auf den folgenden Link, um Ihre E-Mail-Adresse zu bestätigen:

E-Mail-Adresse bestätigen

+

Alternativ können Sie den folgenden Bestätigungscode verwenden:

+ ${pin}

+ Dieser Code ist 30 Minuten gültig.

+ + Sollten Sie diese Registrierung nicht vorgenommen haben, können Sie diese E-Mail einfach ignorieren. Ihr Benutzerkonto wird in diesem Fall nicht aktiviert.

+ + Falls Sie Fragen haben oder Unterstützung benötigen, stehen wir Ihnen gerne zur Verfügung. Kontaktieren Sie uns einfach unter support@online-energieausweis.org.

Mit freundlichen Grüßen,
diff --git a/src/modules/EmbeddedAuthFlowModule.svelte b/src/modules/EmbeddedAuthFlowModule.svelte index 7637e0d4..75878b07 100644 --- a/src/modules/EmbeddedAuthFlowModule.svelte +++ b/src/modules/EmbeddedAuthFlowModule.svelte @@ -2,27 +2,27 @@ import { loginClient } from "#lib/login.js"; import EmbeddedLoginModule from "./EmbeddedLoginModule.svelte" import EmbeddedRegisterModule from "./EmbeddedRegisterModule.svelte" + import EmbeddedVerifyModule from "./EmbeddedVerifyModule.svelte"; export let onLogin: (response: Awaited>) => any; export let email: string = ""; export let password: string = ""; - export let route: "login" | "signup" = "login" + export let route: "login" | "signup" | "verify" = "login" const navigate = (target: string) => { route = target as typeof route; } - - const loginData = { - email, - passwort: "", - } {#if route == "login"} -{:else} +{:else if route == "signup"} { email = response.email + navigate("verify") + }} {navigate} /> +{:else if route == "verify"} + { navigate("login") }} {navigate} /> {/if} \ No newline at end of file diff --git a/src/modules/EmbeddedRegisterModule.svelte b/src/modules/EmbeddedRegisterModule.svelte index 1d16a668..508d3c9d 100644 --- a/src/modules/EmbeddedRegisterModule.svelte +++ b/src/modules/EmbeddedRegisterModule.svelte @@ -122,7 +122,7 @@

- + Passwort Vergessen?
diff --git a/src/modules/EmbeddedVerifyModule.svelte b/src/modules/EmbeddedVerifyModule.svelte new file mode 100644 index 00000000..182c5276 --- /dev/null +++ b/src/modules/EmbeddedVerifyModule.svelte @@ -0,0 +1,130 @@ + + +
+

Verifizieren

+

Bitte geben Sie die 4-stellige PIN ein, den Sie per E-Mail erhalten haben.

+
+
+ { + handleInput(e, 0); + }} /> + { + handleInput(e, 1); + }} /> + { + handleInput(e, 2); + }} /> + { + handleInput(e, 3); + }} /> +
+ +
+ +
+ PIN erneut anfordern +
+
\ No newline at end of file diff --git a/src/modules/PINVerifyModule.svelte b/src/modules/PINVerifyModule.svelte new file mode 100644 index 00000000..f3e2c103 --- /dev/null +++ b/src/modules/PINVerifyModule.svelte @@ -0,0 +1,145 @@ + + +
+

Verifizieren

+

Bitte geben Sie die 4-stellige PIN ein, den Sie per E-Mail erhalten haben.

+
+
+ { + handleInput(e, 0); + }} /> + { + handleInput(e, 1); + }} /> + { + handleInput(e, 2); + }} /> + { + handleInput(e, 3); + }} /> +
+ +
+ +
+ PIN erneut anfordern +
+ +
\ No newline at end of file diff --git a/src/modules/RegisterModule.svelte b/src/modules/RegisterModule.svelte index d7fd0f4a..1e165562 100644 --- a/src/modules/RegisterModule.svelte +++ b/src/modules/RegisterModule.svelte @@ -42,11 +42,11 @@ }) if (redirect) { - window.location.href = redirect + window.location.href = `/auth/verify-pin?e=${encodeURIComponent(email)}&r=${redirect}` return } - window.location.href = "/auth/login"; + window.location.href = "/auth/verify-pin?e=" + encodeURIComponent(email); } catch (e) { errorHidden = false; } diff --git a/src/pages/api/auth/verify.ts b/src/pages/api/auth/verify.ts new file mode 100644 index 00000000..a0e1486d --- /dev/null +++ b/src/pages/api/auth/verify.ts @@ -0,0 +1,65 @@ +import { z } from "zod"; +import { prisma } from "#lib/server/prisma.js"; +import { APIError, defineApiRoute } from "astro-typesafe-api/server"; +import { verifyCode } from "#lib/pin.js"; +import { logger } from "#lib/logger.js"; + +export const PUT = defineApiRoute({ + meta: { + description: + "Erstellt, basierend auf einem existierenden und gültigen Refresh Tokens, einen neuen Access Token, welcher zur Authentifizierung genutzt werden kann. Der resultierende Access Token ist nur 2 Tage gültig und muss danach neu generiert werden. Diese Funktion gibt ebenfalls einen neuen Refresh Token zurück, der alte wird dadurch invalidiert.", + tags: ["Benutzer"], + summary: "Access Token anfragen.", + }, + input: z.object({ + pin: z.string(), + email: z.string(), + }), + output: z.object({ + verified: z.boolean(), + }), + + async fetch(input, ctx) { + const valid = verifyCode(input.email, input.pin); + if (!valid) { + throw new APIError({ + code: "BAD_REQUEST", + message: "Die gegebene PIN ist nicht gültig.", + }); + } + + const user = await prisma.benutzer.findUnique({ + where: { + email: input.email, + }, + }); + + if (!user) { + throw new APIError({ + code: "BAD_REQUEST", + message: "Die gegebene PIN ist nicht gültig.", + }); + } + + logger.info(`Verified ${input.email}`); + + if (user.verified) { + return { + verified: true, + }; + } + + await prisma.benutzer.update({ + where: { + id: user.id, + }, + data: { + verified: true, + }, + }); + + return { + verified: true, + }; + }, +}); diff --git a/src/pages/auth/request-pin.astro b/src/pages/auth/request-pin.astro new file mode 100644 index 00000000..82a94684 --- /dev/null +++ b/src/pages/auth/request-pin.astro @@ -0,0 +1,95 @@ +--- +import MinimalLayout from "#layouts/MinimalLayout.astro"; +import { validateAccessTokenServer } from "#server/lib/validateAccessToken"; +import PINVerifyModule from "#modules/PINVerifyModule.svelte"; +import { generatePinCode } from "#lib/pin"; +import { logger } from "#lib/logger"; +import { transport } from "#lib/mail"; +import { prisma } from "#lib/server/prisma"; +import { TokenType } from "#lib/auth/types"; +import { encodeToken } from "#lib/auth/token"; +import { BASE_URI } from "#lib/constants"; + +const valid = await validateAccessTokenServer(Astro) + +if (valid) { + return Astro.redirect("/dashboard") +} + +const redirect = Astro.url.searchParams.get("r"); +const email = Astro.url.searchParams.get("e"); + +if (!email) { + return Astro.redirect("/auth/signup") +} + +const user = await prisma.benutzer.findUnique({ + where: { + email: email + } +}) + +if (!user) { + // Der Nutzer existiert nicht, weiter zur Registrierung + return Astro.redirect("/auth/signup") +} + +if (user?.verified) { + // Der Nutzer ist bereits verifiziert, wir können ihn auch direkt zum Login weiterleiten + return Astro.redirect(redirect ?? "/auth/login") +} + +const verificationJwt = encodeToken({ + typ: TokenType.Verify, + exp: Date.now() + (15 * 60 * 1000), + id: user.id +}) + +const pin = generatePinCode(email); // 30 Minuten +logger.info(`Generated PIN ${email}: ${pin}`); + +await transport.sendMail({ + from: `"IBCornelsen" `, + to: email, + subject: `Ihre Registrierung bei IBCornelsen - Bitte bestätigen Sie Ihre E-Mail-Adresse`, + bcc: "info@online-energieausweis.org", + html: `

Sehr geehrte*r ${user.vorname} ${user.name},

+

vielen Dank für Ihre Registrierung bei IBCornelsen. Ihr Benutzerkonto wurde erfolgreich erstellt.

+ + Um Ihre Registrierung abzuschließen, klicken Sie bitte auf den folgenden Link, um Ihre E-Mail-Adresse zu bestätigen:

+ + E-Mail-Adresse bestätigen

+

Alternativ können Sie den folgenden Bestätigungscode verwenden:

+ ${pin}

+ Dieser Code ist 30 Minuten gültig.

+ + Sollten Sie diese Registrierung nicht vorgenommen haben, können Sie diese E-Mail einfach ignorieren. Ihr Benutzerkonto wird in diesem Fall nicht aktiviert.

+ + Falls Sie Fragen haben oder Unterstützung benötigen, stehen wir Ihnen gerne zur Verfügung. Kontaktieren Sie uns einfach unter support@online-energieausweis.org. +

+ Mit freundlichen Grüßen, +
+ Dipl.-Ing. Jens Cornelsen +
+
+ IB Cornelsen +
+ Katendeich 5A +
+ 21035 Hamburg +
+ www.online-energieausweis.org +
+
+ fon 040 · 209339850 +
+ fax 040 · 209339859 +

` + }); + + +--- + + + + diff --git a/src/pages/auth/verify-pin.astro b/src/pages/auth/verify-pin.astro new file mode 100644 index 00000000..bd0ca1a6 --- /dev/null +++ b/src/pages/auth/verify-pin.astro @@ -0,0 +1,39 @@ +--- +import MinimalLayout from "#layouts/MinimalLayout.astro"; +import { validateAccessTokenServer } from "#server/lib/validateAccessToken"; +import PINVerifyModule from "#modules/PINVerifyModule.svelte"; +import { prisma } from "#lib/server/prisma"; + +const valid = await validateAccessTokenServer(Astro) + +if (valid) { + return Astro.redirect("/dashboard") +} + +const redirect = Astro.url.searchParams.get("r"); +const email = Astro.url.searchParams.get("e"); + +if (!email) { + return Astro.redirect("/auth/signup") +} + +const user = await prisma.benutzer.findUnique({ + where: { + email: email + } +}) + +if (!user) { + // Der Nutzer existiert nicht, weiter zur Registrierung + return Astro.redirect("/auth/signup") +} + +if (user?.verified) { + // Der Nutzer ist bereits verifiziert, wir können ihn auch direkt zum Login weiterleiten + return Astro.redirect(redirect ?? "/auth/login") +} +--- + + + + diff --git a/src/pages/auth/verify.astro b/src/pages/auth/verify.astro index ce009659..6838308f 100644 --- a/src/pages/auth/verify.astro +++ b/src/pages/auth/verify.astro @@ -12,13 +12,13 @@ if (!token) { const payload = decodeToken(token) -if (payload.typ !== TokenType.Verify || !payload.uid || !payload.exp || payload.exp < Date.now()) { +if (payload.typ !== TokenType.Verify || !payload.id || !payload.exp || payload.exp < Date.now()) { return Astro.redirect("/") } await prisma.benutzer.update({ where: { - uid: payload.uid + id: payload.id }, data: { verified: true diff --git a/src/pages/energieausweis-erstellen/[ausweisart]/index.astro b/src/pages/energieausweis-erstellen/[ausweisart]/index.astro index e8b5174e..b7a31947 100644 --- a/src/pages/energieausweis-erstellen/[ausweisart]/index.astro +++ b/src/pages/energieausweis-erstellen/[ausweisart]/index.astro @@ -59,10 +59,13 @@ let loadFromDatabase = false; if (typ === AusstellungsTyp.Neuausstellung) { if (!user) { + // Der Nutzer muss eingeloggt sein um eine Neuausstellung anzufordern, + // sonst können wir nicht sicher sein, dass der Nutzer berechtigt ist auf den Ausweis zuzugreifen. return Astro.redirect(`/auth/login?r=${Astro.url.toString()}`); } if (!ausweis_id) { + // Falls es keine Ausweis ID gibt können wir nicht fortfahren. return Astro.redirect("/400"); } @@ -97,9 +100,10 @@ if (typ === AusstellungsTyp.Neuausstellung) { return Astro.redirect("/405"); } - ausweis.id = null; - aufnahme.id = null; - delete aufnahme.erstellungsdatum; + // Wir setzen alle Daten vom Ausweis zurück, sonst könnte es passieren, dass der Ausweis als der alte Ausweis gespeichert wird. + ausweis.id = ""; + aufnahme.id = ""; + aufnahme.erstellungsdatum = null; ausweis.created_at = new Date() ausweis.updated_at = new Date(); ausweis.alte_ausweis_id = null; @@ -132,6 +136,11 @@ if (typ === AusstellungsTyp.Neuausstellung) { ausweis = await getBedarfsausweisWohnen(ausweis_id); } + if (!ausweis) { + // Falls der Ausweis nicht gefunden wurde, können wir nicht fortfahren. + return Astro.redirect("/404"); + } + ausweistyp = ausweis.ausweistyp; aufnahme = (await getAufnahme(ausweis.aufnahme_id)) as Aufnahme; @@ -192,8 +201,8 @@ if (typ === AusstellungsTyp.Neuausstellung) { return Astro.redirect("/405"); } - ausweis.id = null; - delete aufnahme.erstellungsdatum; + ausweis.id = ""; + aufnahme.erstellungsdatum = null; ausweis.created_at = new Date() ausweis.updated_at = new Date(); ausweis.alte_ausweis_id = null;