import type { FastifyInstance } from "fastify"; import { randomBytes, randomUUID } from "node:crypto"; import { and, eq, isNull } from "drizzle-orm"; import { schema } from "@app/database"; import { loginInputSchema, refreshInputSchema, registerInputSchema, changePasswordInputSchema, forgotPasswordInputSchema, resetPasswordInputSchema, verifyEmailInputSchema, totpVerifyInputSchema, } from "@app/validation"; import { trialEndsAt } from "@app/subscriptions"; import { BRAND, DEFAULT_LOCALE_PREFERENCES, localeDefaultsForRegion } from "@app/shared-types"; import { renderMail } from "../lib/mailer.js"; import { loadLocalePreferences } from "../lib/localeContext.js"; import { errors, parse } from "../lib/errors.js"; import { hashPassword, verifyPassword } from "../lib/passwords.js"; import { verifyTotp } from "../lib/totp.js"; import { audit, sha256 } from "../lib/helpers.js"; /** * Auth: registrering, inloggning, roterande refresh-tokens, utloggning. * Refresh-tokens lagras hashade; återanvändning av roterad token * ogiltigförklarar hela familjen (token theft detection). */ export async function authRoutes(app: FastifyInstance) { const strictLimit = { config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }; app.post("/v1/auth/register", strictLimit, async (req, reply) => { const input = parse(registerInputSchema, req.body); const [existing] = await app.db .select({ id: schema.users.id }) .from(schema.users) .where(eq(schema.users.email, input.email)) .limit(1); if (existing) throw errors.conflict("E-postadressen är redan registrerad."); const [user] = await app.db .insert(schema.users) .values({ email: input.email, displayName: input.displayName, locale: input.locale }) .returning(); if (!user) throw errors.internal(); await app.db .insert(schema.userCredentials) .values({ userId: user.id, passwordHash: await hashPassword(input.password) }); await app.db.insert(schema.userPreferences).values({ userId: user.id }).onConflictDoNothing(); await app.db .insert(schema.userHealthProfiles) .values({ userId: user.id }) .onConflictDoNothing(); // Product analytics: legitimate interest with opt-out (spec §8, §33). const consentNow = new Date(); await app.db.insert(schema.userConsents).values({ userId: user.id, kind: "product_analytics", status: "granted", grantedAt: consentNow, updatedAt: consentNow, }); await audit(app.db, { actorUserId: user.id, action: "auth.register", ip: req.ip, correlationId: req.correlationId, }); // D-031: enhetens språk följer med registreringen → locale-preferenser skapas // FÖRE välkomstmejlet, så att spanjorens första mejl kommer på spanska. const regionFromLocale = input.locale.split("-")[1]?.toUpperCase(); const localeDefaults = regionFromLocale ? localeDefaultsForRegion(regionFromLocale) : DEFAULT_LOCALE_PREFERENCES; await app.db .insert(schema.userLocalePreferences) .values({ userId: user.id, languageTag: input.locale, regionCode: localeDefaults.regionCode, timeZone: localeDefaults.timeZone, measurementSystem: localeDefaults.measurementSystem, temperatureUnit: localeDefaults.temperatureUnit, currencyCode: localeDefaults.currencyCode, }) .onConflictDoNothing(); // E-postverifiering (icke-blockerande): mejlet skickas, kontot fungerar direkt. await sendVerificationMail(app, user.id, user.email); const tokens = await issueTokens(app, user.id, user.role, req.headers["user-agent"], req.ip); return reply.status(201).send({ user: { id: user.id, email: user.email, displayName: user.displayName, role: user.role }, ...tokens, }); }); app.post("/v1/auth/login", strictLimit, async (req, reply) => { const input = parse(loginInputSchema, req.body); const [user] = await app.db .select() .from(schema.users) .where(and(eq(schema.users.email, input.email), isNull(schema.users.deletedAt))) .limit(1); const [creds] = user ? await app.db .select() .from(schema.userCredentials) .where(eq(schema.userCredentials.userId, user.id)) .limit(1) : []; const ok = user && creds ? await verifyPassword(input.password, creds.passwordHash) : false; if (!ok || !user) { // Konstant svar oavsett om kontot finns – ingen user enumeration. throw errors.unauthorized("Fel e-post eller lösenord."); } // Admin-2FA (step-up): rätt lösenord räcker inte om TOTP är aktiverat. const [totp] = await app.db .select() .from(schema.adminTotp) .where(eq(schema.adminTotp.userId, user.id)) .limit(1); if (totp?.enabledAt) { const preAuthToken = app.jwt.sign( { sub: user.id, type: "preauth" }, { expiresIn: 300 }, // 5 minuter att ange koden ); await audit(app.db, { actorUserId: user.id, action: "auth.login_totp_required", ip: req.ip, correlationId: req.correlationId, }); return reply.send({ totpRequired: true, preAuthToken }); } await audit(app.db, { actorUserId: user.id, action: "auth.login", ip: req.ip, correlationId: req.correlationId, }); const tokens = await issueTokens(app, user.id, user.role, req.headers["user-agent"], req.ip); return reply.send({ user: { id: user.id, email: user.email, displayName: user.displayName, role: user.role }, ...tokens, }); }); /** Steg 2 av admin-inloggning: preauth-token + TOTP-kod → riktiga tokens. */ app.post("/v1/auth/totp-verify", strictLimit, async (req, reply) => { const input = parse(totpVerifyInputSchema, req.body); let payload: { sub?: string; type?: string }; try { payload = app.jwt.verify(input.preAuthToken); } catch { throw errors.unauthorized("Ogiltig eller utgången inloggning. Börja om."); } if (payload.type !== "preauth" || !payload.sub) throw errors.unauthorized(); const [totp] = await app.db .select() .from(schema.adminTotp) .where(eq(schema.adminTotp.userId, payload.sub)) .limit(1); if (!totp?.enabledAt) throw errors.unauthorized(); const step = verifyTotp(totp.secretBase32, input.code); if (step == null || (totp.lastUsedStep != null && step <= totp.lastUsedStep)) { await audit(app.db, { actorUserId: payload.sub, action: "auth.totp_failed", ip: req.ip, }); throw errors.unauthorized("Fel engångskod."); } await app.db .update(schema.adminTotp) .set({ lastUsedStep: step }) .where(eq(schema.adminTotp.userId, payload.sub)); const [user] = await app.db .select() .from(schema.users) .where(and(eq(schema.users.id, payload.sub), isNull(schema.users.deletedAt))) .limit(1); if (!user) throw errors.unauthorized(); await audit(app.db, { actorUserId: user.id, action: "auth.login", metadata: { mfa: true }, ip: req.ip, correlationId: req.correlationId, }); const tokens = await issueTokens(app, user.id, user.role, req.headers["user-agent"], req.ip, { mfa: true, }); return reply.send({ user: { id: user.id, email: user.email, displayName: user.displayName, role: user.role }, ...tokens, }); }); /** Bekräfta e-postadress (länken i välkomstmejlet). Engångs, 24 h TTL. */ app.post("/v1/auth/verify-email", strictLimit, async (req, reply) => { const input = parse(verifyEmailInputSchema, req.body); const [stored] = await app.db .select() .from(schema.emailVerificationTokens) .where(eq(schema.emailVerificationTokens.tokenHash, sha256(input.token))) .limit(1); if (!stored || stored.usedAt || stored.expiresAt < new Date()) { throw errors.unauthorized("Ogiltig eller utgången verifieringslänk. Begär en ny."); } await app.db .update(schema.emailVerificationTokens) .set({ usedAt: new Date() }) .where(eq(schema.emailVerificationTokens.id, stored.id)); await app.db .update(schema.users) .set({ emailVerifiedAt: new Date(), updatedAt: new Date() }) .where(eq(schema.users.id, stored.userId)); // Trial startas först när e-posten är verifierad. En trial per verifierad e-post. const [existingTrial] = await app.db .select({ userId: schema.trials.userId }) .from(schema.trials) .where(eq(schema.trials.userId, stored.userId)) .limit(1); if (!existingTrial) { const now = new Date(); await app.db .insert(schema.trials) .values({ userId: stored.userId, startedAt: now, endsAt: trialEndsAt(now) }); } await audit(app.db, { actorUserId: stored.userId, action: "auth.email_verified", ip: req.ip }); return reply.send({ ok: true }); }); /** Skicka nytt verifieringsmejl (inloggad, ej redan verifierad). */ app.post( "/v1/auth/resend-verification", { preHandler: [app.authenticate], ...strictLimit }, async (req, reply) => { const [user] = await app.db .select({ email: schema.users.email, verifiedAt: schema.users.emailVerifiedAt }) .from(schema.users) .where(eq(schema.users.id, req.userId)) .limit(1); if (!user) throw errors.unauthorized(); if (!user.verifiedAt) await sendVerificationMail(app, req.userId, user.email); return reply.send({ ok: true }); }, ); app.post("/v1/auth/refresh", strictLimit, async (req, reply) => { const input = parse(refreshInputSchema, req.body); const tokenHash = sha256(input.refreshToken); const [stored] = await app.db .select() .from(schema.refreshTokens) .where(eq(schema.refreshTokens.tokenHash, tokenHash)) .limit(1); if (!stored) throw errors.unauthorized("Ogiltig refresh-token."); if (stored.revokedAt) { // Token-återanvändning → hela familjen ogiltigförklaras. await app.db .update(schema.refreshTokens) .set({ revokedAt: new Date() }) .where(eq(schema.refreshTokens.familyId, stored.familyId)); await audit(app.db, { actorUserId: stored.userId, action: "auth.refresh_reuse_detected", metadata: { familyId: stored.familyId }, ip: req.ip, }); throw errors.unauthorized("Sessionen har återkallats. Logga in igen."); } if (stored.expiresAt < new Date()) throw errors.unauthorized("Sessionen har gått ut."); const [user] = await app.db .select() .from(schema.users) .where(and(eq(schema.users.id, stored.userId), isNull(schema.users.deletedAt))) .limit(1); if (!user) throw errors.unauthorized(); // Rotera: revokera gamla, utfärda ny i samma familj. const next = await issueTokens( app, user.id, user.role, req.headers["user-agent"], req.ip, stored.familyId, ); await app.db .update(schema.refreshTokens) .set({ revokedAt: new Date(), replacedByTokenId: next.refreshTokenId }) .where(eq(schema.refreshTokens.id, stored.id)); return reply.send({ accessToken: next.accessToken, refreshToken: next.refreshToken, accessTokenExpiresIn: next.accessTokenExpiresIn, }); }); app.post("/v1/auth/logout", { preHandler: [app.authenticate] }, async (req, reply) => { await app.db .update(schema.refreshTokens) .set({ revokedAt: new Date() }) .where( and(eq(schema.refreshTokens.userId, req.userId), isNull(schema.refreshTokens.revokedAt)), ); await audit(app.db, { actorUserId: req.userId, action: "auth.logout", ip: req.ip }); return reply.send({ ok: true }); }); /** * Lösenordsåterställning steg 1 (spec §56: säker kontohantering). * Svarar ALLTID { ok: true } – avslöjar aldrig om kontot finns * (anti-enumeration). Token: 32 slumpbytes, lagras sha256-hashad, * 30 min TTL, engångsbruk; tidigare oanvända tokens ogiltigförklaras. */ app.post("/v1/auth/forgot-password", strictLimit, async (req, reply) => { const input = parse(forgotPasswordInputSchema, req.body); const [user] = await app.db .select({ id: schema.users.id, email: schema.users.email }) .from(schema.users) .where(eq(schema.users.email, input.email)) .limit(1); if (user) { // Ogiltigförklara tidigare oanvända tokens. await app.db .update(schema.passwordResetTokens) .set({ usedAt: new Date() }) .where( and( eq(schema.passwordResetTokens.userId, user.id), isNull(schema.passwordResetTokens.usedAt), ), ); const token = randomBytes(32).toString("hex"); await app.db.insert(schema.passwordResetTokens).values({ userId: user.id, tokenHash: sha256(token), expiresAt: new Date(Date.now() + 30 * 60_000), }); const languageTag = (await loadLocalePreferences(app.db, user.id)).languageTag; const mail = renderMail("auth.password_reset", languageTag, { email: user.email, link: `${BRAND.urlScheme}://reset-password?token=${token}`, }); await app.mailer.send({ to: user.email, ...mail }); await audit(app.db, { actorUserId: user.id, action: "auth.password_reset_requested", ip: req.ip, }); } // Samma svar oavsett – och ingen tidsskillnad stor nog att mäta via rate limit. return reply.send({ ok: true }); }); /** Lösenordsåterställning steg 2: token + nytt lösenord. Engångsbruk. */ app.post("/v1/auth/reset-password", strictLimit, async (req, reply) => { const input = parse(resetPasswordInputSchema, req.body); const [stored] = await app.db .select() .from(schema.passwordResetTokens) .where(eq(schema.passwordResetTokens.tokenHash, sha256(input.token))) .limit(1); if (!stored || stored.usedAt || stored.expiresAt < new Date()) { throw errors.unauthorized("Ogiltig eller utgången återställningslänk. Begär en ny."); } await app.db .update(schema.passwordResetTokens) .set({ usedAt: new Date() }) .where(eq(schema.passwordResetTokens.id, stored.id)); await app.db .update(schema.userCredentials) .set({ passwordHash: await hashPassword(input.newPassword), passwordUpdatedAt: new Date() }) .where(eq(schema.userCredentials.userId, stored.userId)); // Logga ut ALLA sessioner – ett återställt konto börjar om från noll. await app.db .update(schema.refreshTokens) .set({ revokedAt: new Date() }) .where( and(eq(schema.refreshTokens.userId, stored.userId), isNull(schema.refreshTokens.revokedAt)), ); await audit(app.db, { actorUserId: stored.userId, action: "auth.password_reset_completed", ip: req.ip, }); return reply.send({ ok: true }); }); app.post( "/v1/auth/change-password", { preHandler: [app.authenticate], ...strictLimit }, async (req, reply) => { const input = parse(changePasswordInputSchema, req.body); const [creds] = await app.db .select() .from(schema.userCredentials) .where(eq(schema.userCredentials.userId, req.userId)) .limit(1); if (!creds || !(await verifyPassword(input.currentPassword, creds.passwordHash))) { throw errors.unauthorized("Fel nuvarande lösenord."); } await app.db .update(schema.userCredentials) .set({ passwordHash: await hashPassword(input.newPassword), passwordUpdatedAt: new Date() }) .where(eq(schema.userCredentials.userId, req.userId)); // Logga ut alla andra sessioner. await app.db .update(schema.refreshTokens) .set({ revokedAt: new Date() }) .where( and(eq(schema.refreshTokens.userId, req.userId), isNull(schema.refreshTokens.revokedAt)), ); await audit(app.db, { actorUserId: req.userId, action: "auth.change_password", ip: req.ip }); return reply.send({ ok: true }); }, ); } async function issueTokens( app: FastifyInstance, userId: string, role: string, userAgent: string | undefined, ip: string | undefined, familyIdOrOpts?: string | { mfa?: boolean }, opts?: { mfa?: boolean }, ) { const familyId = typeof familyIdOrOpts === "string" ? familyIdOrOpts : undefined; const mfa = (typeof familyIdOrOpts === "object" ? familyIdOrOpts.mfa : opts?.mfa) ?? false; const accessToken = app.jwt.sign({ sub: userId, role, type: "access", ...(mfa ? { mfa } : {}) }); const refreshToken = randomUUID() + "." + randomUUID(); const family = familyId ?? randomUUID(); const [row] = await app.db .insert(schema.refreshTokens) .values({ userId, tokenHash: sha256(refreshToken), familyId: family, expiresAt: new Date(Date.now() + app.config.JWT_REFRESH_TTL_SECONDS * 1000), userAgent: userAgent?.slice(0, 300) ?? null, ip: ip ?? null, }) .returning({ id: schema.refreshTokens.id }); return { accessToken, refreshToken, refreshTokenId: row!.id, accessTokenExpiresIn: app.config.JWT_ACCESS_TTL_SECONDS, }; } /** Skapa verifieringstoken + skicka mejl på användarens språk (24 h TTL). */ async function sendVerificationMail(app: FastifyInstance, userId: string, email: string) { await app.db .update(schema.emailVerificationTokens) .set({ usedAt: new Date() }) .where( and( eq(schema.emailVerificationTokens.userId, userId), isNull(schema.emailVerificationTokens.usedAt), ), ); const token = randomBytes(32).toString("hex"); await app.db.insert(schema.emailVerificationTokens).values({ userId, tokenHash: sha256(token), expiresAt: new Date(Date.now() + 24 * 3600_000), }); const languageTag = (await loadLocalePreferences(app.db, userId)).languageTag; const mail = renderMail("auth.verify_email", languageTag, { email, link: `${BRAND.urlScheme}://verify-email?token=${token}`, }); await app.mailer.send({ to: email, ...mail }); }