Initial commit (unpacked platform)
This commit is contained in:
@@ -0,0 +1,482 @@
|
||||
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();
|
||||
|
||||
// 7 dagars trial utan kort startar direkt (spec §46)
|
||||
const now = new Date();
|
||||
await app.db
|
||||
.insert(schema.trials)
|
||||
.values({ userId: user.id, startedAt: now, endsAt: trialEndsAt(now) });
|
||||
|
||||
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));
|
||||
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 });
|
||||
}
|
||||
Reference in New Issue
Block a user