636 lines
22 KiB
TypeScript
636 lines
22 KiB
TypeScript
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";
|
||
import { z } from "zod";
|
||
import { createFirebaseTokenVerifier, type TokenVerifier } from "../lib/token-verifier.js";
|
||
|
||
/** Body-schema för Firebase-sessionväxling. */
|
||
const firebaseSessionInputSchema = z.object({
|
||
idToken: z.string().min(1),
|
||
locale: z.string().optional(),
|
||
});
|
||
|
||
/** Lat-initierad Firebase-verifierare (byggd ur base64-env). Cache per process. */
|
||
let firebaseVerifier: TokenVerifier | null = null;
|
||
function getFirebaseVerifier(app: FastifyInstance): TokenVerifier {
|
||
if (firebaseVerifier) return firebaseVerifier;
|
||
const b64 = app.config.FIREBASE_SERVICE_ACCOUNT_B64;
|
||
if (!b64) throw errors.internal("Firebase är inte konfigurerat (FIREBASE_SERVICE_ACCOUNT_B64 saknas).");
|
||
const sa = JSON.parse(Buffer.from(b64, "base64").toString("utf8")) as {
|
||
project_id: string;
|
||
client_email: string;
|
||
private_key: string;
|
||
};
|
||
firebaseVerifier = createFirebaseTokenVerifier({
|
||
projectId: sa.project_id,
|
||
clientEmail: sa.client_email,
|
||
privateKey: sa.private_key,
|
||
});
|
||
return firebaseVerifier;
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
});
|
||
});
|
||
|
||
// Firebase-sessionväxling: verifiera Firebase-ID-token -> find-or-create user -> intern session.
|
||
// Firebase äger kundinloggningen; detta är enda kopplingen till våra interna tokens.
|
||
app.post("/v1/auth/session", strictLimit, async (req, reply) => {
|
||
const input = parse(firebaseSessionInputSchema, req.body);
|
||
|
||
const identity = await getFirebaseVerifier(app)
|
||
.verify(input.idToken)
|
||
.catch(() => {
|
||
throw errors.unauthorized("Ogiltig Firebase-token.");
|
||
});
|
||
|
||
// 1) Redan kopplad via firebaseUid?
|
||
let [user] = await app.db
|
||
.select()
|
||
.from(schema.users)
|
||
.where(and(eq(schema.users.firebaseUid, identity.uid), isNull(schema.users.deletedAt)))
|
||
.limit(1);
|
||
|
||
// 2) Annars: länka befintligt konto på VERIFIERAD e-post (annars neka – undviker kontokapning).
|
||
if (!user && identity.email) {
|
||
const [byEmail] = await app.db
|
||
.select()
|
||
.from(schema.users)
|
||
.where(and(eq(schema.users.email, identity.email), isNull(schema.users.deletedAt)))
|
||
.limit(1);
|
||
if (byEmail) {
|
||
if (!identity.emailVerified) throw errors.forbidden("E-postadressen måste vara verifierad.");
|
||
if (byEmail.firebaseUid && byEmail.firebaseUid !== identity.uid)
|
||
throw errors.conflict("Kontot är redan kopplat till en annan inloggning.");
|
||
[user] = await app.db
|
||
.update(schema.users)
|
||
.set({ firebaseUid: identity.uid, emailVerifiedAt: byEmail.emailVerifiedAt ?? new Date() })
|
||
.where(eq(schema.users.id, byEmail.id))
|
||
.returning();
|
||
}
|
||
}
|
||
|
||
// 3) Skapa nytt konto (inget internt lösenord – Firebase äger inloggningen).
|
||
if (!user) {
|
||
if (!identity.email) throw errors.badRequest("Firebase-token saknar e-postadress.");
|
||
const locale = input.locale ?? "sv-SE";
|
||
const displayName = identity.name ?? identity.email.split("@")[0] ?? "Användare";
|
||
[user] = await app.db
|
||
.insert(schema.users)
|
||
.values({
|
||
email: identity.email,
|
||
displayName,
|
||
locale,
|
||
firebaseUid: identity.uid,
|
||
emailVerifiedAt: identity.emailVerified ? new Date() : null,
|
||
})
|
||
.returning();
|
||
if (!user) throw errors.internal();
|
||
await app.db.insert(schema.userPreferences).values({ userId: user.id }).onConflictDoNothing();
|
||
await app.db
|
||
.insert(schema.userHealthProfiles)
|
||
.values({ userId: user.id })
|
||
.onConflictDoNothing();
|
||
const consentNow = new Date();
|
||
await app.db
|
||
.insert(schema.userConsents)
|
||
.values({
|
||
userId: user.id,
|
||
kind: "product_analytics",
|
||
status: "granted",
|
||
grantedAt: consentNow,
|
||
updatedAt: consentNow,
|
||
})
|
||
.onConflictDoNothing();
|
||
const regionFromLocale = locale.split("-")[1]?.toUpperCase();
|
||
const localeDefaults = regionFromLocale
|
||
? localeDefaultsForRegion(regionFromLocale)
|
||
: DEFAULT_LOCALE_PREFERENCES;
|
||
await app.db
|
||
.insert(schema.userLocalePreferences)
|
||
.values({
|
||
userId: user.id,
|
||
languageTag: locale,
|
||
regionCode: localeDefaults.regionCode,
|
||
timeZone: localeDefaults.timeZone,
|
||
measurementSystem: localeDefaults.measurementSystem,
|
||
temperatureUnit: localeDefaults.temperatureUnit,
|
||
currencyCode: localeDefaults.currencyCode,
|
||
})
|
||
.onConflictDoNothing();
|
||
await audit(app.db, {
|
||
actorUserId: user.id,
|
||
action: "auth.firebase_provision",
|
||
ip: req.ip,
|
||
correlationId: req.correlationId,
|
||
});
|
||
}
|
||
|
||
if (!user) throw errors.internal();
|
||
|
||
await audit(app.db, {
|
||
actorUserId: user.id,
|
||
action: "auth.firebase_session",
|
||
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,
|
||
});
|
||
});
|
||
|
||
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 });
|
||
}
|