Initial commit (unpacked platform)

This commit is contained in:
Sven (AAMOS AI)
2026-08-05 19:21:11 +07:00
commit ac5340195a
314 changed files with 57584 additions and 0 deletions
+482
View File
@@ -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 });
}