Files
Cibello-app/apps/api/src/routes/auth.ts
T

501 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 });
}